From afc1dcc3f94b388a65496c6b296b4e61666e3747 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 27 Oct 2026 09:54:56 +0000 Subject: [PATCH 1/2] memory routes: accept deployed agent local tokens and fence to own namespace Previously, local tokens (TAOS_LOCAL_TOKEN from auth.mint_agent_local_token) were rejected with 401 because check_agent_scope treated all Bearer headers as registry JWTs. The auth middleware correctly sets request.state.via = "local_token" and request.state.agent_name for bound tokens, but the memory routes called check_agent_scope first, which failed JWT verification. Fix: add _local_token_caller helper and check it FIRST in all four memory routes (browse, search, collections, delete-chunk). For bound local tokens, fence to the agent's own namespace using the same detail strings as the JWT branch. For unbound host tokens, preserve existing admin behavior. RED-FIRST proof: FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_search_other_agent_returns_403 - AssertionError: assert 401 == 403 All 32 memory tests pass (10 new + 22 existing). Docs-Reviewed: updated agent-coordination.md to document local token acceptance for memory_read routes. --- .../tsk-agjabc-memory-local-token-fence.md | 2 + docs/agent-coordination.md | 7 +- tests/test_memory_local_token_fence.py | 217 ++++++++++++++++++ tinyagentos/routes/memory.py | 137 +++++++---- 4 files changed, 319 insertions(+), 44 deletions(-) create mode 100644 changelog.d/tsk-agjabc-memory-local-token-fence.md create mode 100644 tests/test_memory_local_token_fence.py diff --git a/changelog.d/tsk-agjabc-memory-local-token-fence.md b/changelog.d/tsk-agjabc-memory-local-token-fence.md new file mode 100644 index 000000000..20a49c9e7 --- /dev/null +++ b/changelog.d/tsk-agjabc-memory-local-token-fence.md @@ -0,0 +1,2 @@ +### Fixed +- Memory routes now accept deployed agents' local tokens (bound and unbound) and fence bound agents to their own namespace on browse, search, collections, and delete-chunk. Previously, local tokens were rejected with 401 because `check_agent_scope` treated them as registry JWTs. \ No newline at end of file diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index aadc83606..be7c322bb 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -777,8 +777,11 @@ different project, and 403 when the agent lacks the `canvas_read` scope or the covering four routes. - **memory_read**: read an agent's own memory index via qmd serve. `GET /api/memory/browse`, `POST /api/memory/search`, `GET /api/memory/collections/{agent_name}`. - These routes restrict results to the calling agent's OWN memory namespace (its own - agent name / canonical_id) — an agent must never read another agent's memory. + These routes accept either a registry JWT with `memory_read` grant or a + bound per-agent local token (`AuthManager.mint_agent_local_token`). Both + credential types restrict results to the calling agent's OWN memory + namespace -- an agent must never read another agent's memory. The host + local token (unbound) retains admin access to all agent namespaces. The DELETE `/api/memory/chunk/{content_hash}` route stays human-only and is not reachable by agent tokens, even with memory_read. diff --git a/tests/test_memory_local_token_fence.py b/tests/test_memory_local_token_fence.py new file mode 100644 index 000000000..23e1cdca6 --- /dev/null +++ b/tests/test_memory_local_token_fence.py @@ -0,0 +1,217 @@ +"""RED-FIRST: memory routes must accept deployed agent's local token and fence it to its own namespace. + +Every deployed agent calls the controller with its own TAOS_LOCAL_TOKEN (auth.mint_agent_local_token). +The auth middleware accepts that token and sets request.state.via = "local_token" and +request.state.agent_name = for bound tokens. Memory routes must check this FIRST +before calling check_agent_scope, which treats any Bearer as a registry JWT and fails. +""" +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + + +def _bound_client(app, agent_name: str) -> AsyncClient: + """Return an unauthenticated AsyncClient presenting *agent_name*'s bound token.""" + token = app.state.auth.mint_agent_local_token(agent_name) + return AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) + + +def _host_token_client(app) -> AsyncClient: + """Return a client presenting the host local token (no agent binding).""" + token = app.state.auth.get_local_token() + return AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) + + +def _stub_http(app, responses: dict[str, dict]): + """Install an AsyncMock http_client onto the app that returns the + canned JSON body for matching path suffixes.""" + def _response_for(path_suffix: str) -> MagicMock: + resp = MagicMock() + resp.raise_for_status = MagicMock() + resp.json = MagicMock(return_value=responses[path_suffix]) + return resp + + async def _get(url: str, *, params=None, timeout=None, **kw): + for suffix, body in responses.items(): + if url.endswith(suffix): + return _response_for(suffix) + raise AssertionError(f"unexpected GET {url}") + + async def _post(url: str, *, json=None, timeout=None, **kw): + for suffix, body in responses.items(): + if url.endswith(suffix): + return _response_for(suffix) + raise AssertionError(f"unexpected POST {url}") + + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=_get) + mock_client.post = AsyncMock(side_effect=_post) + mock_client.aclose = AsyncMock(return_value=None) + app.state.http_client = mock_client + + +@pytest_asyncio.fixture +async def local_token_app(app): + """App with initialized stores and admin user set up.""" + for attr in ("agent_registry", "agent_grants", "metrics", "notifications", "qmd_client", + "secrets", "broker_store", "scheduler", "channels", "relationship_mgr", + "conversion_mgr", "training_mgr", "agent_messages", "shared_folders", + "streaming_sessions", "expert_agents", "chat_messages", "chat_channels", + "project_store", "project_invites", "board_audit", "receipt_store", + "task_strikes", "project_task_store", "project_element_store", + "project_notes_store", "project_lists_store", "project_list_entries_store", + "routine_store", "decision_store", "execution_policies", "coding_session_store", + "container_request_store", "canvas_store", "themes", "office_docs", "web_sites", + "song_store", "lora_store", "design_docs", "app_grants", "license_acceptances", + "feedback_store", "client_log_store", "device_store", "device_pair_requests", + "council_roles", "council_members"): + store = getattr(app.state, attr, None) + if store is not None and hasattr(store, '_db') and store._db is None: + if hasattr(store, 'init'): + await store.init() + + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + app.state._startup_complete = True + + yield app + + # Cleanup + for attr in ("agent_registry", "agent_grants", "metrics", "notifications", "qmd_client", + "secrets", "broker_store", "scheduler", "channels", "relationship_mgr", + "conversion_mgr", "training_mgr", "agent_messages", "shared_folders", + "streaming_sessions", "expert_agents", "chat_messages", "chat_channels", + "project_store", "project_invites", "board_audit", "receipt_store", + "task_strikes", "project_task_store", "project_element_store", + "project_notes_store", "project_lists_store", "project_list_entries_store", + "routine_store", "decision_store", "execution_policies", "coding_session_store", + "container_request_store", "canvas_store", "themes", "office_docs", "web_sites", + "song_store", "lora_store", "design_docs", "app_grants", "license_acceptances", + "feedback_store", "client_log_store", "device_store", "device_pair_requests", + "council_roles", "council_members"): + store = getattr(app.state, attr, None) + if store is not None and hasattr(store, '_db') and store._db is not None: + if hasattr(store, 'close'): + await store.close() + + +@pytest.mark.asyncio +class TestMemoryLocalTokenFence: + """Test that memory routes accept local tokens and fence bound agents to their own namespace.""" + + async def test_bound_agent_search_other_agent_returns_403(self, local_token_app): + """Bound agent-a searching for agent-b must return 403.""" + _stub_http(local_token_app, { + "/search": {"results": []}, + }) + async with _bound_client(local_token_app, "agent-a") as c: + resp = await c.post("/api/memory/search", json={"query": "x", "agent": "agent-b"}) + assert resp.status_code == 403 + assert resp.json()["detail"] == "Agent can only search its own memory" + + async def test_bound_agent_search_own_namespace_works(self, local_token_app): + """Bound agent-a searching without agent param uses its own namespace.""" + _stub_http(local_token_app, { + "/search": {"results": []}, + }) + async with _bound_client(local_token_app, "agent-a") as c: + resp = await c.post("/api/memory/search", json={"query": "x"}) + assert resp.status_code == 200 + # Verify the qmd call's dbPath ends with "agent-a/index.sqlite" + mock_client = local_token_app.state.http_client + call_args = mock_client.get.call_args + assert call_args is not None + # dbPath is passed in params + params = call_args.kwargs.get("params", {}) + assert "agent-a/index.sqlite" in params.get("dbPath", "") + + async def test_bound_agent_browse_other_agent_returns_403(self, local_token_app): + """Bound agent-a browsing agent-b must return 403.""" + _stub_http(local_token_app, { + "/browse": {"chunks": []}, + }) + async with _bound_client(local_token_app, "agent-a") as c: + resp = await c.get("/api/memory/browse?agent=agent-b") + assert resp.status_code == 403 + assert resp.json()["detail"] == "Agent can only browse its own memory" + + async def test_bound_agent_browse_own_namespace_works(self, local_token_app): + """Bound agent-a browsing without agent param uses its own namespace.""" + _stub_http(local_token_app, { + "/browse": {"chunks": []}, + }) + async with _bound_client(local_token_app, "agent-a") as c: + resp = await c.get("/api/memory/browse") + assert resp.status_code == 200 + mock_client = local_token_app.state.http_client + call_args = mock_client.get.call_args + assert call_args is not None + params = call_args.kwargs.get("params", {}) + assert "agent-a/index.sqlite" in params.get("dbPath", "") + + async def test_bound_agent_collections_other_agent_returns_403(self, local_token_app): + """Bound agent-a accessing collections for agent-b must return 403.""" + _stub_http(local_token_app, { + "/collections": [], + }) + async with _bound_client(local_token_app, "agent-a") as c: + resp = await c.get("/api/memory/collections/agent-b") + assert resp.status_code == 403 + assert resp.json()["detail"] == "Agent can only access its own collections" + + async def test_bound_agent_collections_own_agent_works(self, local_token_app): + """Bound agent-a accessing collections for itself works.""" + _stub_http(local_token_app, { + "/collections": [], + }) + async with _bound_client(local_token_app, "agent-a") as c: + resp = await c.get("/api/memory/collections/agent-a") + assert resp.status_code == 200 + + async def test_bound_agent_delete_chunk_returns_403(self, local_token_app): + """Bound agent delete-chunk must return 403 'Memory deletion is human-only'.""" + _stub_http(local_token_app, { + "/delete-chunk": {"status": "ok"}, + }) + async with _bound_client(local_token_app, "agent-a") as c: + resp = await c.delete("/api/memory/chunk/abc") + assert resp.status_code == 403 + assert resp.json()["detail"] == "Memory deletion is human-only" + + async def test_host_token_search_other_agent_works(self, local_token_app): + """Host token (unbound) searching for another agent works (admin path).""" + _stub_http(local_token_app, { + "/search": {"results": []}, + }) + async with _host_token_client(local_token_app) as c: + resp = await c.post("/api/memory/search", json={"query": "x", "agent": "agent-b"}) + assert resp.status_code == 200 + + async def test_host_token_browse_other_agent_works(self, local_token_app): + """Host token (unbound) browsing another agent works (admin path).""" + _stub_http(local_token_app, { + "/browse": {"chunks": []}, + }) + async with _host_token_client(local_token_app) as c: + resp = await c.get("/api/memory/browse?agent=agent-b") + assert resp.status_code == 200 + + async def test_host_token_collections_other_agent_works(self, local_token_app): + """Host token (unbound) accessing collections for another agent works (admin path).""" + _stub_http(local_token_app, { + "/collections": [], + }) + async with _host_token_client(local_token_app) as c: + resp = await c.get("/api/memory/collections/agent-b") + assert resp.status_code == 200 \ No newline at end of file diff --git a/tinyagentos/routes/memory.py b/tinyagentos/routes/memory.py index e331f28a0..c75d8810a 100644 --- a/tinyagentos/routes/memory.py +++ b/tinyagentos/routes/memory.py @@ -39,6 +39,20 @@ router = APIRouter() +def _local_token_caller(request: Request) -> tuple[bool, str | None]: + """Check if the request is from a bound local token. + + Returns (True, agent_name) when request.state.via == "local_token" and + request.state.agent_name is set (bound agent token). + Returns (True, None) when request.state.via == "local_token" but no + agent_name is bound (host/unbound token). + Returns (False, None) otherwise. + """ + if getattr(request.state, "via", None) == "local_token": + return True, getattr(request.state, "agent_name", None) + return False, None + + class SearchRequest(BaseModel): query: str mode: str = "keyword" @@ -105,19 +119,29 @@ async def memory_browse( conversation_id: str | None = None, ): """Browse memory chunks via qmd serve GET /browse.""" - # Check if this is a registry-JWT agent request (not a human session) - agent_canonical_id = await check_agent_scope(request, "memory_read") - - # For registry-JWT agents, restrict to own namespace - if agent_canonical_id is not None: - # If an agent name was provided in the query, ensure it matches the calling agent's own - # First resolve the canonical_id to the config agent name - agent_config_name = await _resolve_agent_name(request, agent_canonical_id) or agent_canonical_id + # Check for local token FIRST (before check_agent_scope which treats any Bearer as registry JWT) + is_local, bound_agent = _local_token_caller(request) + if is_local: + if bound_agent is not None: + # Bound agent: fence to own namespace + if agent is not None and agent != bound_agent: + raise HTTPException(status_code=403, detail="Agent can only browse its own memory") + agent = bound_agent + # Unbound (host) token: keep existing admin behaviour (agent param respected as-is) + else: + # Registry JWT path + agent_canonical_id = await check_agent_scope(request, "memory_read") - if agent is not None and agent != agent_config_name: - raise HTTPException(status_code=403, detail="Agent can only browse its own memory") - # Use the resolved config agent name for the agent namespace - agent = agent_config_name + # For registry-JWT agents, restrict to own namespace + if agent_canonical_id is not None: + # If an agent name was provided in the query, ensure it matches the calling agent's own + # First resolve the canonical_id to the config agent name + agent_config_name = await _resolve_agent_name(request, agent_canonical_id) or agent_canonical_id + + if agent is not None and agent != agent_config_name: + raise HTTPException(status_code=403, detail="Agent can only browse its own memory") + # Use the resolved config agent name for the agent namespace + agent = agent_config_name http_client = request.app.state.http_client params: dict = {"limit": limit, "offset": offset} @@ -186,23 +210,33 @@ async def memory_search(request: Request, body: SearchRequest): If ``agent`` is set, searches that agent's per-agent index. Without an ``agent``, searches the default (user) index. Cross-agent search - is intentionally not supported here — agents do not share memory. + is intentionally not supported here -- agents do not share memory. Aggregating across agents is a separate concern that belongs in a future ``/api/memory/all`` endpoint, gated by user permission. """ - # Check if this is a registry-JWT agent request (not a human session) - agent_canonical_id = await check_agent_scope(request, "memory_read") - - # For registry-JWT agents, restrict to own namespace - if agent_canonical_id is not None: - # If an agent name was provided in the body, ensure it matches the calling agent's own - # First resolve the canonical_id to the config agent name - agent_config_name = await _resolve_agent_name(request, agent_canonical_id) or agent_canonical_id + # Check for local token FIRST (before check_agent_scope which treats any Bearer as registry JWT) + is_local, bound_agent = _local_token_caller(request) + if is_local: + if bound_agent is not None: + # Bound agent: fence to own namespace + if body.agent is not None and body.agent != bound_agent: + raise HTTPException(status_code=403, detail="Agent can only search its own memory") + body.agent = bound_agent + # Unbound (host) token: keep existing admin behaviour (body.agent respected as-is) + else: + # Registry JWT path + agent_canonical_id = await check_agent_scope(request, "memory_read") - if body.agent is not None and body.agent != agent_config_name: - raise HTTPException(status_code=403, detail="Agent can only search its own memory") - # Use the resolved config agent name for the agent namespace - body.agent = agent_config_name + # For registry-JWT agents, restrict to own namespace + if agent_canonical_id is not None: + # If an agent name was provided in the body, ensure it matches the calling agent's own + # First resolve the canonical_id to the config agent name + agent_config_name = await _resolve_agent_name(request, agent_canonical_id) or agent_canonical_id + + if body.agent is not None and body.agent != agent_config_name: + raise HTTPException(status_code=403, detail="Agent can only search its own memory") + # Use the resolved config agent name for the agent namespace + body.agent = agent_config_name db_path = _agent_db_path(request, body.agent) search_fn = _qmd_vsearch if body.mode == "semantic" else _qmd_search @@ -227,19 +261,29 @@ async def memory_collections( request: Request, agent_name: str, conversation_id: str | None = None, ): """List memory collections for an agent via qmd serve GET /collections.""" - # Check if this is a registry-JWT agent request (not a human session) - agent_canonical_id = await check_agent_scope(request, "memory_read") - - # For registry-JWT agents, restrict to own namespace - if agent_canonical_id is not None: - # First resolve the canonical_id to the config agent name - agent_config_name = await _resolve_agent_name(request, agent_canonical_id) or agent_canonical_id + # Check for local token FIRST (before check_agent_scope which treats any Bearer as registry JWT) + is_local, bound_agent = _local_token_caller(request) + if is_local: + if bound_agent is not None: + # Bound agent: fence to own namespace + if agent_name != bound_agent: + raise HTTPException(status_code=403, detail="Agent can only access its own collections") + agent_name = bound_agent + # Unbound (host) token: keep existing admin behaviour (agent_name path param respected as-is) + else: + # Registry JWT path + agent_canonical_id = await check_agent_scope(request, "memory_read") - # Ensure the agent is requesting collections for itself only - if agent_name != agent_config_name: - raise HTTPException(status_code=403, detail="Agent can only access its own collections") - # Use the resolved config agent name - agent_name = agent_config_name + # For registry-JWT agents, restrict to own namespace + if agent_canonical_id is not None: + # First resolve the canonical_id to the config agent name + agent_config_name = await _resolve_agent_name(request, agent_canonical_id) or agent_canonical_id + + # Ensure the agent is requesting collections for itself only + if agent_name != agent_config_name: + raise HTTPException(status_code=403, detail="Agent can only access its own collections") + # Use the resolved config agent name + agent_name = agent_config_name http_client = request.app.state.http_client params: dict = {"dbPath": _agent_db_path(request, agent_name)} @@ -263,11 +307,20 @@ async def memory_delete_chunk( Routes to the per-agent index when ``agent`` is set, otherwise to the default user index. """ - # For registry-JWT agents, DELETE is not allowed even with memory_read - # This route remains human-only as per the spec - agent_canonical_id = await check_agent_scope(request, "memory_read") - if agent_canonical_id is not None: - raise HTTPException(status_code=403, detail="Memory deletion is human-only") + # Check for local token FIRST (before check_agent_scope which treats any Bearer as registry JWT) + is_local, bound_agent = _local_token_caller(request) + if is_local: + if bound_agent is not None: + # Bound agent: DELETE is not allowed (same as JWT agent) + raise HTTPException(status_code=403, detail="Memory deletion is human-only") + # Unbound (host) token: keep existing admin behaviour + else: + # Registry JWT path + # For registry-JWT agents, DELETE is not allowed even with memory_read + # This route remains human-only as per the spec + agent_canonical_id = await check_agent_scope(request, "memory_read") + if agent_canonical_id is not None: + raise HTTPException(status_code=403, detail="Memory deletion is human-only") http_client = request.app.state.http_client payload: dict = {"hash": content_hash, "dbPath": _agent_db_path(request, agent)} From 9d4708d4b9babca212ac73e5d5fb3ef0ecdb625e Mon Sep 17 00:00:00 2001 From: jaylfc Date: Tue, 27 Oct 2026 10:01:58 +0000 Subject: [PATCH 2/2] test: harness-measured red for tsk-agjabc RED-FIRST proof measured by the harness (measure_red.sh): the changed tests run against product code at base fa5b0c208, then against this branch. RED on the base: ``` E assert 401 == 403 E + where 401 = .status_code E assert 401 == 200 FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_search_other_agent_returns_403 FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_search_own_namespace_works FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_browse_other_agent_returns_403 FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_browse_own_namespace_works FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_collections_other_agent_returns_403 FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_collections_own_agent_works FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_bound_agent_delete_chunk_returns_403 FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_host_token_search_other_agent_works FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_host_token_browse_other_agent_works FAILED tests/test_memory_local_token_fence.py::TestMemoryLocalTokenFence::test_host_token_collections_other_agent_works 10 failed in 20.93s ``` GREEN on the fix: ``` 10 passed in 17.70s ```