brain_read returns a note verbatim; brain_edit is str_replace with an exactly-once match. Both operate on INGEST_BRAIN_DIR, which is the pod's mount of /var/lib/supervisor/brain — the tree brain-sync commits from, so an edit reaches gitea on the next cycle rather than landing somewhere the sync cannot see it (brain#2). An adversarial design review before implementation changed three things, each of which would otherwise have shipped: 1. Governance files are NOT in the model-facing tool. The original plan allowed root *.md so CLAUDE.md had an MCP path at all — the absence of one is what pushed a session to edit via the gitea REST API on 2026-06-16 and wedge the sync. But brain content is ingested from the outside world, reaches a model's context via brain_query, and that model would hold the editor: one poisoned page could rewrite the CLAUDE.md a .dispatch-allow agent later loads as instructions. Worse, brain-sync merges with --strategy-option=ours, so the live tree wins against a human's corrective push on exactly the conflicting lines. CLAUDE.md now goes through brain_edit_root: de-surfaced from tools/list (the brain_index precedent, ADR-0012 rule 4) and gated on the local static principal, so a claude.ai-origin session is refused. The two scopes are disjoint — the privileged door has the smaller reach. 2. Reads needed the same allowlist, and brain_get already lacked it. api.ReadNote resolves any brain-relative path and parseFrontmatter returns the whole file when there is no fence, so brain_get reads verbatim session transcripts under archive/ (up to 500 KB, never BM25-indexed, so nobody thinks of them as reachable). A test proves the exposure was live; the same Resolve now gates brain_get. 3. Canonicalise before allowlisting. A prefix check on the raw argument accepts knowledge/../.git/hooks/post-commit, which Clean collapses to a git hook that brain-sync executes inside a pod holding the Gitea push credentials. Traversal, symlink escape, dotfiles, .git/, archive/, sessions/ and raw/ are refused by a static backstop that does not depend on the allowlist being right. There were no traversal tests in this repo before. Also from the review: overlapping match counting (bytes.Count says "aa" occurs once in "aaa", so a Count-based uniqueness check accepts an ambiguous edit); UTF-8 validation on both sides, because encoding/json substitutes U+FFFD and would hand back bytes the file does not contain, making every derived old_str unmatchable; refusal while .git/MERGE_HEAD or index.lock exists, since an edit racing brain-sync's merge is either hard-reset away or committed as a wrongly resolved conflict; flock plus a per-path mutex with the read taken inside the lock, because two pods share the hostPath during a RollingUpdate and uniqueness evaluated against a stale read loses an update while reporting success; same-dir *.tmp + rename + chmod 0644 + fsync of file and parent; and reindex only when the edit touches frontmatter, since BM25 walks live and embeddings are mtime-driven. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
102 lines
4.1 KiB
Go
102 lines
4.1 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
|
)
|
|
|
|
// principalKey is the context key under which the authenticated principal
|
|
// (re-derived in ServeHTTP) is stashed for the capture tool.
|
|
type principalKeyT struct{}
|
|
|
|
var principalKey principalKeyT
|
|
|
|
type principalInfo struct {
|
|
principal string
|
|
viaStatic bool
|
|
}
|
|
|
|
func withPrincipal(ctx context.Context, principal string, viaStatic bool) context.Context {
|
|
return context.WithValue(ctx, principalKey, principalInfo{principal: principal, viaStatic: viaStatic})
|
|
}
|
|
|
|
// captureToolDescriptor is the tools/list entry for the capture relay.
|
|
// Appended only when WithCapture has wired the tool.
|
|
func captureToolDescriptor() map[string]any {
|
|
str := func(d string) map[string]any { return map[string]any{"type": "string", "description": d} }
|
|
insightItem := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"text": str("the insight body"), "wing": str("brain wing"),
|
|
"hall": str("brain hall (facts/decisions/failures/hypotheses/sources)"),
|
|
"supersede_slug": str("optional: slug of a prior note to revise in place instead of creating"),
|
|
},
|
|
"required": []string{"text", "wing", "hall"},
|
|
}
|
|
ticketItem := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"repo": str("gitea repo (owner is always mathias)"), "action": str("create|close|comment"),
|
|
"number": map[string]any{"type": "integer", "description": "issue number (close/comment)"},
|
|
"title": str("issue title (create)"), "body": str("issue/comment body"),
|
|
},
|
|
"required": []string{"repo", "action"},
|
|
}
|
|
schema := map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"context": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"harness": str("descriptive harness label (telemetry only, never a gate input)"),
|
|
"session_ref": str("optional session reference"), "fidelity": str("live-capture|transcript-parse|agent-runlog"),
|
|
"actor": str("acting user/agent"), "classification": str("caller-declared sensitivity: public|internal|confidential"),
|
|
},
|
|
},
|
|
"insights": map[string]any{"type": "array", "items": insightItem},
|
|
"tickets": map[string]any{"type": "array", "items": ticketItem},
|
|
"dry_run": map[string]any{"type": "boolean", "description": "validate + return the would-be receipt, write nothing"},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(schema)
|
|
return map[string]any{
|
|
"name": "capture",
|
|
"description": "Persist a session's value uniformly: insights → brain (write or supersede), action items → Gitea tickets. The relay door for MCP-native harnesses. Origin is server-derived from your authenticated identity; confidential captures through a us-nexus surface are refused (I1). Returns a partial-aware receipt.",
|
|
"inputSchema": json.RawMessage(b),
|
|
}
|
|
}
|
|
|
|
// brainCapture is the MCP capture tool: the #55 relay for MCP-native
|
|
// harnesses. It re-uses the same CaptureService, principal-derivation, and
|
|
// origin resolver as POST /capture — only the transport differs. It holds
|
|
// no state and retains nothing beyond the I5 audit record.
|
|
func (s *Server) brainCapture(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
|
|
if s.capture == nil {
|
|
return nil, fmt.Errorf("capture tool not configured")
|
|
}
|
|
info, ok := ctx.Value(principalKey).(principalInfo)
|
|
if !ok || info.principal == "" {
|
|
// No authenticated principal ⇒ cannot derive origin ⇒ cannot gate.
|
|
return nil, fmt.Errorf("capture requires an authenticated principal")
|
|
}
|
|
|
|
in, err := capturehttp.DecodeRequest(args)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid capture request: %w", err)
|
|
}
|
|
// 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)
|
|
|
|
rec, err := s.capture.svc.Capture(ctx, in)
|
|
if err != nil {
|
|
// Surface I1/I5 refusals and validation failures verbatim; errors.Is
|
|
// markers (ErrSovereigntyRefused / ErrAuditUnavailable) ride in the message.
|
|
return nil, err
|
|
}
|
|
return json.Marshal(rec)
|
|
}
|