feat(brain): stamp server-owned write provenance; surface it in brain_query
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:
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user