diff --git a/ingestion/internal/api/frontmatter.go b/ingestion/internal/api/frontmatter.go index f0614b4..93a5432 100644 --- a/ingestion/internal/api/frontmatter.go +++ b/ingestion/internal/api/frontmatter.go @@ -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. diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go index bdff89c..860b525 100644 --- a/ingestion/internal/api/handler.go +++ b/ingestion/internal/api/handler.go @@ -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()) diff --git a/ingestion/internal/api/update.go b/ingestion/internal/api/update.go index 085796d..8f17ae5 100644 --- a/ingestion/internal/api/update.go +++ b/ingestion/internal/api/update.go @@ -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) diff --git a/ingestion/internal/brainstore/store.go b/ingestion/internal/brainstore/store.go index 8f30ef7..c7633d8 100644 --- a/ingestion/internal/brainstore/store.go +++ b/ingestion/internal/brainstore/store.go @@ -49,10 +49,12 @@ func (s *Store) Write(ctx context.Context, n capture.Note) (capture.Ref, error) n.Content = content relPath, err := api.WriteNote(s.brainDir, api.WriteNoteOptions{ - Content: n.Content, - Filename: n.Filename, - Wing: n.Wing, - Hall: n.Hall, + Content: n.Content, + Filename: n.Filename, + Wing: n.Wing, + Hall: n.Hall, + Origin: n.Origin, + WrittenBy: n.WrittenBy, }) if err != nil { return capture.Ref{}, err @@ -74,7 +76,7 @@ func (s *Store) Update(ctx context.Context, slug string, n capture.Note) (captur } n.Content = content - opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason} + opts := api.UpdateNoteOptions{Content: n.Content, Reason: n.Reason, Origin: n.Origin, WrittenBy: n.WrittenBy} if strings.Contains(slug, "/") { opts.Path = slug } else { diff --git a/ingestion/internal/capture/entities.go b/ingestion/internal/capture/entities.go index 0b159f8..7541a03 100644 --- a/ingestion/internal/capture/entities.go +++ b/ingestion/internal/capture/entities.go @@ -60,6 +60,7 @@ type CaptureContext struct { Classification string // caller-declared level token ("" = unspecified) Principal string // server-derived (auth); audit identity Origin Zone // server-derived trust zone; the I1 gate input + AuthPath string // server-derived: "static" or "jwt"; stamped as note provenance (#145) } // Insight is one piece of session knowledge bound for the brain. A diff --git a/ingestion/internal/capture/ports.go b/ingestion/internal/capture/ports.go index 7952e3d..d65c51e 100644 --- a/ingestion/internal/capture/ports.go +++ b/ingestion/internal/capture/ports.go @@ -40,6 +40,11 @@ type Note struct { Wing string Hall string Reason string + // Provenance (hyperguild#145) is stamped by the server from the + // authenticated principal, never taken from Content's frontmatter. + // Origin is "static", "jwt" or "unknown"; empty means "unknown". + Origin string + WrittenBy string } // BrainStore is the brain persistence port — the shared implementation of diff --git a/ingestion/internal/capture/service.go b/ingestion/internal/capture/service.go index 59af865..00b0bc8 100644 --- a/ingestion/internal/capture/service.go +++ b/ingestion/internal/capture/service.go @@ -142,7 +142,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt, var landed []string for i, ins := range in.Insights { - res, item, err := s.persistInsight(ctx, ins) + res, item, err := s.persistInsight(ctx, ins, in.Context) receipt.Insights = append(receipt.Insights, res) if err != nil { receipt.Errors = append(receipt.Errors, ItemError{Item: fmt.Sprintf("insight[%d]", i), Error: err.Error()}) @@ -278,8 +278,11 @@ func escalationReason(escalators []string) string { return "required by: " + strings.Join(escalators, ", ") } -func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) { - note := Note{Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text))} +func (s *Service) persistInsight(ctx context.Context, ins Insight, cc CaptureContext) (InsightResult, string, error) { + note := Note{ + Content: ins.Text, Wing: ins.Wing, Hall: ins.Hall, Filename: brain.Sanitise(firstLine(ins.Text)), + Origin: cc.AuthPath, WrittenBy: cc.Principal, + } var ref Ref var err error if ins.SupersedeSlug != "" { diff --git a/ingestion/internal/capturehttp/handler.go b/ingestion/internal/capturehttp/handler.go index 15e928d..204bb66 100644 --- a/ingestion/internal/capturehttp/handler.go +++ b/ingestion/internal/capturehttp/handler.go @@ -104,6 +104,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // caller may have tried to put in the body. in.Context.Principal = principal in.Context.Origin = h.resolver.Resolve(principal, viaStatic) + in.Context.AuthPath = "jwt" + if viaStatic { + in.Context.AuthPath = "static" + } rec, err := h.svc.Capture(r.Context(), in) switch { diff --git a/ingestion/internal/mcp/audit.go b/ingestion/internal/mcp/audit.go index 50edb16..4d0540d 100644 --- a/ingestion/internal/mcp/audit.go +++ b/ingestion/internal/mcp/audit.go @@ -12,13 +12,7 @@ import ( // ended. Brain content is never copied into the record — only its size — so // the audit trail does not become a second, unscrubbed copy of the brain. func logToolCall(ctx context.Context, name string, args json.RawMessage, start time.Time, err error) { - principal, origin := "", "unknown" - if info, ok := ctx.Value(principalKey).(principalInfo); ok { - principal, origin = info.principal, "jwt" - if info.viaStatic { - origin = "static" - } - } + origin, principal := provenanceFrom(ctx) attrs := []any{ "tool", name, "principal", principal, diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 2163c94..c4c634c 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -255,11 +255,14 @@ func (s *Server) brainWrite(ctx context.Context, args json.RawMessage) (json.Raw // one implementation (capture uses the same store). The read-after-write // handle {id, path, content_hash} comes back from the store; path is kept // for backward compatibility. + origin, writtenBy := provenanceFrom(ctx) ref, err := s.store.Write(ctx, capture.Note{ - Content: a.Content, - Filename: a.Filename, - Wing: a.Wing, - Hall: a.Hall, + Content: a.Content, + Filename: a.Filename, + Wing: a.Wing, + Hall: a.Hall, + Origin: origin, + WrittenBy: writtenBy, }) if err != nil { return nil, err @@ -304,11 +307,14 @@ func (s *Server) brainUpdate(ctx context.Context, args json.RawMessage) (json.Ra if a.Path != "" { slug = a.Path } + origin, writtenBy := provenanceFrom(ctx) ref, err := s.store.Update(ctx, slug, capture.Note{ - Content: a.Content, - Wing: a.Wing, - Hall: a.Hall, - Reason: a.Reason, + Content: a.Content, + Wing: a.Wing, + Hall: a.Hall, + Reason: a.Reason, + Origin: origin, + WrittenBy: writtenBy, }) if err != nil { return nil, err diff --git a/ingestion/internal/mcp/provenance_test.go b/ingestion/internal/mcp/provenance_test.go new file mode 100644 index 0000000..ad0c135 --- /dev/null +++ b/ingestion/internal/mcp/provenance_test.go @@ -0,0 +1,124 @@ +package mcp_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// resultText decodes the tool result payload of a tools/call response. +func resultText(t *testing.T, resp map[string]any) map[string]any { + t.Helper() + require.NotContains(t, resp, "error", "tool call failed: %v", resp["error"]) + content := resp["result"].(map[string]any)["content"].([]any) + var out map[string]any + require.NoError(t, json.Unmarshal([]byte(content[0].(map[string]any)["text"].(string)), &out)) + return out +} + +func readNote(t *testing.T, dir, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel))) + require.NoError(t, err) + return string(b) +} + +func frontmatterOf(note string) string { + parts := strings.SplitN(note, "---\n", 3) + if len(parts) < 3 { + return "" + } + return parts[1] +} + +// hyperguild#145: a brain entry records which principal wrote it, so a reader +// can tell a local-CLI note from one a connector session wrote after reading +// an untrusted page. +func TestBrainWriteStampsPrincipalProvenance(t *testing.T) { + srv, dir, _, _ := govServer(t) + out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{ + "content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-static", + })) + fm := frontmatterOf(readNote(t, dir, out["path"].(string))) + assert.Contains(t, fm, "origin: static\n") + assert.Contains(t, fm, "written_by: local-cli\n") +} + +func TestBrainWriteWithoutPrincipalStampsOriginUnknown(t *testing.T) { + srv, dir, _, _ := govServer(t) + out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{ + "content": "# P\n\nbody", "wing": "testing", "hall": "facts", "filename": "prov-unknown", + })) + fm := frontmatterOf(readNote(t, dir, out["path"].(string))) + assert.Contains(t, fm, "origin: unknown\n") + assert.NotContains(t, fm, "written_by:") +} + +// The control: a caller cannot claim a more trusted origin by putting it in +// its own frontmatter. +func TestBrainWriteOverwritesCallerSuppliedProvenance(t *testing.T) { + srv, dir, _, _ := govServer(t) + forged := "---\norigin: static\nwritten_by: local-cli\n---\n# F\n\nbody" + out := resultText(t, authCall(t, srv, "", "brain_write", map[string]any{ + "content": forged, "wing": "testing", "hall": "facts", "filename": "prov-forged", + })) + fm := frontmatterOf(readNote(t, dir, out["path"].(string))) + assert.Contains(t, fm, "origin: unknown\n") + assert.NotContains(t, fm, "origin: static") + assert.NotContains(t, fm, "written_by: local-cli") + assert.Equal(t, 1, strings.Count(fm, "origin:"), "exactly one origin line") +} + +// brain_update replaces the content wholesale, so provenance follows the +// last writer — and a forged fragment must not survive the merge either. +func TestBrainUpdateRestampsProvenanceAndIgnoresForgedFragment(t *testing.T) { + srv, dir, _, _ := govServer(t) + out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{ + "content": "# U\n\nv1", "wing": "testing", "hall": "facts", "filename": "prov-update", + })) + path := out["path"].(string) + resultText(t, authCall(t, srv, "", "brain_update", map[string]any{ + "path": path, "content": "---\norigin: static\nwritten_by: local-cli\n---\n# U\n\nv2", + })) + fm := frontmatterOf(readNote(t, dir, path)) + assert.Contains(t, fm, "origin: unknown\n") + assert.NotContains(t, fm, "written_by: local-cli") + assert.Equal(t, 1, strings.Count(fm, "origin:")) +} + +func TestBrainQueryReturnsProvenance(t *testing.T) { + srv, _, _, _ := govServer(t) + resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{ + "content": "# Zebrafinch\n\nzebrafinch provenance probe", "wing": "testing", "hall": "facts", "filename": "prov-query", + })) + out := resultText(t, authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{"query": "zebrafinch"})) + results := out["results"].([]any) + require.NotEmpty(t, results) + hit := results[0].(map[string]any) + assert.Equal(t, "static", hit["origin"]) + assert.Equal(t, "local-cli", hit["written_by"]) +} + +// capture is the door a claude.ai connector session uses, so it is the one +// whose provenance matters most: a JWT principal must be stamped as such. +func TestCaptureStampsJWTProvenance(t *testing.T) { + srv, brainDir := captureServer(t, capFakeValidator{subject: "koala-cli"}, []string{"koala-cli"}) + resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{ + "context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"}, + "insights": []map[string]any{{"text": "Capture provenance probe", "wing": "hyperguild", "hall": "facts"}}, + }) + require.Nil(t, resp["error"], "got error: %v", resp["error"]) + matches, err := filepath.Glob(filepath.Join(brainDir, "wiki/hyperguild/facts/*.md")) + require.NoError(t, err) + require.Len(t, matches, 1) + b, err := os.ReadFile(matches[0]) + require.NoError(t, err) + fm := frontmatterOf(string(b)) + assert.Contains(t, fm, "origin: jwt\n") + assert.Contains(t, fm, "written_by: koala-cli\n") +} diff --git a/ingestion/internal/mcp/tools_capture.go b/ingestion/internal/mcp/tools_capture.go index 6f952fe..cb7589a 100644 --- a/ingestion/internal/mcp/tools_capture.go +++ b/ingestion/internal/mcp/tools_capture.go @@ -90,6 +90,7 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R // Principal and origin are server-derived — never taken from the body. in.Context.Principal = info.principal in.Context.Origin = s.capture.resolver.Resolve(info.principal, info.viaStatic) + in.Context.AuthPath, _ = provenanceFrom(ctx) rec, err := s.capture.svc.Capture(ctx, in) if err != nil { @@ -99,3 +100,17 @@ func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.R } return json.Marshal(rec) } + +// provenanceFrom maps the authenticated principal in ctx to the provenance +// stamped on a brain write (hyperguild#145). No principal → "unknown", never +// silence: an unstamped note must not read as trusted. +func provenanceFrom(ctx context.Context) (origin, writtenBy string) { + info, ok := ctx.Value(principalKey).(principalInfo) + if !ok { + return "unknown", "" + } + if info.viaStatic { + return "static", info.principal + } + return "jwt", info.principal +} diff --git a/ingestion/internal/search/search.go b/ingestion/internal/search/search.go index b0c75fe..0fdab19 100644 --- a/ingestion/internal/search/search.go +++ b/ingestion/internal/search/search.go @@ -48,6 +48,11 @@ type Result struct { // (infra#72). Read from frontmatter when present, otherwise // inferred from the parent directory. Tier string `json:"tier,omitempty"` + // Origin/WrittenBy surface write provenance (hyperguild#145) so a + // reader can discount an entry a connector session wrote. Omitted for + // notes written before provenance existed — never fabricated. + Origin string `json:"origin,omitempty"` + WrittenBy string `json:"written_by,omitempty"` } // tierWeight maps the DIKW tier to a score multiplier applied right @@ -146,6 +151,7 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re rel = filepath.ToSlash(rel) wing, hall := extractWingHall(string(content), rel) tier := extractTier(string(content), rel) + origin, writtenBy := extractProvenance(string(content)) results = append(results, Result{ Path: rel, Title: extractTitle(string(content), d.Name()), @@ -154,6 +160,9 @@ func QueryContext(ctx context.Context, brainDir string, opts QueryOptions) ([]Re Wing: wing, Hall: hall, Tier: tier, + + Origin: origin, + WrittenBy: writtenBy, }) return nil }) @@ -272,6 +281,7 @@ func hydrate(brainDir, relPath string) (Result, error) { } wing, hall := extractWingHall(string(content), relPath) tier := extractTier(string(content), relPath) + origin, writtenBy := extractProvenance(string(content)) return Result{ Path: relPath, Title: extractTitle(string(content), filepath.Base(relPath)), @@ -279,9 +289,45 @@ func hydrate(brainDir, relPath string) (Result, error) { Wing: wing, Hall: hall, Tier: tier, + + Origin: origin, + WrittenBy: writtenBy, }, nil } +// extractProvenance reads origin/written_by from the leading frontmatter +// block only — a body line that happens to start with "origin:" is not +// provenance. +func extractProvenance(content string) (origin, writtenBy string) { + scanner := bufio.NewScanner(strings.NewReader(content)) + inFrontmatter := false + for scanner.Scan() { + line := scanner.Text() + if strings.TrimSpace(line) == "---" { + if !inFrontmatter { + inFrontmatter = true + continue + } + break + } + if !inFrontmatter { + return "", "" + } + key, val, ok := strings.Cut(line, ":") + if !ok || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { + continue + } + v := strings.Trim(strings.TrimSpace(val), `"'`) + switch strings.TrimSpace(key) { + case "origin": + origin = v + case "written_by": + writtenBy = v + } + } + return origin, writtenBy +} + // resolveRoots returns the directories to walk for the given wing/hall // filters. Validates hall against the closed vocabulary when set. func resolveRoots(brainDir, wing, hall string) ([]string, error) {