Skip to content

Fix #163: enforce agent-memory trust model (bind agent_id + visibility)#167

Merged
mkreyman merged 6 commits into
masterfrom
fix/163-agent-memory-trust
Jun 24, 2026
Merged

Fix #163: enforce agent-memory trust model (bind agent_id + visibility)#167
mkreyman merged 6 commits into
masterfrom
fix/163-agent-memory-trust

Conversation

@mkreyman

Copy link
Copy Markdown
Owner

Summary

Closes #163. #151 shipped agent-memory conventions + read-side scoping but left the trust model advisory-only (the 2.19.0 CHANGELOG documented agent_id as self-asserted and visibility as stored-but-not-enforced). This PR makes both a real trust barrier.

Write-path: agent_id binding (ArticleController.create)

  • An agent-role key writing agent-memory metadata (agent_id/memory_type/visibility) has metadata.agent_id stamped from its verified key identity, overriding any spoofed value — an agent can no longer author a memory under another agent's identity.
  • An agent key with no agent identity → 403 agent_identity_required for such a write. Higher roles (orchestrator/user) may still attribute on behalf of others. Plain non-memory articles are untouched.

Read-path: visibility enforcement (Loopctl.Knowledge)

  • New maybe_filter_by_visibility/2: an article is visible to an agent when its visibility is absent/shared, OR the caller owns it (metadata.agent_id matches). Wired into apply_article_filters (list), apply_search_filters (keyword/semantic/combined + context-via-search), list_index, and get_article (single fetch → 404, no existence leak).
  • Scope computed by LoopctlWeb.Helpers.Visibility.scope_opts/1: agent role → [visibility_agent_id: key.agent_id]; higher roles → [] (see all). Threaded through the article/search/context/index read controllers.

The key's agent_id (UUID) is the canonical identity; metadata.agent_id is stamped/compared as its string form.

Tests

New agent_memory_trust_test.exs — write binding (stamp/override, omitted, no-identity 403, higher-role passthrough, non-memory untouched) and read enforcement (private hidden from another agent on get/list/index/search/context, owner sees own, shared visible, higher role sees all). Verified deterministic across seeds — the sweep caught and fixed a String.to_existing_atom flakiness hazard and a list_index filter gap. Full suite green (2511), mix precommit clean.

Docs

mcp-server 2.21.0 CHANGELOG documents the enforcement and marks the 2.19.0 advisory note superseded.

mkreyman added 6 commits June 23, 2026 21:57
#151 shipped agent-memory conventions + read-side scoping but left the trust
model advisory-only (the 2.19.0 CHANGELOG documented agent_id as self-asserted
and visibility as stored-but-not-enforced). This makes both real.

Write-path identity binding (ArticleController.create):
- An agent-role key writing agent-memory metadata (agent_id/memory_type/
  visibility) has metadata.agent_id stamped from its verified key identity,
  overriding any spoofed value. An agent can no longer author a memory under
  another agent's identity.
- An agent key with no agent identity gets 403 agent_identity_required for such
  a write. Higher roles (orchestrator/user) may still attribute on behalf of
  others (orchestration/backfill). Plain non-memory articles are untouched.

Read-path visibility enforcement (Loopctl.Knowledge):
- New maybe_filter_by_visibility/2 predicate: an article is visible to an agent
  when its visibility is absent/shared OR the caller owns it (metadata.agent_id
  matches). Wired into apply_article_filters (list), apply_search_filters
  (keyword/semantic/combined + context-via-search), list_index, and get_article
  (single fetch → 404 with no existence leak).
- The caller's scope is computed by LoopctlWeb.Helpers.Visibility.scope_opts/1:
  agent role → [visibility_agent_id: key.agent_id]; higher roles → [] (see all).
  Threaded through the article/search/context/index read controllers.

The key's agent_id (a UUID) is the canonical identity; metadata.agent_id is
stamped/compared as its string form.

Tests: new agent_memory_trust_test.exs — write binding (stamp/override, omitted,
no-identity 403, higher-role passthrough, non-memory untouched) and read
enforcement (private hidden from other agents on get/list/index/search/context,
owner sees own, shared visible, higher role sees all). Verified deterministic
across seeds (caught + fixed a String.to_existing_atom hazard and a list_index
filter gap during the sweep). Full suite green (2511), mix precommit clean.

Docs: mcp-server 2.21.0 CHANGELOG documents the enforcement and marks the
2.19.0 advisory note superseded.
…round 1)

The enhanced-review team found the read enforcement was incomplete: the first
pass only covered list/index/show/search/context, but agent memories are
published, so every OTHER agent-reachable read path leaked private/owner
memories. Fixed all of them so the trust barrier holds across the whole surface.

Read paths now visibility-scoped (for agent callers):
- distant_pairs (/knowledge/pairs) — candidate set filtered
- random_walk (/knowledge/walk) — start node + every neighbor step
- graph_traversal (/knowledge/graph) — start gate + the recursive CTE node set
  (visibility clause bound as a param, injection-safe)
- suggest_links (/knowledge/articles/:id/suggested_links) — start + candidates
- count_articles / tag_facets (/knowledge/count, /knowledge/facets) — via the
  apply_article_filters chokepoint (controllers now pass the scope)
- stats (/knowledge/stats) — base query filtered
- novelty_scores (/knowledge/novelty) — private priors excluded from the
  comparison set and the prior_count
- get_article preloaded links + context batch_linked_refs — linked-article refs
  are filtered so a shared article never leaks a private memory's title (closes
  the "no existence leak" gap the BA flagged)

All five remaining agent-facing controllers merge LoopctlWeb.Helpers.Visibility
.scope_opts/1, mirroring the article/search/index/context wiring.

Tests: agent_memory_trust_test gains a "cross-endpoint visibility isolation"
suite asserting agent A's private memory never leaks to agent B through pairs,
walk, graph, suggest_links, count, stats, facets, or novelty — plus owner-
visibility and identity-less-agent read cases (engineer/architect test gaps).
Verified deterministic across seeds. Full suite green (2518), precommit clean.

Docs (BA findings):
- mcp-server CHANGELOG 2.21.0 lists all covered read surfaces and adds a
  migration note: pre-#163 memories written with a non-UUID agent_id won't match
  their owner's registry-UUID key identity (backfill or treat as a clean break).
- knowledge_create MCP tool gains a `metadata` property (the handler now forwards
  it) documenting agent-memory keys + the trust model (agent_id key-stamped,
  visibility enforced, 403 agent_identity_required). knowledge_context notes that
  private/owner peers' memories are excluded regardless of the agents= filter.
…nd 2)

The 4-agent adversarial review found two more leak paths that surface article
state INDIRECTLY (round 1 only covered the direct knowledge reads), plus an
identity edge case. All fixed; one finding disproved.

CRITICAL — change feed (GET /changes):
- Audit `new_state`/`old_state` embed an article's body + metadata, and the
  change feed is agent-reachable. An agent could poll it and read every private
  memory another agent ever created/edited. Now, for agent callers, article
  change entries are dropped unless the underlying article is currently visible
  to the caller (new Knowledge.visible_article_ids/3 does one batched lookup —
  robust even for events whose snapshot omits metadata, e.g. article.created).

HIGH — GET /articles/:id/links + POST /article_links:
- list_links_for_article returned linked articles' id+title with no visibility
  filter (a sibling of the get_article/batch_linked_refs leak round 1 closed).
  It now takes the caller scope and drops links whose far side isn't visible
  (both-sides-visible also gates probing a hidden start article → empty).
- create_link gained a visibility gate: an agent may not link to an article it
  can't see (→ 404, no existence leak), closing the "link-then-list to read a
  private title" attack. Self-links still fall through to the 422 changeset
  rejection (distinct-id count).

MEDIUM — blank agent_id:
- The changeset now rejects metadata.agent_id == "" (it was a valid string), so
  a higher role can't store an ""-owned "private" memory that an identity-less
  agent key (whose scope is agent_id = "") would then match and read.

LOW — bridge middle node:
- distant_pairs `bridge_path` now requires the 2-hop middle node to ALSO be
  visible to an agent caller, so a pair can't be reported bridgeable through a
  private memory.

Disproved (documented, not a deferral):
- "PATCH /articles/:id allows agent_id spoofing" — update/delete are role :user;
  agents cannot reach them. Higher-role attribution on update is by-design,
  consistent with create (only the agent role is key-bound).

Perf (architect): the visibility predicate is intentionally not given a
dedicated index — its OR returns the shared majority, so a partial index on
private/owner rows can't serve the dominant read; the per-row JSONB extraction
is cheap on the already-tenant-scoped scan (rationale documented in code).

Tests: +7 — change-feed isolation, /links isolation, create-link rejection,
graph + context owner-positive (over-filtering guard), blank-agent_id 422.
Deterministic across seeds. Full suite green (2523), precommit clean, MCP 45/45.

Docs: OpenAPI operation() descriptions on the 7 now-filtered endpoints + README
rows note the visibility scoping; the CHANGELOG migration note is now an
actionable procedure (identify SQL + PATCH backfill).
A focused final security verification confirmed the round-2 fixes are correct and
the read surface is exhaustively covered, but found two LOW residual existence/id
leaks (no content). Both are the exact existence-leak class this PR closes, so
fixed here per NO-DEFERRALS.

- Idempotency probe (A10-1): create with another agent's private memory's
  idempotency_key returned `200 deduplicated` echoing that private article's id.
  The dedup lookup (fast path AND the post-insert unique-violation recovery) is
  now visibility-scoped: an agent only dedups against a match it can see; a key
  colliding with an invisible memory falls through to the unique index, which
  rejects the write (422) without revealing the private id.
- Change-feed article_link entries (A10-2): `article_link.created/deleted` entries
  carry both endpoint UUIDs and were unfiltered. They're now dropped for an agent
  unless BOTH endpoints are visible (folded into the same visible_article_ids
  lookup), mirroring the /links both-sides rule — a link can't leak a private
  memory's id or graph edge.

Verifier verdict on the round-2 fixes: all PASS (change-feed id types match, no
over-drop/leak; /links fail-closed; create_link distinct-id self-link → 422;
blank agent_id rejected before length check on create+update; bridge 10 args/10
placeholders, vis bound). Non-leaks confirmed role-gated away from agents:
webhooks (subscribe is :user), analytics (:orchestrator), exports (:user).

Tests: +2 — article_link change-feed isolation, idempotency-probe non-disclosure.
Deterministic across seeds. Full suite green (2525), precommit clean.
@mkreyman mkreyman merged commit a3b03a1 into master Jun 24, 2026
6 checks passed
@mkreyman mkreyman deleted the fix/163-agent-memory-trust branch June 24, 2026 04:56
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.

Agent-memory write-path trust model: bind agent_id to API key + enforce visibility (follow-on to #151)

1 participant