From f78a5474a50473c25bdc5e12728219d784d38beb Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 23 Jun 2026 00:21:11 +0200 Subject: [PATCH 1/2] refactor(capturehttp): export Authenticate + DecodeRequest for reuse (#55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifts the Bearer principal-derivation and the request→CaptureInput decode out of the REST handler into exported package funcs, so the MCP capture tool (#55 relay) reuses the exact same auth precedence and wire shape — one implementation, not two. No behaviour change to POST /capture. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/internal/capturehttp/handler.go | 45 ++++++++++++++++------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/ingestion/internal/capturehttp/handler.go b/ingestion/internal/capturehttp/handler.go index 2fd1c73..fd765f4 100644 --- a/ingestion/internal/capturehttp/handler.go +++ b/ingestion/internal/capturehttp/handler.go @@ -11,6 +11,7 @@ import ( "crypto/subtle" "encoding/json" "errors" + "io" "net/http" "strings" @@ -90,19 +91,22 @@ type summaryBody struct { // ServeHTTP authenticates, derives origin, runs the use-case, and maps the // result to an HTTP status. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - principal, viaStatic, ok := h.authenticate(r) + principal, viaStatic, ok := Authenticate(r, h.staticToken, h.staticPrincipal, h.validator) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } - var req request - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + body, err := io.ReadAll(r.Body) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "read body"}) + return + } + in, err := DecodeRequest(body) + if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"}) return } - - in := req.toInput() // Principal and origin are server-derived — overwrite anything the // caller may have tried to put in the body. in.Context.Principal = principal @@ -125,26 +129,39 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { writeJSON(w, statusFor(rec), rec) } -// authenticate mirrors the chassis Bearer precedence (static wins, then -// JWT) but returns the resolved principal and whether the static path was -// taken — the chassis middleware hides both, and capture needs them to -// derive the origin. -func (h *Handler) authenticate(r *http.Request) (principal string, viaStatic, ok bool) { +// Authenticate mirrors the chassis Bearer precedence (static token wins, +// then Dex JWT) and returns the resolved principal plus whether the static +// path was taken — the chassis middleware hides both, and capture (REST or +// MCP) needs them to derive the trust-zone origin. ok is false when no +// credential matched. +func Authenticate(r *http.Request, staticToken, staticPrincipal string, validator Validator) (principal string, viaStatic, ok bool) { raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ") if !found || raw == "" { return "", false, false } - if h.staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(h.staticToken)) == 1 { - return h.staticPrincipal, true, true + if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 { + return staticPrincipal, true, true } - if h.validator != nil { - if sub, err := h.validator.Validate(r.Context(), raw); err == nil && sub != "" { + if validator != nil { + if sub, err := validator.Validate(r.Context(), raw); err == nil && sub != "" { return sub, false, true } } return "", false, false } +// DecodeRequest parses a capture request body into a CaptureInput. Shared +// by the REST adapter and the MCP capture tool so the wire shape has one +// definition. Principal and Origin are NOT set here — the caller sets them +// from the authenticated identity. +func DecodeRequest(data []byte) (capture.CaptureInput, error) { + var b request + if err := json.Unmarshal(data, &b); err != nil { + return capture.CaptureInput{}, err + } + return b.toInput(), nil +} + func (b request) toInput() capture.CaptureInput { in := capture.CaptureInput{ Context: capture.CaptureContext{ From 7cf5bc221d6e67060c8664204505d2944c4816f4 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 23 Jun 2026 00:21:11 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(mcp):=20capture=20relay=20tool=20?= =?UTF-8?q?=E2=80=94=20MCP=20door=20for=20non-library=20harnesses=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `capture` MCP tool: the #55 relay for harnesses that cannot run the use-case in-process (claude.ai Chat/Cowork/Design, Crush, Pi, LLM Council). They reach it through the existing /mcp OAuth connector. - Thin: forwards to the SAME CaptureService as POST /capture; holds no state and retains nothing beyond the I5 audit record. The containment properties accepted in infra security-baseline (I2 ledger) hold by construction. - Per-principal: ServeHTTP re-derives the caller's principal from the Bearer header (the chassis middleware gates but discards it) and stashes it in context; the tool resolves the trust-zone origin from it. A caller-asserted harness/origin in the body is ignored — origin is server-derived, so the I1 confidential refusal still fires for us-nexus callers (claude.ai), and sovereign-allowlisted JWT principals pass. - Registered only when WithCapture is wired (all three sites: tools(), handleCall, package doc); main wires REST + MCP from the same service, resolver, and credentials. Tests: listed-only-when-wired, forwards-via-static-principal, confidential-via-us-nexus-refused, confidential-via-sovereign-allowed, unauthenticated-rejected, caller-cannot-forge-origin. Co-Authored-By: Claude Opus 4.8 (1M context) --- ingestion/cmd/server/main.go | 11 +- ingestion/internal/mcp/handlers.go | 9 +- ingestion/internal/mcp/server.go | 55 ++++++- ingestion/internal/mcp/tools_capture.go | 105 +++++++++++++ ingestion/internal/mcp/tools_capture_test.go | 150 +++++++++++++++++++ 5 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 ingestion/internal/mcp/tools_capture.go create mode 100644 ingestion/internal/mcp/tools_capture_test.go diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 9e1de43..2294b32 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -413,10 +413,15 @@ func main() { captureSvc := capture.NewService( mcpSrv.BrainStore(), tracker, nil, classCfg, auditSink) sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS")) - captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", - capturehttp.NewOriginResolver(sovereign)) + resolver := capturehttp.NewOriginResolver(sovereign) + captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) mux.Handle("POST /capture", captureH) - logger.Info("capture endpoint enabled", "sovereign_principals", len(sovereign)) + // Same use-case behind the MCP `capture` tool (#55 relay) so MCP-native + // harnesses (claude.ai, Crush, Pi, LLM Council) reach capture through + // the existing /mcp OAuth connector. mcpSrv is already wrapped above; + // WithCapture mutates the same instance, so the tool appears live. + mcpSrv.WithCapture(captureSvc, jwtValidator, mcpToken, "local-cli", resolver) + logger.Info("capture enabled (REST + MCP tool)", "sovereign_principals", len(sovereign)) } else { logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)") } diff --git a/ingestion/internal/mcp/handlers.go b/ingestion/internal/mcp/handlers.go index 12a9193..8a750a1 100644 --- a/ingestion/internal/mcp/handlers.go +++ b/ingestion/internal/mcp/handlers.go @@ -38,7 +38,7 @@ func (s *Server) tools() []map[string]any { return b } - return []map[string]any{ + tools := []map[string]any{ { "name": "brain_query", "description": "BM25 full-text search across brain/knowledge/ and brain/wiki/ markdown files. Optionally scope by wing (topic domain) and hall (memory type).", @@ -171,6 +171,13 @@ func (s *Server) tools() []map[string]any { }), }, } + // The capture relay tool (#55) is advertised only when wired via + // WithCapture — MCP-native harnesses (claude.ai, Crush, Pi, LLM Council) + // reach capture through it. + if s.capture != nil { + tools = append(tools, captureToolDescriptor()) + } + return tools } type brainQueryArgs struct { diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 84d94d5..aa33734 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -1,7 +1,8 @@ // Package mcp implements an MCP HTTP handler for the ingestion service. // Exposed tools: brain_query, brain_write, brain_update, brain_get, // brain_index, brain_tunnel, brain_ingest, brain_ingest_raw, -// brain_answer, brain_classify, brain_graph, brain_context, session_log. +// brain_answer, brain_classify, brain_graph, brain_context, session_log, +// and capture (the #55 relay tool, registered only when WithCapture is set). package mcp import ( @@ -12,6 +13,7 @@ import ( "github.com/mathiasbq/hyperguild/ingestion/internal/brainstore" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" + "github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp" "github.com/mathiasbq/hyperguild/ingestion/internal/graphstore" "github.com/mathiasbq/hyperguild/ingestion/internal/graphsync" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" @@ -50,6 +52,19 @@ type Server struct { graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled store *brainstore.Store // shared brain write/update/get impl (also used by capture) tracker capture.IssueTracker // nil = no Gitea ticket integration; wired for capture (#53) + capture *captureDeps // nil = capture MCP tool disabled (#55 relay) +} + +// captureDeps holds what the MCP `capture` tool (the #55 relay door for +// MCP-native harnesses like claude.ai) needs: the use-case, the auth bits +// to re-derive the caller's principal from the Bearer header (the chassis +// middleware gates but discards the principal), and the origin resolver. +type captureDeps struct { + svc *capture.Service + validator capturehttp.Validator + staticToken string + staticPrincipal string + resolver capturehttp.OriginResolver } // NewServer constructs a Server bound to brainDir. pipelineCfg supplies the @@ -123,6 +138,29 @@ func (s *Server) BrainStore() *brainstore.Store { return s.store } +// WithCapture enables the MCP `capture` tool (#55) — the relay door for +// MCP-native harnesses (claude.ai, Crush, Pi, LLM Council) that cannot run +// the use-case in-process. It forwards to the same CaptureService as +// POST /capture, deriving the caller's principal + origin from the same +// auth credentials that gate /mcp. nil svc leaves the tool unregistered. +func (s *Server) WithCapture(svc *capture.Service, validator capturehttp.Validator, staticToken, staticPrincipal string, resolver capturehttp.OriginResolver) *Server { + if svc == nil { + s.capture = nil + return s + } + if staticPrincipal == "" { + staticPrincipal = "local-cli" + } + s.capture = &captureDeps{ + svc: svc, + validator: validator, + staticToken: staticToken, + staticPrincipal: staticPrincipal, + resolver: resolver, + } + return s +} + func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { // MCP streamable HTTP: GET establishes the SSE stream for server-to-client events. if r.Method == http.MethodGet { @@ -172,7 +210,18 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { rpcErr = &rpcError{Code: -32602, Message: "invalid params"} break } - out, err := s.handleCall(r.Context(), p.Name, p.Arguments) + // Re-derive the authenticated principal from the Bearer header so + // the capture tool can compute the trust-zone origin. The request + // is already gated by BearerMiddleware; this only recovers the + // identity that middleware discards. + ctx := r.Context() + if s.capture != nil { + if principal, viaStatic, ok := capturehttp.Authenticate( + r, s.capture.staticToken, s.capture.staticPrincipal, s.capture.validator); ok { + ctx = withPrincipal(ctx, principal, viaStatic) + } + } + out, err := s.handleCall(ctx, p.Name, p.Arguments) if err != nil { rpcErr = &rpcError{Code: -32000, Message: err.Error()} break @@ -214,6 +263,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa return s.brainUpdate(ctx, args) case "brain_get": return s.brainGet(ctx, args) + case "capture": + return s.brainCapture(ctx, args) case "brain_index": return s.brainIndex(ctx, args) case "brain_tunnel": diff --git a/ingestion/internal/mcp/tools_capture.go b/ingestion/internal/mcp/tools_capture.go new file mode 100644 index 0000000..771babb --- /dev/null +++ b/ingestion/internal/mcp/tools_capture.go @@ -0,0 +1,105 @@ +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}, + "summary": map[string]any{"type": "object", "properties": map[string]any{ + "title": str("summary title"), "body": str("summary body"), + "repos_touched": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + }}, + "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, optional summary → ai-sessions. 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) +} diff --git a/ingestion/internal/mcp/tools_capture_test.go b/ingestion/internal/mcp/tools_capture_test.go new file mode 100644 index 0000000..5154d3d --- /dev/null +++ b/ingestion/internal/mcp/tools_capture_test.go @@ -0,0 +1,150 @@ +package mcp_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/audit" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainstore" + "github.com/mathiasbq/hyperguild/ingestion/internal/capture" + "github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp" + "github.com/mathiasbq/hyperguild/ingestion/internal/classification" + "github.com/mathiasbq/hyperguild/ingestion/internal/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const capStaticTok = "cap-static-tok" + +type capFakeTracker struct{} + +func (capFakeTracker) CreateIssue(context.Context, string, string, string) (capture.IssueRef, error) { + return capture.IssueRef{Repo: "hyperguild", Number: 1, URL: "https://git/1"}, nil +} +func (capFakeTracker) CloseIssue(context.Context, string, int, string) (capture.IssueRef, error) { + return capture.IssueRef{}, nil +} +func (capFakeTracker) CommentIssue(context.Context, string, int, string) (capture.IssueRef, error) { + return capture.IssueRef{}, nil +} + +type capFakeValidator struct { + subject string + err error +} + +func (v capFakeValidator) Validate(context.Context, string) (string, error) { + return v.subject, v.err +} + +func captureServer(t *testing.T, validator capturehttp.Validator, sovereign []string) (*mcp.Server, string) { + t.Helper() + brainDir := t.TempDir() + cfg, err := classification.Load(brainDir) + require.NoError(t, err) + svc := capture.NewService(brainstore.New(brainDir), capFakeTracker{}, nil, cfg, audit.NewSlogSink(nil)) + srv := mcp.NewServer(brainDir, nil, nil, nil) + srv.WithCapture(svc, validator, capStaticTok, "local-cli", capturehttp.NewOriginResolver(sovereign)) + return srv, brainDir +} + +func captureCall(t *testing.T, srv http.Handler, authz string, args map[string]any) map[string]any { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": map[string]any{"name": "capture", "arguments": args}, + }) + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + if authz != "" { + req.Header.Set("Authorization", authz) + } + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + var resp map[string]any + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + return resp +} + +func TestCaptureToolListedWhenWired(t *testing.T) { + srv, _ := captureServer(t, nil, nil) + body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + assert.Contains(t, rr.Body.String(), `"capture"`) +} + +func TestCaptureToolNotListedByDefault(t *testing.T) { + srv := mcp.NewServer(t.TempDir(), nil, nil, nil) // no WithCapture + body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}) + req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(body)) + rr := httptest.NewRecorder() + srv.ServeHTTP(rr, req) + assert.NotContains(t, rr.Body.String(), `"capture"`) +} + +func TestCaptureToolForwardsViaStaticPrincipal(t *testing.T) { + srv, brainDir := captureServer(t, nil, nil) + resp := captureCall(t, srv, "Bearer "+capStaticTok, map[string]any{ + "context": map[string]any{"harness": "claude-code", "actor": "mathias", "classification": "internal"}, + "insights": []map[string]any{{"text": "a fact", "wing": "hyperguild", "hall": "facts"}}, + "tickets": []map[string]any{{"repo": "hyperguild", "action": "create", "title": "t"}}, + }) + require.Nil(t, resp["error"], "got error: %v", resp["error"]) + text := resp["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string) + var rec capture.CaptureReceipt + require.NoError(t, json.Unmarshal([]byte(text), &rec)) + assert.True(t, rec.Insights[0].OK) + assert.True(t, rec.Tickets[0].OK) + // Forwarded to the real brain store. + _, statErr := os.Stat(filepath.Join(brainDir, "wiki/hyperguild/facts")) + require.NoError(t, statErr) +} + +func TestCaptureToolRefusesConfidentialViaUSNexus(t *testing.T) { + // JWT principal not in the sovereign allowlist ⇒ us-nexus origin. + srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil) + resp := captureCall(t, srv, "Bearer jwt-token", map[string]any{ + "context": map[string]any{"harness": "claudeai-chat", "actor": "mathias", "classification": "confidential"}, + "insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}}, + }) + require.NotNil(t, resp["error"]) + assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty") +} + +func TestCaptureToolAllowsConfidentialViaSovereignJWT(t *testing.T) { + srv, _ := 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": "confidential"}, + "insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}}, + }) + assert.Nil(t, resp["error"], "sovereign JWT principal should be allowed: %v", resp["error"]) +} + +func TestCaptureToolRejectsUnauthenticated(t *testing.T) { + srv, _ := captureServer(t, capFakeValidator{err: errors.New("no jwt")}, nil) + resp := captureCall(t, srv, "", map[string]any{ // no Authorization + "context": map[string]any{"harness": "x", "classification": "internal"}, + "insights": []map[string]any{{"text": "a", "wing": "hyperguild", "hall": "facts"}}, + }) + require.NotNil(t, resp["error"]) + assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "authenticated principal") +} + +func TestCaptureToolCallerCannotForgeOrigin(t *testing.T) { + // Body asserts sovereign harness, but the us-nexus JWT principal governs. + srv, _ := captureServer(t, capFakeValidator{subject: "claudeai-oauth"}, nil) + resp := captureCall(t, srv, "Bearer jwt", map[string]any{ + "context": map[string]any{"harness": "sovereign-soil", "classification": "confidential"}, + "insights": []map[string]any{{"text": "secret", "wing": "client-seb", "hall": "facts"}}, + }) + require.NotNil(t, resp["error"]) + assert.Contains(t, resp["error"].(map[string]any)["message"].(string), "sovereignty") +}