From 0348dfe3397d6bf886038732f64c462269b8083f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 00:57:08 +0900 Subject: [PATCH 1/4] fix(tools): fail closed on global tool mutations --- AGENTS.md | 5 + CHANGELOG.md | 10 + backend/api/tools.py | 123 ++-------- backend/tests/test_tools_api.py | 418 +++++++++++++------------------- 4 files changed, 203 insertions(+), 353 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ad577447b..34ae68b8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -501,6 +501,11 @@ in this repo. configured, fail closed with `adapter_not_configured` and `provider_write_executed=false`; if an adapter is configured, wrap only the adapter's actual result in the standard runner response envelope. +- Dynamic `/api/tools` `POST`/`PATCH`/`DELETE` mutations must remain fail closed + until tool metadata and handlers are durably scoped by signed-session tenant + and workspace, restricted to an administrative role, and backed by an actual + webhook or provider execution target. Never attach a mock handler or report + successful execution when no external or local tool work occurred. - Calendar UI actions must request `/api/calendar/writeback-intent` with server-authoritative source selection and provenance. Do not wire browser actions back to legacy `/api/calendar/sync` unless a trusted backend credential diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc6297b1..d8e609fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,14 @@ ## [Unreleased] +### 도구 변경 경계 (Tool Mutation Boundary) + +- 프로세스 전역·비영속 레지스트리를 모든 인증 사용자가 변경할 수 있었던 + `POST /api/tools`, `PATCH /api/tools/{code}`, `DELETE /api/tools/{code}`를 + OpenAPI에서 숨긴 fail-closed tombstone으로 전환했습니다. 세 경로는 인증 후 + `501 tool_mutation_not_supported`를 반환하며, 요청 body를 검증하거나 레지스트리를 + 변경하거나 webhook DNS/egress를 시작하지 않습니다. webhook이 없는 사용자 정의 + 도구에 실제 작업 없이 성공을 반환하던 mock handler도 제거했습니다. 도구 목록·상세 + 조회와 기존 내장 도구 실행 계약은 변경하지 않았습니다. + ### 주제 측정 경계 (Topic Measurement) - STM 결과로 오인될 수 있었던 하드코딩 용어표 기반 diff --git a/backend/api/tools.py b/backend/api/tools.py index db3e6b218..032ec71c1 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -1,14 +1,13 @@ import base64 import hashlib import inspect -import json import logging import re import unicodedata import urllib.parse from collections import Counter from collections.abc import Callable -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, NoReturn, Optional import httpx from core.url_validation import ( @@ -25,6 +24,13 @@ logger = logging.getLogger(__name__) ToolHandler = Callable[[Dict[str, Any]], Any] MAX_TOOL_FAILURE_MESSAGE_CHARS = 500 +TOOL_MUTATION_NOT_SUPPORTED_DETAIL = { + "error_code": "tool_mutation_not_supported", + "message": ( + "Dynamic tool mutations are disabled until tenant-scoped persistent " + "storage and administrative authorization are implemented." + ), +} def _tool_code_fingerprint(code: str) -> str: @@ -88,35 +94,6 @@ class ToolInfo(BaseModel): ) -class ToolCreate(BaseModel): - code: str = Field(..., description="도구의 고유 식별 코드") - name: str = Field(..., description="도구의 이름") - description: str = Field(..., description="도구에 대한 상세 설명") - category: str = Field(..., description="도구의 분류 (예: 이메일, 일정, 분석 등)") - parameters: Optional[Dict[str, Any]] = Field( - default=None, description="도구 실행에 필요한 파라미터 스키마" - ) - is_active: bool = Field(default=True, description="도구의 활성화 여부") - webhook_url: Optional[str] = Field( - default=None, description="도구 실행을 위한 외부 웹훅 URL" - ) - - -class ToolUpdate(BaseModel): - name: Optional[str] = Field(default=None, description="도구의 이름") - description: Optional[str] = Field( - default=None, description="도구에 대한 상세 설명" - ) - category: Optional[str] = Field(default=None, description="도구의 분류") - parameters: Optional[Dict[str, Any]] = Field( - default=None, description="도구 실행에 필요한 파라미터 스키마" - ) - is_active: Optional[bool] = Field(default=None, description="도구의 활성화 여부") - webhook_url: Optional[str] = Field( - default=None, description="도구 실행을 위한 외부 웹훅 URL" - ) - - class ExecuteRequest(BaseModel): parameters: Dict[str, Any] = Field( default_factory=dict, description="실행 파라미터" @@ -189,10 +166,6 @@ def _validate_parameters(self, code: str, params: Dict[str, Any]) -> Dict[str, A # Initialize default tools -async def mock_handler(params: Dict[str, Any]) -> str: - encoded = json.dumps(params, ensure_ascii=False, sort_keys=True) - return f"Mock execution successful with params: {encoded}" - async def thread_summarizer_handler(params: Dict[str, Any]) -> Any: thread_id = params.get("thread_id", "") @@ -741,30 +714,17 @@ def get_tools() -> list[ToolInfo]: return registry.get_all() -@router.post("/tools", response_model=ToolInfo, status_code=201) -def create_tool(tool_data: ToolCreate) -> ToolInfo: - """ - 새로운 도구를 등록합니다. - """ - if registry.get(tool_data.code): - raise HTTPException( - status_code=400, detail="Tool with this code already exists" - ) - - tool_info = ToolInfo(**tool_data.model_dump()) +def _reject_tool_mutation() -> NoReturn: + raise HTTPException( + status_code=501, + detail=TOOL_MUTATION_NOT_SUPPORTED_DETAIL, + ) - if tool_info.webhook_url: - try: - handler = make_webhook_handler(tool_info.webhook_url) - except ValueError as e: - raise HTTPException( - status_code=400, detail=f"Invalid or unsafe webhook URL: {e}" - ) - else: - handler = mock_handler - registry.register(tool_info, handler) - return tool_info +@router.post("/tools", include_in_schema=False, response_model=None) +def create_tool() -> NoReturn: + """Fail closed until custom tools have durable tenant-scoped ownership.""" + _reject_tool_mutation() @router.get("/tools/{code}", response_model=ToolInfo) @@ -778,49 +738,16 @@ def get_tool(code: str) -> ToolInfo: return tool -@router.patch("/tools/{code}", response_model=ToolInfo) -def update_tool(code: str, tool_data: ToolUpdate) -> ToolInfo: - """ - 특정 도구의 정보를 업데이트합니다. - """ - tool = registry.get(code) - if not tool: - raise HTTPException(status_code=404, detail="Tool not found") +@router.patch("/tools/{code}", include_in_schema=False, response_model=None) +def update_tool(code: str) -> NoReturn: + """Fail closed without mutating a process-global tool.""" + _reject_tool_mutation() - update_data = tool_data.model_dump(exclude_unset=True) - # Validate webhook URL first to avoid state inconsistency - handler = None - if "webhook_url" in update_data: - if update_data["webhook_url"]: - try: - handler = make_webhook_handler(update_data["webhook_url"]) - except ValueError as e: - raise HTTPException( - status_code=400, detail=f"Invalid or unsafe webhook URL: {e}" - ) - else: - handler = mock_handler - - # Apply updates safely - for key, value in update_data.items(): - setattr(tool, key, value) - - if handler: - registry.register(tool, handler) - - return tool - - -@router.delete("/tools/{code}", status_code=204) -def delete_tool(code: str) -> None: - """ - 특정 도구를 삭제(등록 해제)합니다. - """ - tool = registry.get(code) - if not tool: - raise HTTPException(status_code=404, detail="Tool not found") - registry.unregister(code) +@router.delete("/tools/{code}", include_in_schema=False, response_model=None) +def delete_tool(code: str) -> NoReturn: + """Fail closed without unregistering a process-global tool.""" + _reject_tool_mutation() @router.post("/tools/{code}/execute", response_model=ExecuteResponse) diff --git a/backend/tests/test_tools_api.py b/backend/tests/test_tools_api.py index d411094e7..a4308d240 100644 --- a/backend/tests/test_tools_api.py +++ b/backend/tests/test_tools_api.py @@ -5,10 +5,11 @@ import os import secrets import time -from unittest.mock import AsyncMock, patch +from unittest.mock import MagicMock, patch import httpx import pytest +from fastapi import FastAPI from fastapi.testclient import TestClient os.environ.setdefault("AUTH_SESSION_HMAC_SECRET", secrets.token_urlsafe(48)) @@ -62,6 +63,20 @@ def _signed_session_token() -> str: return f"{signing_input}.{_base64url_encode(signature)}" +def _assert_tool_mutation_not_supported(response) -> None: + assert response.status_code == 501 + assert response.json() == { + "detail": { + "error_code": "tool_mutation_not_supported", + "message": ( + "Dynamic tool mutations are disabled until tenant-scoped " + "persistent storage and administrative authorization are " + "implemented." + ), + } + } + + def test_tools_rejects_missing_signed_session(): with TestClient(app) as client: response = client.get("/api/tools") @@ -563,14 +578,15 @@ async def test_base64_decoder_tool_invalid_input(): assert "Invalid Base64 string" in data["message"] -def test_create_tool_success(): +def test_create_tool_mutation_fails_closed_without_registry_write(): + code = "new_custom_tool" try: with TestClient(app) as client: response = client.post( "/api/tools", headers={"Authorization": f"Bearer {_signed_session_token()}"}, json={ - "code": "new_custom_tool", + "code": code, "name": "Custom Tool", "description": "Custom Description", "category": "Custom Category", @@ -578,257 +594,197 @@ def test_create_tool_success(): "is_active": True, }, ) - assert response.status_code == 201 - data = response.json() - assert data["code"] == "new_custom_tool" + _assert_tool_mutation_not_supported(response) + assert registry.get(code) is None + finally: + registry.unregister(code) + - tool = registry.get("new_custom_tool") - assert tool is not None - assert tool.name == "Custom Tool" +def test_create_tool_mutation_fails_closed_even_with_safe_webhook(): + code = "webhook_custom_tool" + try: + with patch("api.tools._resolve_global_addresses") as resolve_addresses: + with TestClient(app) as client: + response = client.post( + "/api/tools", + headers={"Authorization": f"Bearer {_signed_session_token()}"}, + json={ + "code": code, + "name": "Webhook Tool", + "description": "Calls an external webhook", + "category": "Custom Category", + "parameters": {"input": "string"}, + "webhook_url": "https://example.com/webhook", + }, + ) + + _assert_tool_mutation_not_supported(response) + assert registry.get(code) is None + resolve_addresses.assert_not_called() finally: - registry.unregister("new_custom_tool") + registry.unregister(code) -def test_create_tool_already_exists(): - with TestClient(app) as client: - response = client.post( - "/api/tools", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={ - "code": "thread_summarizer", - "name": "Should Fail", - "description": "Should Fail", - "category": "Test", - }, - ) - assert response.status_code == 400 - assert response.json() == {"detail": "Tool with this code already exists"} +def test_update_tool_mutation_fails_closed_without_registry_change(): + code = "update_tool" + def handler(_params): + return "ok" -def test_update_tool_success(): + original = ToolInfo( + code=code, + name="Old Name", + description="Old Desc", + category="Test", + ) + original_snapshot = original.model_copy(deep=True) try: - registry.register( - ToolInfo( - code="update_tool", - name="Old Name", - description="Old Desc", - category="Test", - ), - lambda p: "ok", - ) + registry.register(original, handler) with TestClient(app) as client: response = client.patch( - "/api/tools/update_tool", + f"/api/tools/{code}", headers={"Authorization": f"Bearer {_signed_session_token()}"}, json={"name": "New Name", "is_active": False}, ) - assert response.status_code == 200 - data = response.json() - assert data["name"] == "New Name" - assert data["is_active"] is False - - tool = registry.get("update_tool") - assert tool.name == "New Name" - assert tool.is_active is False + _assert_tool_mutation_not_supported(response) + assert registry.get(code) == original_snapshot + assert registry._handlers[code] is handler finally: - registry.unregister("update_tool") - - -def test_update_tool_with_webhook(): - try: - registry.register( - ToolInfo( - code="webhook_update_tool", - name="Old", - description="Old", - category="Test", - ), - lambda p: "ok", - ) + registry.unregister(code) - with patch( - "api.tools._resolve_global_addresses", - return_value=("93.184.216.34",), - ): - with TestClient(app) as client: - response = client.patch( - "/api/tools/webhook_update_tool", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"webhook_url": "https://example.com/webhook"}, - ) - assert response.status_code == 200 - tool = registry.get("webhook_update_tool") - assert tool.webhook_url == "https://example.com/webhook" - finally: - registry.unregister("webhook_update_tool") +def test_delete_tool_mutation_fails_closed_without_registry_change(): + code = "delete_tool" + def handler(_params): + return "ok" -def test_update_tool_remove_webhook(): + original = ToolInfo( + code=code, + name="Do Not Delete", + description="Do Not Delete", + category="Test", + ) try: - registry.register( - ToolInfo( - code="webhook_remove_tool", - name="Old", - description="Old", - category="Test", - webhook_url="https://example.com/webhook", - ), - lambda p: "ok", - ) + registry.register(original, handler) with TestClient(app) as client: - response = client.patch( - "/api/tools/webhook_remove_tool", + response = client.delete( + f"/api/tools/{code}", headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"webhook_url": None}, ) - assert response.status_code == 200 - tool = registry.get("webhook_remove_tool") - assert tool.webhook_url is None + _assert_tool_mutation_not_supported(response) + assert registry.get(code) == original + assert registry._handlers[code] is handler finally: - registry.unregister("webhook_remove_tool") + registry.unregister(code) -def test_update_tool_not_found(): +@pytest.mark.parametrize( + ("method", "path", "payload"), + [ + ( + "POST", + "/api/tools", + { + "code": "unauthorized_tool", + "name": "Unauthorized Tool", + "description": "Must not be registered", + "category": "Test", + }, + ), + ("PATCH", "/api/tools/thread_summarizer", {"name": "Unauthorized"}), + ("DELETE", "/api/tools/thread_summarizer", None), + ], +) +def test_tool_mutation_routes_require_signed_session(method, path, payload): with TestClient(app) as client: - response = client.patch( - "/api/tools/non_existent_tool", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"name": "New Name"}, - ) - assert response.status_code == 404 + response = client.request(method, path, json=payload) + assert response.status_code == 401 + assert response.json() == {"detail": "Authentication required"} -def test_delete_tool_success(): - try: - registry.register( - ToolInfo( - code="delete_tool", - name="To Delete", - description="To Delete", - category="Test", - ), - lambda p: "ok", + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("POST", "/api/tools"), + ("PATCH", "/api/tools/non_existent_tool"), + ], +) +def test_tool_mutation_tombstones_do_not_validate_request_models(method, path): + with TestClient(app) as client: + response = client.request( + method, + path, + headers={ + "Authorization": f"Bearer {_signed_session_token()}", + "Content-Type": "application/json", + }, + content="{not-json", ) - with TestClient(app) as client: - response = client.delete( - "/api/tools/delete_tool", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - ) + _assert_tool_mutation_not_supported(response) - assert response.status_code == 204 - assert registry.get("delete_tool") is None - finally: - registry.unregister("delete_tool") +def test_tool_mutation_routes_are_hidden_from_openapi(): + from api.tools import router as tools_router -def test_delete_tool_not_found(): - with TestClient(app) as client: - response = client.delete( - "/api/tools/non_existent_tool", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - ) - assert response.status_code == 404 + schema_app = FastAPI() + schema_app.include_router(tools_router) + paths = schema_app.openapi()["paths"] + + assert "post" not in paths["/api/tools"] + assert "patch" not in paths["/api/tools/{code}"] + assert "delete" not in paths["/api/tools/{code}"] @pytest.mark.asyncio async def test_webhook_handler_success(): - try: - with patch( - "api.tools._resolve_global_addresses", - return_value=("93.184.216.34",), - ): - with TestClient(app) as client: - client.post( - "/api/tools", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={ - "code": "webhook_tool", - "name": "Webhook Tool", - "description": "Calls external webhook", - "category": "Test", - "parameters": {"input": "string"}, - "webhook_url": "https://example.com/webhook", - }, - ) - - with patch("httpx.AsyncClient.post") as mock_post: - mock_response = AsyncMock() - mock_response.json.return_value = { - "webhook_success": True - } # json() is sync, return_value returns coroutine from AsyncMock, wait... - from unittest.mock import MagicMock - - mock_response.json = MagicMock(return_value={"webhook_success": True}) - mock_response.raise_for_status = lambda: None - mock_post.return_value = mock_response - - with TestClient(app) as client: - response = client.post( - "/api/tools/webhook_tool/execute", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {"input": "hello"}}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" - assert data["result"] == {"webhook_success": True} - - mock_post.assert_called_once() - args, kwargs = mock_post.call_args - assert args[0] == "https://example.com/webhook" - assert kwargs["json"] == {"parameters": {"input": "hello"}} + from api.tools import make_webhook_handler - finally: - registry.unregister("webhook_tool") + with patch( + "api.tools._resolve_global_addresses", + return_value=("93.184.216.34",), + ): + handler = make_webhook_handler("https://example.com/webhook") + with patch("httpx.AsyncClient.post") as mock_post: + mock_response = MagicMock() + mock_response.json.return_value = {"webhook_success": True} + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + result = await handler({"input": "hello"}) + + assert result == {"webhook_success": True} + mock_post.assert_awaited_once_with( + "https://example.com/webhook", + json={"parameters": {"input": "hello"}}, + timeout=10.0, + ) @pytest.mark.asyncio async def test_webhook_handler_http_error(): - try: + from api.tools import make_webhook_handler + + with patch( + "api.tools._resolve_global_addresses", + return_value=("93.184.216.34",), + ): + handler = make_webhook_handler("https://example.com/webhook") with patch( - "api.tools._resolve_global_addresses", - return_value=("93.184.216.34",), + "httpx.AsyncClient.post", + side_effect=httpx.HTTPError("Simulated HTTP Error"), ): - with TestClient(app) as client: - client.post( - "/api/tools", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={ - "code": "webhook_fail_tool", - "name": "Webhook Fail Tool", - "description": "Calls external webhook", - "category": "Test", - "parameters": {"input": "string"}, - "webhook_url": "https://example.com/webhook", - }, - ) - - with patch("httpx.AsyncClient.post") as mock_post: - mock_post.side_effect = httpx.HTTPError("Simulated HTTP Error") - - with TestClient(app) as client: - response = client.post( - "/api/tools/webhook_fail_tool/execute", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"parameters": {"input": "hello"}}, - ) - - assert response.status_code == 200 - data = response.json() - assert data["status"] == "failed" - assert ( - "Webhook execution failed: Simulated HTTP Error" in data["message"] - ) - - finally: - registry.unregister("webhook_fail_tool") + with pytest.raises( + ValueError, + match="Webhook execution failed: Simulated HTTP Error", + ): + await handler({"input": "hello"}) def test_tool_registry_execute_no_handler(): @@ -899,46 +855,6 @@ def test_validate_parameters_not_dict(): registry._validate_parameters("some_code", "not a dict") # type: ignore -def test_create_tool_unsafe_webhook(): - with TestClient(app) as client: - response = client.post( - "/api/tools", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={ - "code": "unsafe_tool", - "name": "Unsafe", - "description": "Unsafe", - "category": "Test", - "webhook_url": "http://localhost:8080/admin", - }, - ) - assert response.status_code == 400 - assert "Invalid or unsafe webhook URL" in response.json()["detail"] - - -def test_update_tool_unsafe_webhook(): - try: - registry.register( - ToolInfo( - code="unsafe_update_tool", - name="Safe", - description="Safe", - category="Test", - ), - lambda p: "ok", - ) - with TestClient(app) as client: - response = client.patch( - "/api/tools/unsafe_update_tool", - headers={"Authorization": f"Bearer {_signed_session_token()}"}, - json={"webhook_url": "http://169.254.169.254/latest/meta-data/"}, - ) - assert response.status_code == 400 - assert "Invalid or unsafe webhook URL" in response.json()["detail"] - finally: - registry.unregister("unsafe_update_tool") - - def test_is_safe_webhook_url_coverage(): from api.tools import is_safe_webhook_url @@ -1053,14 +969,6 @@ def test_execute_grammar_checker(): assert data["result"]["errors_found"] == 3 -@pytest.mark.asyncio -async def test_mock_handler(): - from api.tools import mock_handler - - res = await mock_handler({"test": 123}) - assert "123" in res - - def test_validate_webhook_url_no_host(): from api.tools import validate_webhook_url From 6dcc3dbfafa88fc09b23ca09c40b1ef88754b850 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:09:40 +0900 Subject: [PATCH 2/4] test(tools): retain UUID built-in across mutation freeze --- .../test_tools_uuid_generator_contract.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/tests/test_tools_uuid_generator_contract.py diff --git a/backend/tests/test_tools_uuid_generator_contract.py b/backend/tests/test_tools_uuid_generator_contract.py new file mode 100644 index 000000000..f15ca254d --- /dev/null +++ b/backend/tests/test_tools_uuid_generator_contract.py @@ -0,0 +1,21 @@ +"""Regression contract for the retained UUID v4 built-in tool.""" + +from __future__ import annotations + +import uuid + +import pytest + +from api.tools import registry + + +@pytest.mark.asyncio +async def test_uuid_v4_generator_remains_available_after_mutation_freeze() -> None: + """Keep the safe built-in utility while disabling only dynamic mutations.""" + tool = registry.get("uuid_v4_generator") + + assert tool is not None + assert tool.parameters == {} + result = await registry.invoke_tool("uuid_v4_generator", {}) + generated_uuid = uuid.UUID(result["uuid"]) + assert generated_uuid.version == 4 From e774ae5fe3a3089de81373b2c32ee8c210090769 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 15 Aug 2026 21:37:26 +0900 Subject: [PATCH 3/4] fix(tools): preserve UUID utility during mutation freeze --- backend/api/tools.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/api/tools.py b/backend/api/tools.py index 032ec71c1..d2d7d2a51 100644 --- a/backend/api/tools.py +++ b/backend/api/tools.py @@ -5,6 +5,7 @@ import re import unicodedata import urllib.parse +import uuid from collections import Counter from collections.abc import Callable from typing import Any, Dict, List, NoReturn, Optional @@ -706,6 +707,23 @@ async def keyword_extractor_handler(params: Dict[str, Any]) -> Any: ) +async def uuid_v4_generator_handler(params: Dict[str, Any]) -> Dict[str, str]: + """Generate one RFC 9562 UUID version 4 for the retained built-in utility.""" + return {"uuid": str(uuid.uuid4())} + + +registry.register( + ToolInfo( + code="uuid_v4_generator", + name="UUID V4 생성기 (UUID v4 Generator)", + description="범용 고유 식별자(UUID) 버전 4를 무작위로 생성합니다.", + category="유틸리티", + parameters={}, + ), + uuid_v4_generator_handler, +) + + @router.get("/tools", response_model=list[ToolInfo]) def get_tools() -> list[ToolInfo]: """ From 0833fcbdba583b3c508620e5a09ccca060ab40e6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 5 Sep 2026 14:39:08 +0900 Subject: [PATCH 4/4] fix(tools): return AGENTS ownership to canonical docs lane --- AGENTS.md | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 940beca25..9104dd1f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -440,9 +440,11 @@ in this repo. reusable business identifier such as `document_ref`, `model_id`, `topic_id`, or `label_id` as an unscoped primary or foreign key. Use an opaque immutable reference that binds the full scope or an explicit composite identity with the - required snapshot revision, model version, request/result scope, and label - version. Never join snapshots, model artifacts, topic components, or label - evidence by a bare document, model, topic, rank, label, or display value. + applicable snapshot revision, model version, request/result scope, or label + version. Define the required identity tuple for each entity; require only the + dimensions relevant to that entity. Never join snapshots, model artifacts, + topic components, or label evidence by a bare document, model, topic, rank, + label, or display value. - When reviews find public/private identifier leaks, stale API fixture shapes, or recurring bug patterns, update tests, frontend mocks, E2E mocks, README examples, architecture docs, and explicitly record the anti-pattern in `AGENTS.md` so the same bug pattern does not reappear in copied examples. - Memoized id-to-record Maps must be first-wins (`if (!map.has(key)) map.set(...)`). `new Map(items.map((item) => [String(item.id), item]))` is last-wins and @@ -508,11 +510,6 @@ in this repo. configured, fail closed with `adapter_not_configured` and `provider_write_executed=false`; if an adapter is configured, wrap only the adapter's actual result in the standard runner response envelope. -- Dynamic `/api/tools` `POST`/`PATCH`/`DELETE` mutations must remain fail closed - until tool metadata and handlers are durably scoped by signed-session tenant - and workspace, restricted to an administrative role, and backed by an actual - webhook or provider execution target. Never attach a mock handler or report - successful execution when no external or local tool work occurred. - Calendar UI actions must request `/api/calendar/writeback-intent` with server-authoritative source selection and provenance. Do not wire browser actions back to legacy `/api/calendar/sync` unless a trusted backend credential