Review round 1 on PR #189 (three tests stayed green with their guard
removed):
- TestEditRefusesANoteBehindASymlinkedDirectory had no .git, so Edit
refused "not the sync source" before any path check. It now has one,
plus a positive control.
- AppendRegular's unclean-path case was refused by a final symlink, not
cleanRel. New test where ".." after a symlinked dir resolves to a real
file, for AppendRegular and PutRegular.
- brainfs.Remove had no test; new one (refuses behind a symlinked dir,
removes a final link but never its target, positive control).
Also: promote/reject create with O_EXCL (new brainfs.CreateRegular), so a
note that appears after the collision check is never overwritten;
gofmt fixes in provenance.go and wiki/log.go; dead wingDir and
putBrainFile removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Writers that created or rewrote brain files by path followed symlinks:
- promote/reject: os.MkdirAll + os.WriteFile of the destination, then
os.Remove of the raw source. Now PutRegular + brainfs.Remove; the source
is kept when the write is refused.
- StampProvenanceFile (brain_edit's re-stamp): provenance.ApplyFile read
and rewrote by path. Now ReadRegular + WriteRegular; ApplyFile, now
unused, is removed.
- BuildWingIndex (_index.md) and wiki.RebuildIndex (wiki/index.md): now
PutRegular.
- wiki.AppendLog (log.md) and AutoTunnel's fuzzy-candidate log
(raw/tunnel-candidates-*.md): now AppendRegular; tunnel's fileExists,
now unused, is removed.
- New brainfs.Remove: clean path, real parent, removes a final symlink
itself, never its target.
brain_edit's own atomic rename was already refused behind a symlinked
directory by #187's read check; a guard test pins it.
Pipeline writes, BackfillRefs, the watcher's raw/ markers and the session
archive in cmd/server follow in a separate PR (#158).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
readExcerpt, ReadNote and ReadScoped now read through brainfs (os.Root, regular files only, type checked on the opened handle), so a symlink swapped in after a check is not followed and a FIFO does not hang the reader. ReadRegularMax bounds the read itself with io.LimitReader(max+1). notes.Target carries the brain root.
Refs #157. Adversarial review round 2: PASS (round 1 BLOCKED: unbounded read, rule-9 test names).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
brain_edit_root was gated but unobserved: on success it emitted a slog.Warn
and nothing else. That proves a rewrite was allowed, not that one happened —
and brain-sync commits it within 15 minutes authored `brain-sync`, so git
history cannot tell a CLAUDE.md rewrite apart from routine note traffic.
A successful brain_edit_root now emits an ntfy notification and an AuditEntry
through the same sink a capture already uses. notes.Result gains
PrevContentHash so both ends of the change are identifiable.
Path and hashes only in the notification body. Reserved at Internal, not
Confidential, because Confidential makes DegradingSink refuse to record while
loki is down rather than buffer. Observation is best-effort and never fails
the call; a failure to observe logs at ERROR.
Ordinary brain_edit on knowledge//wiki/ is deliberately not audited — git
history already carries the same before/after bytes with a diff.
Closes#97.
Co-authored-by: Mathias <mthbqv@gmail.com>
brain_read returns a note verbatim; brain_edit is str_replace with an
exactly-once match, operating on the tree brain-sync commits from.
An adversarial design review before implementation changed three things:
1. Governance files are not in the model-facing tool. Brain content is
ingested from the outside world and reaches a model's context, and that
model would hold the editor — one poisoned page could rewrite the
CLAUDE.md a .dispatch-allow agent later loads as instructions. And
brain-sync merges with --strategy-option=ours, so the live tree beats a
human's corrective push on the conflicting lines. CLAUDE.md now goes
through brain_edit_root: de-surfaced from tools/list and gated on the
local static principal. The scopes are disjoint — the privileged door
has the smaller reach.
2. Reads needed the same allowlist, and brain_get already lacked it: it
returned verbatim session transcripts under archive/ by path. A test
proved the exposure live before the fix.
3. Canonicalise before allowlisting — knowledge/../.git/hooks/post-commit
would otherwise reach a hook that brain-sync executes in a pod holding
the Gitea push credentials.
Plus overlapping match counting, UTF-8 validation both ways, refusal while
git is mid-operation, flock with the read inside the lock, atomic
same-dir rename, and reindex only when frontmatter is touched.
Refs brain#4.
Co-authored-by: Mathias <mthbqv@gmail.com>