feat(brain): tool-call audit log (#144) + server-owned write provenance (#145) #147

Merged
mathias merged 5 commits from feat/mcp-audit-provenance into main 2026-10-01 21:06:36 +00:00
Owner

Closes #144
Closes #145

Two commits, each one logical change. Decisions: Mathias, 2026-10-01. The brain holds no confidential client content (#144). Provenance gets tagged, not staged (#145).

a9a9eeb: audit-log every tools/call (#144, OWASP MCP08)

One slog record per call: tool, principal, origin (static/jwt/unknown), wing/hall, outcome (truncated to 200 chars), duration_ms. Content is reduced to content_bytes.

32dedb3: write provenance (#145, OWASP MCP06)

  • origin and written_by are stamped in frontmatter on brain_write, brain_update and capture (both doors), taken from the authenticated principal.
  • The control: caller-supplied origin/written_by, in content frontmatter or an update fragment, are discarded. If no principal is derivable, origin: unknown is stamped. It is never omitted.
  • brain_update re-stamps provenance to the last writer.
  • REST POST /write carries no principal, so it stamps unknown. It is converted field by field, so the JSON body can't set provenance.
  • brain_query results include origin/written_by where the note has them. Older notes omit the fields; nothing is fabricated.

Known limits

  • The principal is only derived when capture is wired (s.capture != nil, as in prod). Otherwise everything is unknown.
  • First-party internal writers (claudewatcher, pipeline) stamp unknown, which is noisy for trusted sources. Possible follow-up: an internal origin.

Verification

  • Tests were written red first: audit_test.go (4) and provenance_test.go (6, including forged frontmatter on write and on update, and JWT via capture).
  • CI=1 task check EXIT=0. CI mode is needed because the worktree sits outside ~/dev, so context-sync can't find the root AGENT.md.

risk/medium. The provenance overwrite is control code (rule 8).

🤖 Generated with Claude Code


Created via git-mcp on behalf of @mathias

Closes #144 Closes #145 Two commits, each one logical change. Decisions: Mathias, 2026-10-01. The brain holds no confidential client content (#144). Provenance gets tagged, not staged (#145). ## a9a9eeb: audit-log every tools/call (#144, OWASP MCP08) One slog record per call: `tool`, `principal`, `origin` (static/jwt/unknown), `wing`/`hall`, `outcome` (truncated to 200 chars), `duration_ms`. Content is reduced to `content_bytes`. ## 32dedb3: write provenance (#145, OWASP MCP06) - `origin` and `written_by` are stamped in frontmatter on brain_write, brain_update and capture (both doors), taken from the authenticated principal. - **The control:** caller-supplied `origin`/`written_by`, in content frontmatter or an update fragment, are discarded. If no principal is derivable, `origin: unknown` is stamped. It is never omitted. - brain_update re-stamps provenance to the last writer. - REST `POST /write` carries no principal, so it stamps unknown. It is converted field by field, so the JSON body can't set provenance. - `brain_query` results include `origin`/`written_by` where the note has them. Older notes omit the fields; nothing is fabricated. ## Known limits - The principal is only derived when capture is wired (`s.capture != nil`, as in prod). Otherwise everything is `unknown`. - First-party internal writers (claudewatcher, pipeline) stamp `unknown`, which is noisy for trusted sources. Possible follow-up: an `internal` origin. ## Verification - Tests were written red first: `audit_test.go` (4) and `provenance_test.go` (6, including forged frontmatter on write and on update, and JWT via capture). - `CI=1 task check` EXIT=0. CI mode is needed because the worktree sits outside ~/dev, so context-sync can't find the root AGENT.md. risk/medium. The provenance overwrite is control code (rule 8). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- _Created via git-mcp on behalf of @mathias_
mathias added 2 commits 2026-10-01 18:49:22 +00:00
brain_query and brain_read left no record of who read what; only capture
and governance edits reached the audit sink (OWASP MCP08).

One slog record per call: tool, principal, origin (static/jwt/unknown),
wing/hall when given, outcome, duration. Content is reduced to
content_bytes so the audit line never copies brain content.

Refs #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(brain): stamp server-owned write provenance; surface it in brain_query
CI / Lint / Test / Vet (pull_request) Successful in 43s
CI / Mirror to GitHub (pull_request) Skipped
32dedb318d
brain_write was an ungated, durable prompt-injection path: a session that
just read an untrusted page could write an entry every later session
retrieves as trusted guidance (OWASP MCP06). Decision (#145): tag
provenance rather than stage writes.

Every write and update stamps origin (static | jwt | unknown) and
written_by (the authenticated principal) into frontmatter. Server-owned:
origin/written_by in caller content or an update fragment are discarded,
so a caller cannot claim a more trusted origin. No principal stamps
origin: unknown, never silence. brain_update re-stamps to the last
writer. capture (the claude.ai connector door) stamps too, via a new
server-derived CaptureContext.AuthPath set by both capture doors after
decode. REST POST /write carries no principal and stamps unknown.

brain_query results carry origin/written_by when the note has them;
older notes omit them rather than fabricate. brain_get already returns
frontmatter.

Refs #145

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mathias added the risk/medium label 2026-10-01 18:49:35 +00:00
Author
Owner

pr-gatekeeper: BLOCKED (head 32dedb3)

I built the PR head in a scratch worktree and called the real mcp.Server over HTTP, with a JWT principal attacker (fake validator) and the static principal local-cli. Each written file was read back from disk, and brain_query / brain_get were called on it. CI run 8691 is green and CI=1 task check exits 0. Green covers none of the findings below.

The control under test: a caller must never be able to make a note read origin: static or written_by: <someone else>. That control is defeated through three model-reachable tools.

E1 (critical): brain_edit rewrites provenance directly

brain_edit is listed in tools/list, available to any principal, and does a byte-level replace with no provenance awareness.

# as JWT principal "attacker"
brain_write  {"content":"# E\n\nzqeditprobe","wing":"testing","hall":"facts","filename":"e-1"}
brain_edit   {"path":"wiki/testing/facts/e-1.md",
              "old_str":"origin: jwt\nwritten_by: attacker\n",
              "new_str":"origin: static\nwritten_by: local-cli\n"}

Result: the file reads origin: static / written_by: local-cli, and brain_query zqeditprobe returns origin=static written_by=local-cli. Fix direction: give editWithScope a provenance check next to checkClaimEnvelope, so it refuses any edit that changes an origin/written_by frontmatter line. Alternatively, re-stamp after the edit.

E2 (critical): brain_ingest_raw injects provenance through a newline in title

buildFrontmatter wraps the title in YAML single quotes. Every frontmatter reader here is line-based, though (extractProvenance, parseFrontmatter), so an embedded newline starts a new key.

# as JWT principal "attacker"
brain_ingest_raw {"source":"zqsrc","pages":[{"title":"Zqinj\norigin: static\nwritten_by: local-cli\nx: 'y","type":"concept","content":"zqingestinj body"}]}

Result: wiki/concepts/zqinjorigin-staticwritten-by-local-clix-y.md, and brain_query zqingestinj returns origin=static written_by=local-cli.

E3 (critical): brain_ingest_raw with type ../raw, then brain_promote, lands a forged note in a hall

buildPage builds the directory as "wiki/" + rp.Type, so a type of ../raw writes into raw/. PromoteNote then keeps the raw frontmatter verbatim and never stamps provenance.

brain_ingest_raw {"source":"zqsrc2","pages":[{"title":"Zqpromo\norigin: static\nwritten_by: local-cli\nx: 'y","type":"../raw","content":"zqpromote body"}]}
brain_promote    {"filename":"zqpromoorigin-staticwritten-by-local-clix-y.md","wing":"testing","hall":"facts"}

Result: wiki/testing/facts/zqpromo...md, and brain_query zqpromote returns origin=static written_by=local-cli. The type traversal predates this PR, but it is the route into a hall, so it needs its own issue.

E4 (high): model-reachable write paths that omit provenance entirely

The #145 spec says: "Never omit the field — absence must not read as trusted". Three paths still write notes with no origin line:

  • brain_ingest_raw: plain {"title":"Zqplain","type":"concept",...} produces a note with no origin, and brain_query returns origin=<nil>.
  • brain_ingest: goes through the same buildAndWrite (read, not executed, because it needs an LLM).
  • brain_promote: stamps nothing.

Such a note cannot be told apart from a pre-provenance legacy note.

E5 (medium): forged keys survive into the written file (brain_write, brain_update, capture insight text)

The writer drops only keys whose TrimSpace equals origin/written_by. Each of the following reached disk:

variant (content frontmatter) write update yaml.v3 on the file
"origin": static / 'origin': static survives survives duplicate-key error (frontmatter unparseable)
Origin: static survives survives parses; case-variant key
? origin\n: static survives dropped duplicate-key error
title: x\rorigin: static (lone CR) survives survives duplicate-key error
<<: {origin: static, written_by: local-cli} survives survives unauthenticated write: written_by=local-cli, because no real written_by line exists to override the merge key
{origin: static, ...} flow map, indented origin: survives flow survives parse error

brain_query reported the real values in every row; it held. The other readers did not:

  • brain_get returns "origin": static as a sibling key next to origin: jwt.
  • brain_read hands the model raw text containing two origin lines.
  • Any strict YAML consumer (brain-gardener, lint) either fails or, with merge keys, reads the forged value.

Fix direction: refuse content frontmatter that is not a flat, unique-key map, i.e. reject <<, ?, flow mappings, quoted or case-variant provenance keys, and bare CR. Do not try to normalise them.

E6 (medium): the audit log copies content through error outcomes (#144 says never log content)

The outcome field is the handler's error string, and several errors echo caller input. Sentinel probe (ZQSENTINEL7731):

  • brain_write {"content":"---\nhalf_life: ZQSENTINEL7731\n---\nx",...} logs the outcome claim envelope: half_life: "ZQSENTINEL7731" is not one of ..., so a frontmatter value is in the log.
  • brain_update {"path":"wiki/nope/ZQSENTINEL7731.md",...} puts the path in the outcome.

These did not leak: capture's insights[].text, brain_write content on success, brain_edit new_str/old_str, and brain_query query (the query is not logged at all).

Things that held under execution

  • REST POST /write: a body with origin, written_by, Origin and WrittenBy still produces origin: unknown.
  • Capture: AuthPath, auth_path, principal and origin set in context, in an insight, or at top level are all ignored, and the note gets origin: jwt / written_by: attacker. DecodeRequest maps fields explicitly.
  • These spellings were stripped on write: origin:static, origin : static, origin\t: static, CRLF fences, duplicate fence, second --- block in the body, and anchors.
  • brain_update always re-stamps, so an update cannot delete origin.

Flip tests (mechanism broken, then the suite run)

flip result
writeHallNote lets caller origin/written_by through RED
UpdateNote stops re-stamping origin RED
provenanceFrom reports jwt as static RED (capture test only)
brainWrite drops Origin RED
audit logs raw content RED
UpdateNote fragment skip for origin/written_by removed GREEN: masked by the later fm.set; the skip is unobserved
capturehttp REST AuthPath always "static" GREEN: REST capture provenance has no test
extractProvenance reads body lines after the fence GREEN: the doc comment's "a body line is not provenance" has no test
audit outcome truncation removed GREEN
provenanceOrigin("") returns "" (REST /write would stamp an empty origin) GREEN: REST /write provenance has no test

Rule 9 (test names vs assertions) and spec gaps

  • TestBrainWriteOverwritesCallerSuppliedProvenance claims overwrite in general, but checks one canonical spelling from an unauthenticated caller. The #145 spec asks for "written by a JWT principal ends up origin: jwt". E5 shows the general claim is false.
  • TestBrainUpdateRestampsProvenanceAndIgnoresForgedFragment: the "IgnoresForgedFragment" half is not observed (see the flip table), and quoted or case-variant fragments do survive.
  • TestBrainWriteStampsPrincipalProvenance: the spec asks for static and JWT; only static is tested.
  • TestCaptureStampsJWTProvenance: covers MCP capture only; REST capturehttp is untested.

Required before re-review

  1. E1, E2, E3 closed, each with a red-first test that reproduces the exact call above.
  2. E4: every model-reachable writer stamps provenance, or the gap is an explicit, justified allowlist with a test that fails when a new writer appears.
  3. E5: forged-key variants refused, with a table-driven test over the variants above that asserts on the file and on brain_get.
  4. E6: outcome scrubbed or classified (for example, the error class only).
  5. The green flips above given assertions.

Not checked: live deploy and Loki ingestion; brain_ingest executed end to end (no LLM); the claudewatcher sink (internal, writes to archive/, not indexed); JWT sub containing a newline (validator-controlled).

## pr-gatekeeper: BLOCKED (head 32dedb3) I built the PR head in a scratch worktree and called the real `mcp.Server` over HTTP, with a JWT principal `attacker` (fake validator) and the static principal `local-cli`. Each written file was read back from disk, and `brain_query` / `brain_get` were called on it. CI run 8691 is green and `CI=1 task check` exits 0. **Green covers none of the findings below.** The control under test: a caller must never be able to make a note read `origin: static` or `written_by: <someone else>`. That control is defeated through three model-reachable tools. ### E1 (critical): `brain_edit` rewrites provenance directly `brain_edit` is listed in tools/list, available to any principal, and does a byte-level replace with no provenance awareness. ``` # as JWT principal "attacker" brain_write {"content":"# E\n\nzqeditprobe","wing":"testing","hall":"facts","filename":"e-1"} brain_edit {"path":"wiki/testing/facts/e-1.md", "old_str":"origin: jwt\nwritten_by: attacker\n", "new_str":"origin: static\nwritten_by: local-cli\n"} ``` Result: the file reads `origin: static` / `written_by: local-cli`, and `brain_query zqeditprobe` returns `origin=static written_by=local-cli`. Fix direction: give `editWithScope` a provenance check next to `checkClaimEnvelope`, so it refuses any edit that changes an `origin`/`written_by` frontmatter line. Alternatively, re-stamp after the edit. ### E2 (critical): `brain_ingest_raw` injects provenance through a newline in `title` `buildFrontmatter` wraps the title in YAML single quotes. Every frontmatter reader here is line-based, though (`extractProvenance`, `parseFrontmatter`), so an embedded newline starts a new key. ``` # as JWT principal "attacker" brain_ingest_raw {"source":"zqsrc","pages":[{"title":"Zqinj\norigin: static\nwritten_by: local-cli\nx: 'y","type":"concept","content":"zqingestinj body"}]} ``` Result: `wiki/concepts/zqinjorigin-staticwritten-by-local-clix-y.md`, and `brain_query zqingestinj` returns `origin=static written_by=local-cli`. ### E3 (critical): `brain_ingest_raw` with type `../raw`, then `brain_promote`, lands a forged note in a hall `buildPage` builds the directory as `"wiki/" + rp.Type`, so a `type` of `../raw` writes into `raw/`. `PromoteNote` then keeps the raw frontmatter verbatim and never stamps provenance. ``` brain_ingest_raw {"source":"zqsrc2","pages":[{"title":"Zqpromo\norigin: static\nwritten_by: local-cli\nx: 'y","type":"../raw","content":"zqpromote body"}]} brain_promote {"filename":"zqpromoorigin-staticwritten-by-local-clix-y.md","wing":"testing","hall":"facts"} ``` Result: `wiki/testing/facts/zqpromo...md`, and `brain_query zqpromote` returns `origin=static written_by=local-cli`. The `type` traversal predates this PR, but it is the route into a hall, so it needs its own issue. ### E4 (high): model-reachable write paths that omit provenance entirely The #145 spec says: "**Never** omit the field — absence must not read as trusted". Three paths still write notes with no `origin` line: - `brain_ingest_raw`: plain `{"title":"Zqplain","type":"concept",...}` produces a note with no origin, and brain_query returns `origin=<nil>`. - `brain_ingest`: goes through the same `buildAndWrite` (read, not executed, because it needs an LLM). - `brain_promote`: stamps nothing. Such a note cannot be told apart from a pre-provenance legacy note. ### E5 (medium): forged keys survive into the written file (brain_write, brain_update, capture insight text) The writer drops only keys whose `TrimSpace` equals `origin`/`written_by`. Each of the following reached disk: | variant (content frontmatter) | write | update | yaml.v3 on the file | |---|---|---|---| | `"origin": static` / `'origin': static` | survives | survives | duplicate-key error (frontmatter unparseable) | | `Origin: static` | survives | survives | parses; case-variant key | | `? origin\n: static` | survives | dropped | duplicate-key error | | `title: x\rorigin: static` (lone CR) | survives | survives | duplicate-key error | | `<<: {origin: static, written_by: local-cli}` | survives | survives | **unauthenticated write: `written_by=local-cli`**, because no real written_by line exists to override the merge key | | `{origin: static, ...}` flow map, indented `origin:` | survives | flow survives | parse error | `brain_query` reported the real values in every row; it held. The other readers did not: - `brain_get` returns `"origin": static` as a sibling key next to `origin: jwt`. - `brain_read` hands the model raw text containing two origin lines. - Any strict YAML consumer (brain-gardener, lint) either fails or, with merge keys, reads the forged value. Fix direction: refuse content frontmatter that is not a flat, unique-key map, i.e. reject `<<`, `?`, flow mappings, quoted or case-variant provenance keys, and bare CR. Do not try to normalise them. ### E6 (medium): the audit log copies content through error outcomes (#144 says never log content) The outcome field is the handler's error string, and several errors echo caller input. Sentinel probe (ZQSENTINEL7731): - `brain_write {"content":"---\nhalf_life: ZQSENTINEL7731\n---\nx",...}` logs the outcome `claim envelope: half_life: "ZQSENTINEL7731" is not one of ...`, so a frontmatter value is in the log. - `brain_update {"path":"wiki/nope/ZQSENTINEL7731.md",...}` puts the path in the outcome. These did not leak: capture's `insights[].text`, brain_write content on success, brain_edit `new_str`/`old_str`, and brain_query `query` (the query is not logged at all). ### Things that held under execution - REST `POST /write`: a body with `origin`, `written_by`, `Origin` and `WrittenBy` still produces `origin: unknown`. - Capture: `AuthPath`, `auth_path`, `principal` and `origin` set in `context`, in an insight, or at top level are all ignored, and the note gets `origin: jwt` / `written_by: attacker`. DecodeRequest maps fields explicitly. - These spellings were stripped on write: `origin:static`, `origin : static`, `origin\t: static`, CRLF fences, duplicate fence, second `---` block in the body, and anchors. - `brain_update` always re-stamps, so an update cannot delete `origin`. ### Flip tests (mechanism broken, then the suite run) | flip | result | |---|---| | writeHallNote lets caller origin/written_by through | RED | | UpdateNote stops re-stamping origin | RED | | provenanceFrom reports jwt as static | RED (capture test only) | | brainWrite drops Origin | RED | | audit logs raw content | RED | | UpdateNote fragment skip for origin/written_by removed | **GREEN**: masked by the later `fm.set`; the skip is unobserved | | capturehttp REST AuthPath always "static" | **GREEN**: REST capture provenance has no test | | extractProvenance reads body lines after the fence | **GREEN**: the doc comment's "a body line is not provenance" has no test | | audit outcome truncation removed | **GREEN** | | provenanceOrigin("") returns "" (REST /write would stamp an empty origin) | **GREEN**: REST /write provenance has no test | ### Rule 9 (test names vs assertions) and spec gaps - `TestBrainWriteOverwritesCallerSuppliedProvenance` claims overwrite in general, but checks one canonical spelling from an *unauthenticated* caller. The #145 spec asks for "written by a JWT principal ends up origin: jwt". E5 shows the general claim is false. - `TestBrainUpdateRestampsProvenanceAndIgnoresForgedFragment`: the "IgnoresForgedFragment" half is not observed (see the flip table), and quoted or case-variant fragments do survive. - `TestBrainWriteStampsPrincipalProvenance`: the spec asks for static **and** JWT; only static is tested. - `TestCaptureStampsJWTProvenance`: covers MCP capture only; REST capturehttp is untested. ### Required before re-review 1. E1, E2, E3 closed, each with a red-first test that reproduces the exact call above. 2. E4: every model-reachable writer stamps provenance, or the gap is an explicit, justified allowlist with a test that fails when a new writer appears. 3. E5: forged-key variants refused, with a table-driven test over the variants above that asserts on the file *and* on brain_get. 4. E6: outcome scrubbed or classified (for example, the error class only). 5. The green flips above given assertions. Not checked: live deploy and Loki ingestion; `brain_ingest` executed end to end (no LLM); the claudewatcher sink (internal, writes to `archive/`, not indexed); JWT `sub` containing a newline (validator-controlled).
mathias added 1 commit 2026-10-01 20:29:48 +00:00
fix(brain): provenance as a post-condition on every model-reachable write
CI / Lint / Test / Vet (pull_request) Successful in 45s
CI / Mirror to GitHub (pull_request) Skipped
e615d6f144
The PR #147 review forged origin: static as a JWT principal through
three tools the first version never touched (brain_edit, brain_ingest_raw,
ingest_raw + brain_promote), and through YAML spellings a key-name match
missed (quoted, escaped, tagged, flow-mapped, merge keys, lone CR).

- api.StampProvenance removes every spelling of origin/written_by from
  the frontmatter and appends the real values. A line is dropped if
  EITHER yaml.v3 (decoding quotes, escapes, tags, flow maps) or the
  brain's line-oriented readers would read it as provenance; complex
  keys, orphan values and merge keys are dropped outright. Lone CRs in
  frontmatter become LF. Principal values that are not bare words are
  quoted.
- Applied as the last step of writeHallNote, UpdateNote, PromoteNote
  (re-attributed to the promoter), brain_edit (re-attributed to the
  editor) and both ingests (every page written or merged). REST
  /write and /promote carry no principal and stamp unknown.
- Ingest: newlines in scalar fields fold to spaces; a page type must be
  a plain lower-case name (../raw wrote outside wiki/, pre-existing).
- Audit: outcome is ok|error, never the error text, which echoed
  argument values.

Tests: every escape from the review is a table case asserting the
invariant on the file (parses as YAML, yaml.v3 and brain_get agree,
exactly the stamped lines). Plus REST /write, REST capture AuthPath,
body-line provenance in search, and quoted principals; two of the
review's surviving flips now go red. Three tests renamed so names claim
only what they assert.

Refs #145 #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

pr-gatekeeper round 2: BLOCKED (head e615d6f)

I built e615d6f in a scratch worktree and drove the real mcp.Server and api.Handler over HTTP, with a JWT principal attacker and with no principal. Each written file was read back from disk and checked with yaml.v3, brain_query and brain_get. CI run 8698 is green and CI=1 task check exits 0. Green covers none of the findings below.

Round 1 is closed by execution. E1 through E6 and all 16 variants in my table now give origin=jwt written_by=attacker in yaml.v3, brain_query and brain_get alike, for write, update and the unauthenticated case. The ../raw type is refused. MCP ingest_raw pages are stamped. Audit outcomes no longer echo arguments. The new probes that held: block and folded scalars (continuation lines are dropped, which is safe), a BOM before the fence, --- and ---\t fences, a closing fence with a trailing space, and !!str-tagged aliases (yaml.v3 refuses to parse these).

R1 (high): a YAML alias key plus a ... document-end line forges provenance for yaml.v3

isProvenanceLine judges one line at a time, and an alias only resolves in context. On its own, *a : static fails to parse in yaml.v3 (unknown anchor), and the textual reader sees the key *a, so the line is kept. Then a ... line ends the YAML document before the appended stamp, so yaml.v3 never sees the real values.

# as JWT principal "attacker" (also works with no principal)
brain_write {"wing":"testing","hall":"facts","filename":"r-alias","content":
  "---\nx: &a origin\n*a : static\ny: &b written_by\n*b : local-cli\n...\n---\n# V\n\nbody"}

The file's frontmatter, parsed with yaml.v3, gives origin=static written_by=local-cli. brain_query and brain_get give jwt/attacker. That breaks the invariant this PR's own escapes test states ("yaml.v3 and brain_get agree").

  • The same payload through brain_edit (old_str k: v\n, new_str = the four alias lines plus ...\n) also gives yaml.v3 origin=static written_by=local-cli.
  • brain_update does not reproduce it, because its fragment parser drops the ... line.
  • With no principal, the ... is not needed. The aliases alone give yaml.v3 written_by=local-cli, because no stamped written_by line exists to collide with.

R2 (medium): ... alone hides provenance from yaml.v3

"---\nx: y\n...\n---\n# V\n\nbody" gives yaml.v3 origin=<nil>. Every YAML consumer then sees a note with no provenance, which is the "absence" case the #145 spec forbids.

Fix direction for R1 and R2: stop judging line by line and verify the whole block as a post-condition. After stamping, decode the complete frontmatter with yaml.v3, and require all of the following, refusing the write otherwise:

  • (a) it parses as a single document
  • (b) origin and written_by equal the stamp
  • (c) the textual reader agrees

Also refuse alias keys (*) and ... lines outright.

R3 (medium): the REST ingest doors still write notes with no provenance (E4 not closed)

stampPages exists only in the MCP server. api.Handler.IngestRaw, and by code Ingest and IngestPath, call the pipeline directly:

POST /ingest-raw {"source":"zqrest","pages":[{"title":"Zqrestpage","type":"concept","content":"zqrestbody"}]}

The result has no origin line at all: ---\ntitle: 'Zqrestpage'\nlast_updated: ...\naliases: ...\n---. The PR handles REST /write and /promote ("this door carries no principal → unknown") but not these three. Note also that the REST mux has no in-process auth (reg.Middleware(mux) only). That predates this PR and is worth its own issue.

R4 (medium): on REST /ingest-raw, the only defence against title injection is untested

On that door nothing stamps, so yamlScalar's CR/LF fold is the only thing that stops E2. With the fold removed, POST /ingest-raw with title "Zqrestinj\norigin: static\nwritten_by: local-cli\nx: 'y" makes search.Query return origin=static written_by=local-cli, and the full suite stays green (flip G11). TestBrainIngestRawStampsPagesAndNeutralisesTitleNewlines checks with yaml.v3, which reads the multi-line single-quoted title as one scalar, so it can never see the injected keys. The line-based readers can. Its "NeutralisesTitleNewlines" half is unobserved (rule 9).

Flip tests (round-2 mechanisms)

flip result
yamlSeesProvenance always false RED
textSeesProvenance always false RED
? / : / << drop removed RED
brain_edit re-stamp removed RED
ingest_raw stampPages removed RED
promote StampProvenance removed / MCP promote passes no principal RED / RED
page-type regexp removed RED
audit outcome back to error text RED
scalar() never quotes RED
UpdateNote stamp removed RED
lone-CR normalisation removed GREEN: redundant today, because yaml.v3 treats the CR as a line break and drops the line; unobserved
brain_ingest (LLM path) stampPages removed GREEN: no test (it needs a fake Complete)
yamlScalar CR/LF fold removed GREEN: see R4

Audit logging

Still useful: tool, principal, origin, wing, hall, content_bytes and duration are all present. outcome is now only ok/error, so operators lose the error class. Acceptable, though a fixed error-category enum would recover it without echoing input. Not blocking.

Minor (not blocking, but file them)

  • brain_tunnel appends a See-also wikilink to both notes' bodies with no re-stamp. Content changes while attribution stays with the previous writer; it is a link to an existing path only.
  • brain_ingest with path reads an arbitrary server file via extract.Text(a.Path). This predates the PR and is an unvalidated boundary.

Required before re-review

  1. R1 and R2: whole-block post-condition verification, with tests for the alias + ... payload through brain_write and brain_edit, and the unauthenticated alias-only payload.
  2. R3: REST /ingest, /ingest-path and /ingest-raw stamp (origin unknown), with a test per door.
  3. R4: a test that fails when the yamlScalar fold is removed, asserting with the line-based reader (or search.Query) on the REST door.
  4. A test for brain_ingest stamping (fake Complete).

Not checked: live deploy, Loki, and other YAML consumers outside this repo (brain-gardener). I didn't check which parser they use, but R1 holds against yaml.v3, the parser this PR itself delegates to.

## pr-gatekeeper round 2: BLOCKED (head e615d6f) I built e615d6f in a scratch worktree and drove the real `mcp.Server` and `api.Handler` over HTTP, with a JWT principal `attacker` and with no principal. Each written file was read back from disk and checked with yaml.v3, `brain_query` and `brain_get`. CI run 8698 is green and `CI=1 task check` exits 0. **Green covers none of the findings below.** **Round 1 is closed by execution.** E1 through E6 and all 16 variants in my table now give `origin=jwt written_by=attacker` in yaml.v3, brain_query and brain_get alike, for write, update and the unauthenticated case. The `../raw` type is refused. MCP ingest_raw pages are stamped. Audit outcomes no longer echo arguments. The new probes that held: block and folded scalars (continuation lines are dropped, which is safe), a BOM before the fence, `--- ` and `---\t` fences, a closing fence with a trailing space, and `!!str`-tagged aliases (yaml.v3 refuses to parse these). ### R1 (high): a YAML alias key plus a `...` document-end line forges provenance for yaml.v3 `isProvenanceLine` judges one line at a time, and an alias only resolves in context. On its own, `*a : static` fails to parse in yaml.v3 (unknown anchor), and the textual reader sees the key `*a`, so the line is kept. Then a `...` line ends the YAML document *before* the appended stamp, so yaml.v3 never sees the real values. ``` # as JWT principal "attacker" (also works with no principal) brain_write {"wing":"testing","hall":"facts","filename":"r-alias","content": "---\nx: &a origin\n*a : static\ny: &b written_by\n*b : local-cli\n...\n---\n# V\n\nbody"} ``` The file's frontmatter, parsed with yaml.v3, gives `origin=static written_by=local-cli`. brain_query and brain_get give `jwt/attacker`. That breaks the invariant this PR's own escapes test states ("yaml.v3 and brain_get agree"). - The same payload through **brain_edit** (old_str `k: v\n`, new_str = the four alias lines plus `...\n`) also gives yaml.v3 `origin=static written_by=local-cli`. - brain_update does not reproduce it, because its fragment parser drops the `...` line. - **With no principal, the `...` is not needed.** The aliases alone give yaml.v3 `written_by=local-cli`, because no stamped written_by line exists to collide with. ### R2 (medium): `...` alone hides provenance from yaml.v3 `"---\nx: y\n...\n---\n# V\n\nbody"` gives yaml.v3 `origin=<nil>`. Every YAML consumer then sees a note with no provenance, which is the "absence" case the #145 spec forbids. **Fix direction for R1 and R2:** stop judging line by line and verify the whole block as a post-condition. After stamping, decode the complete frontmatter with yaml.v3, and require all of the following, refusing the write otherwise: - (a) it parses as a single document - (b) origin and written_by equal the stamp - (c) the textual reader agrees Also refuse alias keys (`*`) and `...` lines outright. ### R3 (medium): the REST ingest doors still write notes with no provenance (E4 not closed) `stampPages` exists only in the MCP server. `api.Handler.IngestRaw`, and by code `Ingest` and `IngestPath`, call the pipeline directly: ``` POST /ingest-raw {"source":"zqrest","pages":[{"title":"Zqrestpage","type":"concept","content":"zqrestbody"}]} ``` The result has no origin line at all: `---\ntitle: 'Zqrestpage'\nlast_updated: ...\naliases: ...\n---`. The PR handles REST `/write` and `/promote` ("this door carries no principal → unknown") but not these three. Note also that the REST mux has no in-process auth (`reg.Middleware(mux)` only). That predates this PR and is worth its own issue. ### R4 (medium): on REST `/ingest-raw`, the only defence against title injection is untested On that door nothing stamps, so `yamlScalar`'s CR/LF fold is the only thing that stops E2. With the fold removed, `POST /ingest-raw` with title `"Zqrestinj\norigin: static\nwritten_by: local-cli\nx: 'y"` makes `search.Query` return `origin=static written_by=local-cli`, **and the full suite stays green** (flip G11). `TestBrainIngestRawStampsPagesAndNeutralisesTitleNewlines` checks with yaml.v3, which reads the multi-line single-quoted title as one scalar, so it can never see the injected keys. The line-based readers can. Its "NeutralisesTitleNewlines" half is unobserved (rule 9). ### Flip tests (round-2 mechanisms) | flip | result | |---|---| | yamlSeesProvenance always false | RED | | textSeesProvenance always false | RED | | `?` / `:` / `<<` drop removed | RED | | brain_edit re-stamp removed | RED | | ingest_raw stampPages removed | RED | | promote StampProvenance removed / MCP promote passes no principal | RED / RED | | page-type regexp removed | RED | | audit outcome back to error text | RED | | scalar() never quotes | RED | | UpdateNote stamp removed | RED | | lone-CR normalisation removed | **GREEN**: redundant today, because yaml.v3 treats the CR as a line break and drops the line; unobserved | | `brain_ingest` (LLM path) stampPages removed | **GREEN**: no test (it needs a fake `Complete`) | | yamlScalar CR/LF fold removed | **GREEN**: see R4 | ### Audit logging Still useful: tool, principal, origin, wing, hall, content_bytes and duration are all present. `outcome` is now only `ok`/`error`, so operators lose the error class. Acceptable, though a fixed error-category enum would recover it without echoing input. Not blocking. ### Minor (not blocking, but file them) - `brain_tunnel` appends a See-also wikilink to both notes' bodies with no re-stamp. Content changes while attribution stays with the previous writer; it is a link to an existing path only. - `brain_ingest` with `path` reads an arbitrary server file via `extract.Text(a.Path)`. This predates the PR and is an unvalidated boundary. ### Required before re-review 1. R1 and R2: whole-block post-condition verification, with tests for the alias + `...` payload through brain_write and brain_edit, and the unauthenticated alias-only payload. 2. R3: REST `/ingest`, `/ingest-path` and `/ingest-raw` stamp (origin unknown), with a test per door. 3. R4: a test that fails when the yamlScalar fold is removed, asserting with the *line-based* reader (or `search.Query`) on the REST door. 4. A test for brain_ingest stamping (fake `Complete`). Not checked: live deploy, Loki, and other YAML consumers outside this repo (brain-gardener). I didn't check which parser they use, but R1 holds against yaml.v3, the parser this PR itself delegates to.
mathias added 1 commit 2026-10-01 20:55:53 +00:00
fix(brain): provenance keeps only provably plain frontmatter; pipeline stamps
CI / Lint / Test / Vet (pull_request) Successful in 43s
CI / Mirror to GitHub (pull_request) Skipped
fcae16569d
Round 2 of the PR #147 review forged origin: static for yaml.v3 with an
alias key plus a "..." document-end line: per-line judgement cannot see
context-dependent YAML. It also found REST ingest writing pages with no
provenance (stampPages lived only in the MCP server).

- New package internal/provenance. Apply keeps only top-level groups
  `key: value` with a bare-identifier key whose group decodes on its own
  to exactly that key, drops reserved and duplicate keys and everything
  else (aliases, anchors as keys, document markers, complex/merge keys,
  flow maps, tags, quoted keys), appends the stamp, then decodes the
  whole result with yaml.v3 and refuses the write unless it reads back
  exactly the stamp. api.StampProvenance wraps it and now returns an
  error, which every caller propagates.
- The ingest pipeline stamps every page it writes or merges, from
  provenance.FromContext(ctx); MCP attaches the caller's stamp, REST and
  the watcher stamp unknown. RunRaw takes a ctx.
- brain_edit snapshots the note and rolls the edit back if the stamp
  cannot be verified.

Tests: R1/R2 payloads join the write/update variant table, plus
brain_edit with the alias payload; REST /ingest-raw (title newline
checked with the line-oriented reader) and REST /ingest (stub LLM) stamp
unknown. Two existing byte-exact tests updated for the stamp line.

Refs #145 #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

pr-gatekeeper round 3: BLOCKED (head fcae165)

I built fcae165 in a scratch worktree and drove the real mcp.Server and api.Handler, with JWT principals of several shapes and with no principal. Each file was read back from disk and checked with yaml.v3, brain_query and brain_get. CI run 8701 is green and CI=1 task check exits 0. Green covers none of B1–B3.

The new allowlist holds against forgery. Every round-1 and round-2 probe (all 16 variants, alias and ... payloads, block and folded scalars, BOM, fence variants, REST ingest-raw with title injection) gives the real stamp in yaml.v3, brain_query and brain_get. These fresh attacks were all dropped or harmless, with the stamp intact in every reader:

  • y/n/on/off/yes/true/null/True/a./_ keys
  • an anchor in a kept value aliased from another group's value (the alias group is dropped)
  • a: &origin static
  • nested mappings at mismatched indents
  • - origin: items
  • tab-indented continuations
  • |2 explicit indentation
  • a ---x line

I found no way to make any reader report origin: static.

B1 (medium, blocks): brain_edit can hide provenance from brain_query

search.extractProvenance treats any line whose TrimSpace is --- as the closing fence, including an indented continuation inside a block scalar. plainGroups correctly keeps k: |\n --- as a plain group (it decodes alone to k: "---\n"), so the stamp lands after a line that the search reader takes as the end of the frontmatter.

# as JWT principal "attacker", brain dir with .git
brain_write {"content":"---\nk: v\n---\n# E\n\nzqr3editdash","wing":"testing","hall":"facts","filename":"ed"}
brain_edit  {"path":"wiki/testing/facts/ed.md","old_str":"k: v\n","new_str":"k: |\n  ---\n"}

The file is ...k: |\n ---\norigin: jwt\nwritten_by: attacker\n---. brain_get shows origin: jwt, but brain_query zqr3editdash returns no origin and no written_by, so the note looks like a pre-provenance legacy note. That is the "absence must not read as trusted" case again (round-2 R2), now on the primary retrieval surface. brain_write does not reproduce it, because writeHallNote's splitter cuts at ---; brain_edit does.

Fix: make extractProvenance use the same fence rule as frontmatter.Split (a column-0 --- only), ideally by calling it. Add a test with this exact payload, asserting on brain_query.

B2 (medium, blocks): brain_edit's rollback swallows its own failure (iron law 2)

In mcp/tools_notes.go brainEdit:

  • _ = json.Unmarshal(args, &a)
  • prior, _ = os.ReadFile(...)
  • _ = os.WriteFile(t.Abs, prior, 0o644)

If the snapshot read or the restore fails, or notes.Resolve fails, nothing is restored, yet the tool still returns "edit rolled back". The note is then left with the attacker's edit under the previous writer's stamp, which is exactly the misattribution this PR exists to prevent. The rollback also has no test: removing the restore entirely stays green (flip H11). Fix: propagate the snapshot error before editing, return a distinct error if the restore fails, and test both.

B3 (medium, blocks): the backstop the design rests on has no test

The commit says the whole-document yaml.v3 verify is the guarantee. Flips:

  • verify returns nil: GREEN
  • decodesToOnly returns true: GREEN
  • both together: GREEN

internal/provenance has no test file, and no test references ErrUnverifiable. I exercised the error path myself with JWT subjects true, 123, null, 1e3 and 0x1F, which scalar() emits bare and yaml.v3 decodes as non-strings. The refusals were clean:

caller result when verify fails
brain_write refused, no note written. The empty hall directory is left behind, regressing the brain#21 "rejected write leaves nothing, not even an empty hall dir" invariant
brain_update refused, file byte-identical
brain_edit refused, rolled back, file byte-identical
brain_promote refused, raw source survives, no destination
brain_ingest_raw (two pages) refused, no page written
capture per-insight error, no file

That behaviour is correct, and nothing would notice its removal. Required: table-driven tests over those callers with a principal that fails verify, asserting refusal and no file (and no directory). Also add a test where a keyLine-matching group fails to decode alone (e.g. a: b\n origin: c) and is dropped.

Other flips

flip result
plainGroups keeps everything RED
reserved-key check off RED
lower-casing off RED
pipeline stamp removed RED (MCP ingest_raw, REST ingest-raw, REST ingest)
MCP ingest_raw passes no caller stamp RED
scalar() never quotes RED
duplicate dedupe off GREEN (verify or yaml would catch it; unasserted)
CR folding off GREEN
MCP brain_ingest (LLM path) passes no caller stamp GREEN: only the REST stub is tested
tabs treated as continuation GREEN (yaml rejects tabs; defence in depth)

Not blocking, but please fix or file

  • scalar() emits true/123/null/1e3/0x1F bare, so a principal with that shape has every write refused. It fails closed, but quoting anything yaml.v3 would not decode as a string costs nothing.
  • A ---x line in content frontmatter makes frontmatter.Split fail, so Apply prepends a fresh stamp block. The whole server frontmatter (wing/hall/created_at) and the caller's unfiltered block, origin: static included, then sit in the body. Readers take the first block, so this is not forgery, but brain_get loses wing/hall and brain_read shows a second block reading origin: static. Refusing such content would be cleaner.
  • The hall-directory leftover from B3.

Required before re-review

  1. B1 fix plus a test of that payload through brain_edit, asserting on brain_query.
  2. B2: no swallowed errors in the snapshot or rollback, with tests for restore success and restore failure.
  3. B3: tests for the verify refusal across all callers (no file, no directory) and for the decodesToOnly drop.

Not checked: live deploy and Loki; YAML consumers outside this repo.

## pr-gatekeeper round 3: BLOCKED (head fcae165) I built fcae165 in a scratch worktree and drove the real `mcp.Server` and `api.Handler`, with JWT principals of several shapes and with no principal. Each file was read back from disk and checked with yaml.v3, `brain_query` and `brain_get`. CI run 8701 is green and `CI=1 task check` exits 0. **Green covers none of B1–B3.** **The new allowlist holds against forgery.** Every round-1 and round-2 probe (all 16 variants, alias and `...` payloads, block and folded scalars, BOM, fence variants, REST ingest-raw with title injection) gives the real stamp in yaml.v3, brain_query and brain_get. These fresh attacks were all dropped or harmless, with the stamp intact in every reader: - `y`/`n`/`on`/`off`/`yes`/`true`/`null`/`True`/`a.`/`_` keys - an anchor in a kept value aliased from another group's value (the alias group is dropped) - `a: &origin static` - nested mappings at mismatched indents - `- origin:` items - tab-indented continuations - `|2` explicit indentation - a `---x` line I found no way to make any reader report `origin: static`. ### B1 (medium, blocks): `brain_edit` can hide provenance from `brain_query` `search.extractProvenance` treats *any* line whose `TrimSpace` is `---` as the closing fence, including an indented continuation inside a block scalar. `plainGroups` correctly keeps `k: |\n ---` as a plain group (it decodes alone to `k: "---\n"`), so the stamp lands after a line that the search reader takes as the end of the frontmatter. ``` # as JWT principal "attacker", brain dir with .git brain_write {"content":"---\nk: v\n---\n# E\n\nzqr3editdash","wing":"testing","hall":"facts","filename":"ed"} brain_edit {"path":"wiki/testing/facts/ed.md","old_str":"k: v\n","new_str":"k: |\n ---\n"} ``` The file is `...k: |\n ---\norigin: jwt\nwritten_by: attacker\n---`. `brain_get` shows `origin: jwt`, but **`brain_query zqr3editdash` returns no origin and no written_by**, so the note looks like a pre-provenance legacy note. That is the "absence must not read as trusted" case again (round-2 R2), now on the primary retrieval surface. `brain_write` does not reproduce it, because `writeHallNote`'s splitter cuts at ` ---`; `brain_edit` does. Fix: make `extractProvenance` use the same fence rule as `frontmatter.Split` (a column-0 `---` only), ideally by calling it. Add a test with this exact payload, asserting on `brain_query`. ### B2 (medium, blocks): `brain_edit`'s rollback swallows its own failure (iron law 2) In `mcp/tools_notes.go` `brainEdit`: - `_ = json.Unmarshal(args, &a)` - `prior, _ = os.ReadFile(...)` - `_ = os.WriteFile(t.Abs, prior, 0o644)` If the snapshot read or the restore fails, or `notes.Resolve` fails, nothing is restored, yet the tool still returns "edit rolled back". The note is then left with the attacker's edit under the *previous* writer's stamp, which is exactly the misattribution this PR exists to prevent. The rollback also has no test: removing the restore entirely stays green (flip H11). Fix: propagate the snapshot error before editing, return a distinct error if the restore fails, and test both. ### B3 (medium, blocks): the backstop the design rests on has no test The commit says the whole-document yaml.v3 verify is the guarantee. Flips: - `verify` returns nil: **GREEN** - `decodesToOnly` returns true: **GREEN** - **both together: GREEN** `internal/provenance` has no test file, and no test references `ErrUnverifiable`. I exercised the error path myself with JWT subjects `true`, `123`, `null`, `1e3` and `0x1F`, which `scalar()` emits bare and yaml.v3 decodes as non-strings. The refusals were clean: | caller | result when verify fails | |---|---| | brain_write | refused, no note written. **The empty hall directory is left behind**, regressing the brain#21 "rejected write leaves nothing, not even an empty hall dir" invariant | | brain_update | refused, file byte-identical | | brain_edit | refused, rolled back, file byte-identical | | brain_promote | refused, raw source survives, no destination | | brain_ingest_raw (two pages) | refused, no page written | | capture | per-insight error, no file | That behaviour is correct, and nothing would notice its removal. Required: table-driven tests over those callers with a principal that fails verify, asserting refusal and no file (and no directory). Also add a test where a `keyLine`-matching group fails to decode alone (e.g. `a: b\n origin: c`) and is dropped. ### Other flips | flip | result | |---|---| | plainGroups keeps everything | RED | | reserved-key check off | RED | | lower-casing off | RED | | pipeline stamp removed | RED (MCP ingest_raw, REST ingest-raw, REST ingest) | | MCP ingest_raw passes no caller stamp | RED | | scalar() never quotes | RED | | duplicate dedupe off | GREEN (verify or yaml would catch it; unasserted) | | CR folding off | GREEN | | MCP `brain_ingest` (LLM path) passes no caller stamp | GREEN: only the REST stub is tested | | tabs treated as continuation | GREEN (yaml rejects tabs; defence in depth) | ### Not blocking, but please fix or file - `scalar()` emits `true`/`123`/`null`/`1e3`/`0x1F` bare, so a principal with that shape has *every* write refused. It fails closed, but quoting anything yaml.v3 would not decode as a string costs nothing. - A `---x` line in content frontmatter makes `frontmatter.Split` fail, so `Apply` prepends a fresh stamp block. The whole server frontmatter (wing/hall/created_at) and the caller's unfiltered block, `origin: static` included, then sit in the body. Readers take the first block, so this is not forgery, but `brain_get` loses wing/hall and `brain_read` shows a second block reading `origin: static`. Refusing such content would be cleaner. - The hall-directory leftover from B3. ### Required before re-review 1. B1 fix plus a test of that payload through brain_edit, asserting on brain_query. 2. B2: no swallowed errors in the snapshot or rollback, with tests for restore success and restore failure. 3. B3: tests for the verify refusal across all callers (no file, no directory) and for the decodesToOnly drop. Not checked: live deploy and Loki; YAML consumers outside this repo.
mathias added 1 commit 2026-10-01 21:03:11 +00:00
fix(brain): close round-3 review findings on provenance
CI / Lint / Test / Vet (pull_request) Successful in 37s
CI / Mirror to GitHub (pull_request) Skipped
7afbd60ea2
- B1: search.extractProvenance locates the frontmatter with
  frontmatter.Split (fence at column 0), so an indented "---" inside a
  block scalar no longer hides provenance from brain_query.
- B2: brain_edit refuses to edit without a snapshot, and if the stamp
  cannot be verified restores the note and reports failure; a failed
  restore is reported as exactly that, never as "rolled back".
- B3: internal/provenance gets direct tests for the whole-document
  backstop (a stamp that cannot read back is refused), standalone group
  decoding, duplicates, lone CR, tab continuations and unclosable
  fences. Removing verify or the restore now fails the suite.
- Principals YAML would read as non-strings (true, 123, null, ~) are
  quoted instead of refused. An unclosable fence ("---x") is refused
  rather than demoted into the body. writeHallNote creates the hall
  directory only after the note passes, so a refused write leaves
  nothing behind (brain#21).
- MCP brain_ingest (stub LLM) asserted to stamp the caller.

Refs #145 #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mathias merged commit a6da93332d into main 2026-10-01 21:06:36 +00:00
mathias deleted branch feat/mcp-audit-provenance 2026-10-01 21:06:36 +00:00
Author
Owner

pr-gatekeeper round 4: MERGED (7afbd60, squashed as a6da933)

I verified by execution in a scratch worktree, driving the real mcp.Server and api.Handler with several JWT principals and with none, reading every file back and checking it with yaml.v3 (via frontmatter.Split), brain_query and brain_get.

Re-run, all hold. Every round-1/2/3 probe:

  • 16 frontmatter variants through write, update and unauthenticated writes
  • alias + ... payloads
  • block and folded scalars, BOM, fence variants
  • MCP and REST ingest-raw, including title injection
  • capture body fields, REST /write body fields
  • the audit sentinel
  • B1's k: |\n --- through brain_edit: brain_query now reports jwt/attacker
  • a ---x line: now refused

New attacks, all hold:

  • brain_edit removing the closing fence, removing the opening fence, or inserting ---x: refused before the edit, file byte-identical.
  • JWT subject bad\xff (a principal that can't round-trip): brain_write refused with no directory; brain_update and brain_edit refused with the file byte-identical; promote refused with the source surviving; ingest_raw refused with no pages; capture gives a per-insight error and no file.
  • Principals a"b, ö-user, x: y, -, a b, #c, a\nb, true, ~ and origin: static: yaml.v3 reads back exactly the principal, and origin stays jwt.

Flips: everything from my round-3 table now goes red, including verify off, decodesToOnly off, both together, dedupe off, CR folding off, MCP brain_ingest without the caller stamp, and the brain_edit restore removed. The new mechanisms also go red: the scalar round-trip check, the unclosed-fence refusal, directory creation after the stamp, and the shared fence rule in extractProvenance. Two flips stay green, and neither is a gap:

  • tabs as continuation: yaml.v3 already rejects tabs, so this is defence in depth
  • the snapshot-read guard in brain_edit: when that read fails, the edit's own read fails too, so the guard can't be observed

CI 8708 on 7afbd60 succeeded; CI=1 task check exits 0; gitleaks found nothing.

Not blocking, filed as a follow-up (see the linked issue):

  1. search.extractProvenance strips quote characters instead of decoding the YAML scalar. A JWT subject 'local-cli' displays as written_by=local-cli in brain_query (origin stays jwt). The caller can't choose its own subject, so this is out of the caller's reach, but the read side should decode properly. brain_get returns the raw quoted text.
  2. PromoteNote creates the wing directory before the stamp, so a refused promote leaves an empty directory (the brain#21 invariant).
  3. Multi-page ingest stops at the first page that can't be stamped; earlier pages stay written (each correctly stamped).

Not verified: the #144 clause "after deploy, Loki shows mcp tool call records for a live brain_query" needs a deploy. YAML consumers outside this repo were not checked.

## pr-gatekeeper round 4: MERGED (7afbd60, squashed as a6da933) I verified by execution in a scratch worktree, driving the real `mcp.Server` and `api.Handler` with several JWT principals and with none, reading every file back and checking it with yaml.v3 (via `frontmatter.Split`), `brain_query` and `brain_get`. **Re-run, all hold.** Every round-1/2/3 probe: - 16 frontmatter variants through write, update and unauthenticated writes - alias + `...` payloads - block and folded scalars, BOM, fence variants - MCP and REST ingest-raw, including title injection - capture body fields, REST /write body fields - the audit sentinel - B1's `k: |\n ---` through brain_edit: brain_query now reports `jwt/attacker` - a `---x` line: now refused **New attacks, all hold:** - `brain_edit` removing the closing fence, removing the opening fence, or inserting `---x`: refused before the edit, file byte-identical. - JWT subject `bad\xff` (a principal that can't round-trip): brain_write refused with no directory; brain_update and brain_edit refused with the file byte-identical; promote refused with the source surviving; ingest_raw refused with no pages; capture gives a per-insight error and no file. - Principals `a"b`, `ö-user`, `x: y`, `-`, `a b`, `#c`, `a\nb`, `true`, `~` and `origin: static`: yaml.v3 reads back exactly the principal, and origin stays `jwt`. **Flips: everything from my round-3 table now goes red**, including verify off, decodesToOnly off, both together, dedupe off, CR folding off, MCP brain_ingest without the caller stamp, and the brain_edit restore removed. The new mechanisms also go red: the scalar round-trip check, the unclosed-fence refusal, directory creation after the stamp, and the shared fence rule in extractProvenance. Two flips stay green, and neither is a gap: - tabs as continuation: yaml.v3 already rejects tabs, so this is defence in depth - the snapshot-read guard in brain_edit: when that read fails, the edit's own read fails too, so the guard can't be observed CI 8708 on 7afbd60 succeeded; `CI=1 task check` exits 0; gitleaks found nothing. **Not blocking, filed as a follow-up** (see the linked issue): 1. `search.extractProvenance` strips quote characters instead of decoding the YAML scalar. A JWT subject `'local-cli'` displays as `written_by=local-cli` in brain_query (origin stays `jwt`). The caller can't choose its own subject, so this is out of the caller's reach, but the read side should decode properly. brain_get returns the raw quoted text. 2. `PromoteNote` creates the wing directory before the stamp, so a refused promote leaves an empty directory (the brain#21 invariant). 3. Multi-page ingest stops at the first page that can't be stamped; earlier pages stay written (each correctly stamped). **Not verified:** the #144 clause "after deploy, Loki shows `mcp tool call` records for a live brain_query" needs a deploy. YAML consumers outside this repo were not checked.
Sign in to join this conversation.