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) <noreply@anthropic.com>
151 lines
6.1 KiB
Go
151 lines
6.1 KiB
Go
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")
|
|
}
|