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

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>
This commit is contained in:
2026-10-01 20:49:11 +02:00
co-authored by Claude Opus 5.5
parent a9a9eebe26
commit 32dedb318d
13 changed files with 277 additions and 25 deletions
+12
View File
@@ -95,6 +95,18 @@ func (f *frontmatter) set(key, value string) {
f.lines = append(f.lines, fmLine{key: key, value: value})
}
// del removes every top-level line for key. Continuation lines of a
// multi-line value are not tracked per key, so del is only for scalars.
func (f *frontmatter) del(key string) {
kept := f.lines[:0]
for _, l := range f.lines {
if l.key != key {
kept = append(kept, l)
}
}
f.lines = kept
}
// render serialises the frontmatter back into a `---`-fenced block. An
// empty frontmatter renders to the empty string so bodies without a
// header stay header-less.
+29 -1
View File
@@ -118,6 +118,20 @@ type WriteNoteOptions struct {
Wing string
Hall string
SourceType string // "internal" marks a first-party observation (e.g. claudewatcher) that needs no external citation
// Origin/WrittenBy are server-owned provenance (hyperguild#145): any
// origin/written_by in Content's own frontmatter is discarded, so a
// caller cannot claim a more trusted origin than it authenticated as.
Origin string
WrittenBy string
}
// provenanceOrigin normalises an origin for the frontmatter: absence must
// never read as trusted, so empty becomes "unknown".
func provenanceOrigin(o string) string {
if o == "" {
return "unknown"
}
return o
}
// WriteNote writes a markdown note into the brain. Returns the path
@@ -180,6 +194,11 @@ func writeHallNote(brainDir string, opts WriteNoteOptions) (string, error) {
fmt.Fprintf(&fm, "hall: %s\n", opts.Hall)
fmt.Fprintf(&fm, "created_at: %s\n", time.Now().UTC().Format(time.RFC3339))
emitted["wing"], emitted["hall"], emitted["created_at"] = true, true, true
fmt.Fprintf(&fm, "origin: %s\n", provenanceOrigin(opts.Origin))
emitted["origin"], emitted["written_by"] = true, true
if opts.WrittenBy != "" {
fmt.Fprintf(&fm, "written_by: %s\n", opts.WrittenBy)
}
// writeField merges one key: opts.Content's own value (if the note already
// carries this field in its own frontmatter) always wins over the fallback,
@@ -295,7 +314,16 @@ func (h *Handler) Write(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}
relPath, err := WriteNote(h.brainDir, WriteNoteOptions(req))
// Field by field, not a struct conversion: provenance is server-owned and
// this door carries no principal, so it stamps origin: unknown
// (hyperguild#145). A JSON body must never be able to set it.
relPath, err := WriteNote(h.brainDir, WriteNoteOptions{
Content: req.Content,
Filename: req.Filename,
Wing: req.Wing,
Hall: req.Hall,
SourceType: req.SourceType,
})
if err != nil {
h.logger.Error("write failed", "err", err)
writeError(w, http.StatusBadRequest, err.Error())
+13 -1
View File
@@ -46,6 +46,11 @@ type UpdateNoteOptions struct {
Slug string
Content string // new full body (whole-note replace)
Reason string // optional; stamped as supersede_reason
// Origin/WrittenBy re-stamp provenance to the updating principal: the
// body is replaced wholesale, so the last writer is the author of what
// a reader sees (hyperguild#145). Never taken from Content's fragment.
Origin string
WrittenBy string
}
// UpdateNote supersedes an existing note in place. It replaces the body
@@ -122,12 +127,19 @@ func UpdateNote(brainDir string, opts UpdateNoteOptions) (relPath, contentHash,
// overwrites below to save the other two keeps the invariant
// explicit at the one place it can be silently broken by a future
// reordering, not implicit in call order.
if l.key == "" || l.key == "updated_at" || l.key == "supersedes" || l.key == "supersede_reason" {
if l.key == "" || l.key == "updated_at" || l.key == "supersedes" || l.key == "supersede_reason" ||
l.key == "origin" || l.key == "written_by" {
continue
}
fm.set(l.key, l.value)
}
fm.set("updated_at", time.Now().UTC().Format(time.RFC3339))
fm.set("origin", provenanceOrigin(opts.Origin))
if opts.WrittenBy != "" {
fm.set("written_by", opts.WrittenBy)
} else {
fm.del("written_by")
}
fm.set("supersedes", priorHash)
if opts.Reason != "" {
fm.set("supersede_reason", opts.Reason)