Skip to content

security: close the read-side authorization gap on handoff and decay sweeps - #99

Merged
quantifylabs merged 2 commits into
mainfrom
security/guard-handoff-read-sink
Jul 30, 2026
Merged

quantifylabs merged 2 commits into
mainfrom
security/guard-handoff-read-sink

Conversation

@quantifylabs

Copy link
Copy Markdown
Owner

What

The W1 authorization pass (#95, #98) covered every route that writes memories. Two routes that only read or sweep them inherited nothing:

  • POST /memories/handoff took source_agent_id from the request body with no AuthContext, and get_agent_memories_for_handoff applied no scope filter — so any holder of a project key (including one bound to a different agent) could pull all of another agent's memories, agent-private included, as key_facts in a single call. This is the same "agent-private is not private" hole W1 closed on the query routes, still open on one route.
  • POST /memories/decay/archive ran project-wide with no agent authorization — a key bound to one agent could soft-deprecate every other agent's memories.

Why the sweep missed it

test_every_router_that_writes_memories_screens_and_authorizes enumerates routers from the source — but keys on MemoryRepository.add(. A handoff writes nothing, so a pure read sink was invisible to it. Same shape as the ace_delta miss (#98), one level up.

Fix

  • handoffs.py — resolve the source identity through effective_agent_id: a bound key may only hand off its own memories (handoff is a push by the source, never a pull by an arbitrary key); an unbound key represents the application and may orchestrate, per the documented W1 posture. read_scope_restriction is applied as the principal-trust ceiling, mirroring the query routes.
  • decay.py — a bound key's archive sweep is confined to its own agent's rows via a new agent_id filter on archive_stale; unbound keys still sweep the project.
  • memory_repository.pyget_agent_memories_for_handoff gains requested_scope, archive_stale gains agent_id. Both optional, both default to prior behavior for unbound callers.

Tests

  • New source-derived sweep test_every_router_that_touches_the_repository_authorizes_an_identity: enumerates every router touching MemoryRepository at all — read, write, or sweep — and requires a memory_authz helper or the admin gate. A new read-only router now fails on the day it is added.
  • TestHandoffHttpBypass / TestDecayArchiveScoping: real HTTP requests through the FastAPI stack with a repository canary, per the TestHttpLayerBypass methodology.
  • Validated against pre-fix main (cf74ec2) in a scratch worktree: the three adversarial tests fail there (spoofed handoff pull reaches retrieval; bound-key sweep runs project-wide; sweep test flags both routers) and the three positive controls pass.
  • Full suite: 738 passed, 24 skipped, 1 xfailed.

🤖 Generated with Claude Code

arulnidhii and others added 2 commits July 30, 2026 17:02
…sweeps

POST /memories/handoff took source_agent_id from the request body with no
auth context, and get_agent_memories_for_handoff applied no scope filter,
so any project key could pull another agent's memories - agent-private
included - as key_facts in one call. The W1 sweep missed it because the
source-derived wiring test keyed on MemoryRepository.add, and a handoff
writes nothing.

- handoffs: resolve the source through effective_agent_id (bound keys may
  only hand off their own memories; unbound keys orchestrate as the
  application) and apply read_scope_restriction as the principal-trust
  ceiling, mirroring the query routes.
- decay: POST /memories/decay/archive archived project-wide with no agent
  authz; a bound key's sweep is now confined to its own agent's rows.
- test_authz_bypass: new source-derived sweep over every router that
  touches MemoryRepository at all (not just writers), plus HTTP-layer
  bypass tests for both routes. Validated against pre-fix main (cf74ec2)
  in a scratch worktree: the three adversarial tests fail there and the
  positive controls pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ollection

Unrelated to this PR's change: mcp 2.0.0 shipped upstream and the
dependency was unpinned, so any CI run today fails collecting
tests/test_mcp_server.py and tests/test_mcp_local_mode.py
(ModuleNotFoundError: mcp.server.fastmcp). The last green main run
(2026-07-20) resolved 1.x. Pin below the major; migrating
aegis_memory/mcp_server.py to the 2.0 API is a separate task.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@quantifylabs

Copy link
Copy Markdown
Owner Author

CI failure was unrelated to the security change: mcp 2.0.0 released upstream and the dependency was unpinned, so today's CI resolved the new major and test collection died on ModuleNotFoundError: mcp.server.fastmcp (removed in 2.0). The last green main run (2026-07-20) predates the release — main would fail the same way today.

Pushed 88e9d7e pinning mcp>=1.6.0,<2.0 as a rider so this PR can go green. Migrating aegis_memory/mcp_server.py to the mcp 2.0 API is a separate follow-up task.

@quantifylabs
quantifylabs merged commit 4e96df1 into main Jul 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants