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
No Branch/Tag Specified
main
fix/pipeline-writes-158
fix/edit-path-io-192
fix/create-regular-188
fix/guard-special-179
fix/linkcheck-keeps-write-178
fix/write-dir-redirect-173
fix/tunnel-detect-174
fix/pipeline-root-157
w3/154-mcp-auth-wiring
fix/tunnel-traversal
test/in-brain-targets-171
fix/brain-scan-root-157
fix/graph-wiki-root-157
fix/vectorstore-root-157
fix/notes-root-157
fix/search-root-157
fix/brainwide-root-157
fix/raw-symlinks-153
feat/symlink-tripwire-157
fix/rest-auth-149
fix/123-brain-edit-envelope-check
feat/106-page-aware-extraction
fix/105-source-drift-on-main
fix/104-surface-merge-conflicts-on-main
agent/b1-compile-on-ingest
agent/104-surface-merge-conflicts
agent/105-source-drift
agent/107-loud-unconfigured-alerting
feat/brain-read-edit
fix/capture-i1-refusal-names-target
renovate/configure
feat/consolidate-single-harness
icebox/cmd-routing
feat/brain-pending-promote
fix/close-session-classification-refresh
fix/capture-summarywriter
v0.24.0
v0.23.1
v0.23.0
v0.22.0
v0.21.1
v0.21.0
v0.20.1
v0.20.0
v0.19.1
v0.19.0
v0.18.4
v0.18.3
v0.18.2
v0.18.1
v0.18.0
v0.17.0
v0.16.1
v0.16.0
v0.15.0
v0.14.0
v0.13.2
v0.13.1
v0.13.0
v0.12.1
v0.12.0
v0.11.2
icebox/cmd-routing-2026-07-01
v0.11.1
v0.11.0
v0.10.0
v0.9.0
v0.8.0
v0.7.0
v0.6.0
v0.5.0
v0.4.1
v0.4.0
v0.3.0
v0.2.0
v0.1.0
Labels
Clear labels
agent/ready
blocked/client
blocked/supplier
blocked/upstream
risk/high
risk/low
risk/medium
size/l
size/m
size/s
size/xs
status:dispatch
status:dispatched
status:review
status:reviewing
type/chore
type/decision
type/docs
type/feat
type/fix
type/ops
type/research
type/test
Meets the agent-ready contract; authorizes unattended execution
Waiting on the client
Waiting on a supplier
Waiting on an upstream project or vendor
Irreversible, security-relevant, or control plane
Reversible, local, test-covered
Touches shared state, deploys or data
Too big - split it
Under two days
Under half a day
Under an hour
Maintenance, no behaviour change
Human decision required; never agent/ready
Documentation only
New capability
Corrects broken behaviour
Infrastructure, deploy, runtime
Investigate; output is a finding, not code
Test coverage only
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mathias/hyperguild#145
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found during an OWASP MCP Top 10 (2025) audit, 2026-10-01.
Gap
internal/mcp/tools_notes.goalready names the threat precisely forbrain_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_writewrites straight toknowledge/with no gate (memory:captureis I1-gated,brain_writeis "the ungated raw door"). And the estate's doctrine tells every agent tobrain_queryreflexively before acting and to treat results as prior art. So: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)
brain_writefrom model-facing origins (claude.ai connector / OAuth path) throughpending/+brain_promote, like ingest — human-in-the-loop for anything that becomes retrievable.origin: claude.ai, session id) and havebrain_queryresults render it, so a reader can discount.Done when
OWASP refs: MCP06 Prompt Injection via Contextual Payloads, MCP03 Tool Poisoning (output side).
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
brainWrite(internal/mcp/handlers.go:249) andbrainUpdatestamp provenance into the note's frontmatter from the authenticated principal already in ctx (principalKey→principalInfo, set bywithPrincipalinserver.goServeHTTP ~L229):written_by: <principal>(the principal string)origin: static | jwt(fromviaStatic)written_atif 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/originalready present in caller-suppliedcontentfrontmatter is overwritten by the server's value. This is the control; test it explicitly.brain_query,brain_get,brain_readresults includeoriginandwritten_byper hit when the note has them (older notes without them: omit, do not fabricate).capture.Note/BrainStore.Writerather than string-patching the content in the handler, socapturegets the same stamp for free.Tests (red first)
TestBrainWriteStampsPrincipalProvenance— static and JWT principals produce the rightorigin/written_by.TestBrainWriteOverwritesCallerSuppliedProvenance— content withorigin: staticfrontmatter written by a JWT principal ends uporigin: jwt.TestBrainWriteWithoutPrincipalStampsUnknown.TestBrainQueryReturnsProvenance.Done when
task checkgreen; a note written through the claude.ai connector path showsorigin: jwtin both the file and thebrain_queryresult.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
Correction:
status:dispatchdoes nothing here. The dispatch watcher has been suspended since 2026-09-17 (dispatch#56, infrak3s/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
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 rewritesorigin: jwttoorigin: staticbyte-for-byte; (E2) brain_ingest_raw with a newline intitleinjects anorigin: staticline, 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 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 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 ak: |\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: #147pr-gatekeeper: done, verified by execution. PR #147 merged as
a6da933after four review rounds.origin(static/jwt/unknown) andwritten_byfrom the authenticated principal...., 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.origin: jwtin the file and in the brain_query result, under test.