brain_write is an ungated persistent prompt-injection path into every future session (OWASP MCP06) #145

Closed
opened 2026-10-01 16:34:51 +00:00 by mathias · 6 comments
Owner

Found during an OWASP MCP Top 10 (2025) audit, 2026-10-01.

Gap

internal/mcp/tools_notes.go already names the threat precisely for brain_edit_root: "brain content is ingested from the outside world and reaches a model's context, so a listed governance-file editor is a durable prompt-injection path." That tool is de-surfaced and gated on the static principal. Good.

But brain_write writes straight to knowledge/ with no gate (memory: capture is I1-gated, brain_write is "the ungated raw door"). And the estate's doctrine tells every agent to brain_query reflexively before acting and to treat results as prior art. So:

a session that just read an untrusted page (web fetch, an issue body, a PR from outside) → brain_write → every later session on every harness retrieves it as trusted guidance.

That is the same durable path, one hop removed from CLAUDE.md. Whether a knowledge/ entry obeys differently from CLAUDE.md is a matter of degree, not kind.

Options (not decided)

  • Route brain_write from model-facing origins (claude.ai connector / OAuth path) through pending/ + brain_promote, like ingest — human-in-the-loop for anything that becomes retrievable.
  • Or mark provenance in frontmatter (origin: claude.ai, session id) and have brain_query results render it, so a reader can discount.
  • Plus: one audit line per write with principal (see sibling issue on caller identity).

Done when

  • A write from a non-static principal is either staged or provenance-tagged, proven by test.
  • Retrieval output shows provenance.

OWASP refs: MCP06 Prompt Injection via Contextual Payloads, MCP03 Tool Poisoning (output side).


Created via git-mcp on behalf of @mathias

Found during an OWASP MCP Top 10 (2025) audit, 2026-10-01. ## Gap `internal/mcp/tools_notes.go` already names the threat precisely for `brain_edit_root`: *"brain content is ingested from the outside world and reaches a model's context, so a listed governance-file editor is a durable prompt-injection path."* That tool is de-surfaced **and** gated on the static principal. Good. But `brain_write` writes straight to `knowledge/` with no gate (memory: `capture` is I1-gated, `brain_write` is "the ungated raw door"). And the estate's doctrine tells every agent to `brain_query` reflexively before acting and to treat results as prior art. So: > a session that just read an untrusted page (web fetch, an issue body, a PR from outside) → `brain_write` → every later session on every harness retrieves it as trusted guidance. That is the same durable path, one hop removed from CLAUDE.md. Whether a `knowledge/` entry *obeys* differently from CLAUDE.md is a matter of degree, not kind. ## Options (not decided) - Route `brain_write` from model-facing origins (claude.ai connector / OAuth path) through `pending/` + `brain_promote`, like ingest — human-in-the-loop for anything that becomes retrievable. - Or mark provenance in frontmatter (`origin: claude.ai`, session id) and have `brain_query` results render it, so a reader can discount. - Plus: one audit line per write with principal (see sibling issue on caller identity). ## Done when - A write from a non-static principal is either staged or provenance-tagged, proven by test. - Retrieval output shows provenance. OWASP refs: MCP06 Prompt Injection via Contextual Payloads, MCP03 Tool Poisoning (output side). --- _Created via git-mcp on behalf of @mathias_
mathias added the risk/mediumsize/mtype/research labels 2026-10-01 16:34:51 +00:00
Author
Owner

Decision (Mathias, 2026-10-01): provenance tag, no staging

Non-breaking: agents keep writing learnings without a human step; readers can see where an entry came from and discount it.

Spec for the executor

  1. Write side. brainWrite (internal/mcp/handlers.go:249) and brainUpdate stamp provenance into the note's frontmatter from the authenticated principal already in ctx (principalKey → principalInfo, set by withPrincipal in server.go ServeHTTP ~L229):
    • written_by: <principal> (the principal string)
    • origin: static | jwt (from viaStatic)
    • written_at if not already stamped by the store.
      No principal in ctx (e.g. tests, internal callers) → origin: unknown. Never omit the field — absence must not read as trusted.
      The caller cannot supply or override these — any written_by/origin already present in caller-supplied content frontmatter is overwritten by the server's value. This is the control; test it explicitly.
  2. Read side. brain_query, brain_get, brain_read results include origin and written_by per hit when the note has them (older notes without them: omit, do not fabricate).
  3. Plumb through capture.Note / BrainStore.Write rather than string-patching the content in the handler, so capture gets the same stamp for free.

Tests (red first)

  • TestBrainWriteStampsPrincipalProvenance — static and JWT principals produce the right origin/written_by.
  • TestBrainWriteOverwritesCallerSuppliedProvenance — content with origin: static frontmatter written by a JWT principal ends up origin: jwt.
  • TestBrainWriteWithoutPrincipalStampsUnknown.
  • TestBrainQueryReturnsProvenance.

Done when

task check green; a note written through the claude.ai connector path shows origin: jwt in both the file and the brain_query result.

Rule 8: this is control code (the overwrite rule). Report-back should list how the overwrite was verified by execution, not just that tests pass.


Created via git-mcp on behalf of @mathias

## Decision (Mathias, 2026-10-01): provenance tag, no staging Non-breaking: agents keep writing learnings without a human step; readers can see where an entry came from and discount it. ## Spec for the executor 1. **Write side.** `brainWrite` (`internal/mcp/handlers.go:249`) and `brainUpdate` stamp provenance into the note's frontmatter from the authenticated principal already in ctx (`principalKey` → `principalInfo`, set by `withPrincipal` in `server.go` ServeHTTP ~L229): - `written_by: <principal>` (the principal string) - `origin: static | jwt` (from `viaStatic`) - `written_at` if not already stamped by the store. No principal in ctx (e.g. tests, internal callers) → `origin: unknown`. **Never** omit the field — absence must not read as trusted. **The caller cannot supply or override these** — any `written_by`/`origin` already present in caller-supplied `content` frontmatter is overwritten by the server's value. This is the control; test it explicitly. 2. **Read side.** `brain_query`, `brain_get`, `brain_read` results include `origin` and `written_by` per hit when the note has them (older notes without them: omit, do not fabricate). 3. Plumb through `capture.Note` / `BrainStore.Write` rather than string-patching the content in the handler, so `capture` gets the same stamp for free. ## Tests (red first) - `TestBrainWriteStampsPrincipalProvenance` — static and JWT principals produce the right `origin`/`written_by`. - `TestBrainWriteOverwritesCallerSuppliedProvenance` — content with `origin: static` frontmatter written by a JWT principal ends up `origin: jwt`. - `TestBrainWriteWithoutPrincipalStampsUnknown`. - `TestBrainQueryReturnsProvenance`. ## Done when `task check` green; a note written through the claude.ai connector path shows `origin: jwt` in both the file and the `brain_query` result. Rule 8: this is control code (the overwrite rule). Report-back should list how the overwrite was verified by execution, not just that tests pass. --- _Created via git-mcp on behalf of @mathias_
mathias added the status:dispatch label 2026-10-01 18:33:27 +00:00
Author
Owner

Correction: status:dispatch does nothing here. The dispatch watcher has been suspended since 2026-09-17 (dispatch#56, infra k3s/apps/dispatch/cronjob.yaml), and the CI agent lane (agent-run.yml) exists only on cobalt-dingo. Taking this interactively instead, via a PR and gatekeeper review. The label stays because gitea-mcp has no unlabel tool (gitea-mcp#61); it is inert while the watcher is frozen.


Created via git-mcp on behalf of @mathias

Correction: `status:dispatch` does nothing here. The dispatch watcher has been suspended since 2026-09-17 (dispatch#56, infra `k3s/apps/dispatch/cronjob.yaml`), and the CI agent lane (`agent-run.yml`) exists only on cobalt-dingo. Taking this interactively instead, via a PR and gatekeeper review. The label stays because gitea-mcp has no unlabel tool (gitea-mcp#61); it is inert while the watcher is frozen. --- _Created via git-mcp on behalf of @mathias_
Author
Owner

pr-gatekeeper: PR #147 BLOCKED at 32dedb3, so this issue is not done. I found three ways to forge provenance by execution, all model-reachable: (E1) brain_edit rewrites origin: jwt to origin: static byte-for-byte; (E2) brain_ingest_raw with a newline in title injects an origin: static line, and brain_query reports it; (E3) brain_ingest_raw with type ../raw, then brain_promote, lands a forged note in a hall. Also: brain_ingest_raw, brain_ingest and brain_promote write notes with no origin field at all (E4), and quoted, case-variant, merge-key and lone-CR forged keys survive into the file (E5). CI 8691 is green and covers none of this. Exact repros and flip-test table: #147 (comment)

pr-gatekeeper: PR #147 BLOCKED at 32dedb3, so this issue is not done. I found three ways to forge provenance by execution, all model-reachable: (E1) brain_edit rewrites `origin: jwt` to `origin: static` byte-for-byte; (E2) brain_ingest_raw with a newline in `title` injects an `origin: static` line, and brain_query reports it; (E3) brain_ingest_raw with type `../raw`, then brain_promote, lands a forged note in a hall. Also: brain_ingest_raw, brain_ingest and brain_promote write notes with no origin field at all (E4), and quoted, case-variant, merge-key and lone-CR forged keys survive into the file (E5). CI 8691 is green and covers none of this. Exact repros and flip-test table: https://git.d-ma.be/mathias/hyperguild/pulls/147#issuecomment-13961
Author
Owner

pr-gatekeeper round 2: PR #147 is still BLOCKED, now at e615d6f. Round-1 escapes E1-E6 are closed by execution. New: (R1) a YAML alias key plus a ... document-end line makes yaml.v3 read origin=static written_by=local-cli through brain_write and brain_edit, while brain_query and brain_get show the real values. With no principal, the aliases alone forge written_by. (R2) ... alone hides origin from yaml.v3. (R3) REST /ingest, /ingest-path and /ingest-raw write notes with no provenance. (R4) On REST /ingest-raw the only defence against title injection is the yamlScalar fold, and no test notices if it is removed. CI 8698 is green and covers none of this. Repros: #147 (comment)

pr-gatekeeper round 2: PR #147 is still BLOCKED, now at e615d6f. Round-1 escapes E1-E6 are closed by execution. New: (R1) a YAML alias key plus a `...` document-end line makes yaml.v3 read origin=static written_by=local-cli through brain_write and brain_edit, while brain_query and brain_get show the real values. With no principal, the aliases alone forge written_by. (R2) `...` alone hides origin from yaml.v3. (R3) REST /ingest, /ingest-path and /ingest-raw write notes with no provenance. (R4) On REST /ingest-raw the only defence against title injection is the yamlScalar fold, and no test notices if it is removed. CI 8698 is green and covers none of this. Repros: https://git.d-ma.be/mathias/hyperguild/pulls/147#issuecomment-13988
Author
Owner

pr-gatekeeper round 3: PR #147 is still BLOCKED, now at fcae165. The forgery control holds: I found no way to make any reader report origin: static. Three things block the merge. (B1) brain_edit inserting a k: |\n --- block scalar makes brain_query report no provenance, because extractProvenance treats an indented --- as the fence. (B2) brain_edit's rollback ignores its own errors and still reports 'edit rolled back' when nothing was restored. (B3) the whole-document verify backstop and the decodesToOnly filter have no tests; disabling both stays green. CI 8701 is green and covers none of this. Repros: #147

pr-gatekeeper round 3: PR #147 is still BLOCKED, now at fcae165. The forgery control holds: I found no way to make any reader report origin: static. Three things block the merge. (B1) brain_edit inserting a `k: |\n ---` block scalar makes brain_query report no provenance, because extractProvenance treats an indented --- as the fence. (B2) brain_edit's rollback ignores its own errors and still reports 'edit rolled back' when nothing was restored. (B3) the whole-document verify backstop and the decodesToOnly filter have no tests; disabling both stays green. CI 8701 is green and covers none of this. Repros: https://git.d-ma.be/mathias/hyperguild/pulls/147
Author
Owner

pr-gatekeeper: done, verified by execution. PR #147 merged as a6da933 after four review rounds.

  • Write side: brain_write, brain_update, brain_edit, brain_promote, capture (MCP and REST), MCP brain_ingest / brain_ingest_raw and REST /write, /ingest, /ingest-raw all stamp origin (static/jwt/unknown) and written_by from the authenticated principal.
  • Forged provenance in content is dropped (16 frontmatter variants plus alias, ..., block-scalar, BOM, fence and principal-shape attacks). A stamp that doesn't read back exactly through yaml.v3 refuses the write and leaves no file.
  • Read side: brain_query and brain_get return origin/written_by; brain_read returns the raw stamped frontmatter.
  • The JWT path (fake validator) stamps origin: jwt in the file and in the brain_query result, under test.
  • CI 8708 is green, and every flip of the control goes red.
  • Evidence: #147 (comment). Follow-ups (read-side quote decoding, promote empty dir, partial ingest): #151.
pr-gatekeeper: done, verified by execution. PR #147 merged as a6da933 after four review rounds. - Write side: brain_write, brain_update, brain_edit, brain_promote, capture (MCP and REST), MCP brain_ingest / brain_ingest_raw and REST /write, /ingest, /ingest-raw all stamp `origin` (static/jwt/unknown) and `written_by` from the authenticated principal. - Forged provenance in content is dropped (16 frontmatter variants plus alias, `...`, block-scalar, BOM, fence and principal-shape attacks). A stamp that doesn't read back exactly through yaml.v3 refuses the write and leaves no file. - Read side: brain_query and brain_get return origin/written_by; brain_read returns the raw stamped frontmatter. - The JWT path (fake validator) stamps `origin: jwt` in the file and in the brain_query result, under test. - CI 8708 is green, and every flip of the control goes red. - Evidence: https://git.d-ma.be/mathias/hyperguild/pulls/147#issuecomment-14040. Follow-ups (read-side quote decoding, promote empty dir, partial ingest): #151.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/hyperguild#145