Skip to content

fix(mcp): the server decides whose memory and what provenance; untrusted writes are quarantined - #62

Merged
TAIPANBOX merged 5 commits into
mainfrom
fix/memory-scope-and-provenance
Oct 18, 2026
Merged

TAIPANBOX merged 5 commits into
mainfrom
fix/memory-scope-and-provenance

Conversation

@TAIPANBOX

Copy link
Copy Markdown
Owner

Part of the agent-security programme (@decided 2026-10-17: close the review's findings). Memory poisoning is the persistence layer 2026 attacks use, and the server took the scope and the provenance from the caller.

  • The scope is the server's configured identity (--agent-id); a call naming another agent_id is refused, never served.
  • Provenance is set by the server (--provenance / ENGRAM_MCP_PROVENANCE, default external); a caller may only lower it (tool, external) and can never claim user.
  • Quarantine: once a session has read untrusted content (recall or why returning non-user or quarantined memory, content the caller labels as tool or external, read_untrusted, or --session-untrusted), its writes are stored quarantined and recall returns that mark.
  • forget and fact deletion are limited to the caller's own scope; a shared fact needs an explicit opt-in at write time. Existing rows migrate to a conservative provenance.
  • 17 mutants, all caught; a live run over stdio with three real server processes on one file.

Breaking: a client that passes another agent's agent_id is refused. The console (genaryx) spawns the server with no --agent-id and passes agent_id per call, so it must change first: do not merge before the genaryx connector change lands, because the console image takes engram from main at its next build.

Not proven: content the agent read outside engram is not seen unless the caller or launcher labels the session; identity is configuration, not a verified token (wave 1 later).

🤖 Generated with Claude Code

TAIPANBOX and others added 5 commits October 18, 2026 11:45
…against main

Thirteen scenarios in features/memory-scope-and-provenance.feature, one test
each in tests/test_memory_scope.py. All thirteen fail on origin/main c8708c5:
the scope, user-claim, external-default and fact-deletion tests on the defect
itself (DID NOT RAISE, 'tool' == 'external'), the rest because the quarantine
mark, the sharing opt-in and the server settings do not exist yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…untrusted writes (F-007)

engram-mcp took the agent scope and the trust label of every write from the
tool call: any client could read or write another agent's memory by naming
its agent_id, label any text provenance="user", and delete any fact, since
facts were shared by every agent and forget checked only the id.

- The scope is the identity the server is started with (--agent-id). A call
  naming another agent_id is refused with PermissionError, never served from
  that scope; naming its own, or nothing, is served. A server with no
  identity serves the whole file as one tenant, warns, and refuses any named
  agent. The pool now holds one Engram instance.
- The server sets provenance: --provenance / ENGRAM_MCP_PROVENANCE (default
  external, since an MCP write is an agent's own output) is the ceiling; a
  caller may lower it to tool or external, and "user" is refused.
- Quarantine: the session turns untrusted when recall or why returns a memory
  that is not the user's own or is quarantined, when the caller writes content
  it labels tool or external or passes read_untrusted, or from the start with
  --session-untrusted. Later writes are stored quarantined, never as user, and
  recall, why, library results and the adapters return the mark. compress()
  keeps it on a summary of quarantined episodes.
- Facts record their writer and whether they are shared. remember(kind=
  "semantic") keeps a fact private unless shared=true; assert_fact keeps its
  shared default. Scoped fact reads leave out other agents' private facts,
  and forget_fact deletes only the instance's own (KeyError for an invisible
  fact, PermissionError for a visible one); an unscoped instance may delete any.
- Migration: four nullable columns, no default, nothing rewritten. Rows
  written before them read as the least trusted (episode quarantined with
  provenance unknown; fact quarantined, visible to all, deletable by no agent).
  Export and import carry the new fields.

Invariant 12 and an approved decision in CLAUDE.md; 14 scenarios in
features/memory-scope-and-provenance.feature bound to tests by the new gate
scripts/features-are-bound.sh, with four teeth cases, wired into CI and the
release workflow. Tests that asserted the old behaviour (per-call agent_id on
one pool, a caller-claimed user provenance) now open one server per agent and
take the source from the server's configuration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ed by every agent

Facts can now be private to their writer, so 'shared by every agent' no longer
describes them; the count still covers every agent's facts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d quarantined

why() already read the mark off the source episodes; storing it on the fact
makes timeline(), export and a later reader see it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TAIPANBOX
TAIPANBOX merged commit ba0c1d9 into main Oct 18, 2026
10 checks passed
@TAIPANBOX
TAIPANBOX deleted the fix/memory-scope-and-provenance branch October 18, 2026 12:42
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.

1 participant