Review round 1 on PR #194:
- The edit lock did not exclude other processes (also on main): unlock
removes the lock file, so a waiter could wake holding a flock on the
orphaned inode while the next caller locked a fresh file. flockFile now
re-checks after flock that the locked file is still the one at the path
(brainfs LockIsCurrent) and retries otherwise; OpenLock reports a lock
replaced mid-open as ErrLockChanged, which is retried instead of
failing the edit. New cross-process test (3 re-executed processes x 400
rounds, O_EXCL marker under the lock): 0 overlaps; red without the
re-check.
- OpenLock unit test: relative dangling link (only the Lstat pre-check
stops it; os.Root refuses absolute links on its own), live link, FIFO,
directory, behind a symlinked dir, unclean path, positive control.
- Rollback test: a symlink swapped in for the note before the rollback
(test seam beforeStampHook) is refused, target untouched in and outside
the brain.
- The in-brain dangling-lock case uses a relative link and asserts the
refusal names the lock. OpenLock reuses #189's RealParent; RemoveLock is
dropped for brainfs.Remove. The doc comment names the swap residual.
Rebased onto main after #189.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brain_edit did its own I/O by path around notes.Edit:
- the pre-edit snapshot used os.ReadFile, which hung on a FIFO note held
open by a writer;
- the rollback (when the provenance stamp fails) used os.WriteFile;
- the edit lock "<note>.lock" was opened with O_CREATE by path, so a
planted dangling .lock symlink made a successful edit create a file at
the link's target, in the brain or outside it.
Snapshot is brainfs ReadRegular, rollback WriteRegular, and the lock is
opened and removed through new brainfs OpenLock/RemoveLock: clean path,
real parent directories, an existing lock must be regular, and the opened
handle must be the file at the path (SameFile).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes#144 (audit line per tools/call) and #145 (origin/written_by stamped on every model-reachable write, surfaced in brain_query/brain_get).
Passed pr-gatekeeper after four rounds of adversarial review, verified by execution: #147
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mathias <mthbqv@gmail.com>
7 review rounds closed 8 distinct bypasses across two mechanism families: fence-detection (field-name substring, block-containment, fence-stripping, CRLF, UTF-8 BOM) and value-extraction (folded YAML block scalar hiding an invalid value). A known v1 gap remains in the block-scalar fold's handling of blank-line-interrupted continuations — documented on checkClaimEnvelope, accepted deliberately (narrow, requires an already-privileged caller deliberately constructing a specific malformed-YAML shape) rather than chased past diminishing returns. Real fix (shared, tested, encoding-normalized frontmatter parser) tracked separately as hyperguild#138.
Co-Authored-By: Claude Sonnet 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>