feat: capture relay — MCP capture tool for non-library harnesses (#55, capture 49f) #61
@@ -413,10 +413,15 @@ func main() {
|
|||||||
captureSvc := capture.NewService(
|
captureSvc := capture.NewService(
|
||||||
mcpSrv.BrainStore(), tracker, nil, classCfg, auditSink)
|
mcpSrv.BrainStore(), tracker, nil, classCfg, auditSink)
|
||||||
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
sovereign := splitList(os.Getenv("BRAIN_CAPTURE_SOVEREIGN_PRINCIPALS"))
|
||||||
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli",
|
resolver := capturehttp.NewOriginResolver(sovereign)
|
||||||
capturehttp.NewOriginResolver(sovereign))
|
captureH := capturehttp.New(captureSvc, jwtValidator, mcpToken, "local-cli", resolver)
|
||||||
mux.Handle("POST /capture", captureH)
|
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 {
|
} else {
|
||||||
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
logger.Info("capture endpoint disabled (BRAIN_GITEA_TOKEN unset)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -90,19 +91,22 @@ type summaryBody struct {
|
|||||||
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
// ServeHTTP authenticates, derives origin, runs the use-case, and maps the
|
||||||
// result to an HTTP status.
|
// result to an HTTP status.
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if !ok {
|
||||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var req request
|
body, err := io.ReadAll(r.Body)
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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"})
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
in := req.toInput()
|
|
||||||
// Principal and origin are server-derived — overwrite anything the
|
// Principal and origin are server-derived — overwrite anything the
|
||||||
// caller may have tried to put in the body.
|
// caller may have tried to put in the body.
|
||||||
in.Context.Principal = principal
|
in.Context.Principal = principal
|
||||||
@@ -125,26 +129,39 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, statusFor(rec), rec)
|
writeJSON(w, statusFor(rec), rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticate mirrors the chassis Bearer precedence (static wins, then
|
// Authenticate mirrors the chassis Bearer precedence (static token wins,
|
||||||
// JWT) but returns the resolved principal and whether the static path was
|
// then Dex JWT) and returns the resolved principal plus whether the static
|
||||||
// taken — the chassis middleware hides both, and capture needs them to
|
// path was taken — the chassis middleware hides both, and capture (REST or
|
||||||
// derive the origin.
|
// MCP) needs them to derive the trust-zone origin. ok is false when no
|
||||||
func (h *Handler) authenticate(r *http.Request) (principal string, viaStatic, ok bool) {
|
// 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 ")
|
raw, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
if !found || raw == "" {
|
if !found || raw == "" {
|
||||||
return "", false, false
|
return "", false, false
|
||||||
}
|
}
|
||||||
if h.staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(h.staticToken)) == 1 {
|
if staticToken != "" && subtle.ConstantTimeCompare([]byte(raw), []byte(staticToken)) == 1 {
|
||||||
return h.staticPrincipal, true, true
|
return staticPrincipal, true, true
|
||||||
}
|
}
|
||||||
if h.validator != nil {
|
if validator != nil {
|
||||||
if sub, err := h.validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
if sub, err := validator.Validate(r.Context(), raw); err == nil && sub != "" {
|
||||||
return sub, false, true
|
return sub, false, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false, false
|
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 {
|
func (b request) toInput() capture.CaptureInput {
|
||||||
in := capture.CaptureInput{
|
in := capture.CaptureInput{
|
||||||
Context: capture.CaptureContext{
|
Context: capture.CaptureContext{
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
return []map[string]any{
|
tools := []map[string]any{
|
||||||
{
|
{
|
||||||
"name": "brain_query",
|
"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).",
|
"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 {
|
type brainQueryArgs struct {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
// Package mcp implements an MCP HTTP handler for the ingestion service.
|
||||||
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
// Exposed tools: brain_query, brain_write, brain_update, brain_get,
|
||||||
// brain_index, brain_tunnel, brain_ingest, brain_ingest_raw,
|
// 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
|
package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"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/graphstore"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/graphsync"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
@@ -50,6 +52,19 @@ type Server struct {
|
|||||||
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
graph graphsync.Store // nil = brain_graph and GraphRAG augmentation disabled
|
||||||
store *brainstore.Store // shared brain write/update/get impl (also used by capture)
|
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)
|
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
|
// NewServer constructs a Server bound to brainDir. pipelineCfg supplies the
|
||||||
@@ -123,6 +138,29 @@ func (s *Server) BrainStore() *brainstore.Store {
|
|||||||
return s.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) {
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
// MCP streamable HTTP: GET establishes the SSE stream for server-to-client events.
|
||||||
if r.Method == http.MethodGet {
|
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"}
|
rpcErr = &rpcError{Code: -32602, Message: "invalid params"}
|
||||||
break
|
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 {
|
if err != nil {
|
||||||
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||||
break
|
break
|
||||||
@@ -214,6 +263,8 @@ func (s *Server) handleCall(ctx context.Context, name string, args json.RawMessa
|
|||||||
return s.brainUpdate(ctx, args)
|
return s.brainUpdate(ctx, args)
|
||||||
case "brain_get":
|
case "brain_get":
|
||||||
return s.brainGet(ctx, args)
|
return s.brainGet(ctx, args)
|
||||||
|
case "capture":
|
||||||
|
return s.brainCapture(ctx, args)
|
||||||
case "brain_index":
|
case "brain_index":
|
||||||
return s.brainIndex(ctx, args)
|
return s.brainIndex(ctx, args)
|
||||||
case "brain_tunnel":
|
case "brain_tunnel":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user