diff --git a/ingestion/internal/mcp/audit.go b/ingestion/internal/mcp/audit.go new file mode 100644 index 0000000..50edb16 --- /dev/null +++ b/ingestion/internal/mcp/audit.go @@ -0,0 +1,53 @@ +package mcp + +import ( + "context" + "encoding/json" + "log/slog" + "time" +) + +// logToolCall emits one structured record per tools/call (hyperguild#144, +// OWASP MCP08): who called which tool, against which wing/hall, and how it +// 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" + } + } + attrs := []any{ + "tool", name, + "principal", principal, + "origin", origin, + "duration_ms", time.Since(start).Milliseconds(), + } + var a struct { + Wing string `json:"wing"` + Hall string `json:"hall"` + Content string `json:"content"` + } + if json.Unmarshal(args, &a) == nil { + if a.Wing != "" { + attrs = append(attrs, "wing", a.Wing) + } + if a.Hall != "" { + attrs = append(attrs, "hall", a.Hall) + } + if a.Content != "" { + attrs = append(attrs, "content_bytes", len(a.Content)) + } + } + outcome := "ok" + if err != nil { + outcome = err.Error() + if len(outcome) > 200 { + outcome = outcome[:200] + } + } + attrs = append(attrs, "outcome", outcome) + slog.InfoContext(ctx, "mcp tool call", attrs...) +} diff --git a/ingestion/internal/mcp/audit_test.go b/ingestion/internal/mcp/audit_test.go new file mode 100644 index 0000000..4e0f6b4 --- /dev/null +++ b/ingestion/internal/mcp/audit_test.go @@ -0,0 +1,83 @@ +package mcp_test + +import ( + "bytes" + "encoding/json" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// captureToolCallLog routes slog to a buffer for fn and returns the decoded +// "mcp tool call" records. +func captureToolCallLog(t *testing.T, fn func()) []map[string]any { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, nil))) + t.Cleanup(func() { slog.SetDefault(prev) }) + fn() + var out []map[string]any + for _, l := range strings.Split(strings.TrimSpace(buf.String()), "\n") { + var rec map[string]any + if json.Unmarshal([]byte(l), &rec) == nil && rec["msg"] == "mcp tool call" { + out = append(out, rec) + } + } + return out +} + +// hyperguild#144: brain_query/brain_read left no record of who read what. +func TestToolsCallLogsOneRecordWithToolPrincipalOriginAndScope(t *testing.T) { + srv, _, _, _ := govServer(t) + recs := captureToolCallLog(t, func() { + authCall(t, srv, "Bearer "+capStaticTok, "brain_query", + map[string]any{"query": "anything", "wing": "homelab", "hall": "facts"}) + }) + require.Len(t, recs, 1) + r := recs[0] + assert.Equal(t, "brain_query", r["tool"]) + assert.Equal(t, "local-cli", r["principal"]) + assert.Equal(t, "static", r["origin"]) + assert.Equal(t, "homelab", r["wing"]) + assert.Equal(t, "facts", r["hall"]) + assert.Equal(t, "ok", r["outcome"]) + assert.Contains(t, r, "duration_ms") +} + +func TestToolsCallWithoutDerivablePrincipalLogsOriginUnknown(t *testing.T) { + srv, _, _, _ := govServer(t) + recs := captureToolCallLog(t, func() { + authCall(t, srv, "", "brain_query", map[string]any{"query": "x"}) + }) + require.Len(t, recs, 1) + assert.Equal(t, "unknown", recs[0]["origin"]) + assert.Equal(t, "", recs[0]["principal"]) +} + +func TestToolsCallLogsErrorOutcome(t *testing.T) { + srv, _, _, _ := govServer(t) + recs := captureToolCallLog(t, func() { + authCall(t, srv, "Bearer "+capStaticTok, "brain_query", map[string]any{}) + }) + require.Len(t, recs, 1) + assert.Contains(t, recs[0]["outcome"], "query is required") +} + +// The audit line must not become a second copy of brain content. +func TestToolsCallLogOmitsContentButRecordsItsSize(t *testing.T) { + const sentinel = "SENTINEL-BRAIN-CONTENT-91c2" + srv, _, _, _ := govServer(t) + recs := captureToolCallLog(t, func() { + authCall(t, srv, "Bearer "+capStaticTok, "brain_write", map[string]any{ + "content": "# t\n\n" + sentinel, "wing": "testing", "hall": "facts", "filename": "audit-sentinel", + }) + }) + require.Len(t, recs, 1) + raw, _ := json.Marshal(recs[0]) + assert.NotContains(t, string(raw), sentinel) + assert.EqualValues(t, len("# t\n\n"+sentinel), recs[0]["content_bytes"]) +} diff --git a/ingestion/internal/mcp/server.go b/ingestion/internal/mcp/server.go index 3726e0b..84c2307 100644 --- a/ingestion/internal/mcp/server.go +++ b/ingestion/internal/mcp/server.go @@ -16,6 +16,7 @@ import ( "encoding/json" "fmt" "net/http" + "time" "github.com/mathiasbq/hyperguild/ingestion/internal/brainstore" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" @@ -229,7 +230,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx = withPrincipal(ctx, principal, viaStatic) } } + start := time.Now() out, err := s.handleCall(ctx, p.Name, p.Arguments) + logToolCall(ctx, p.Name, p.Arguments, start, err) if err != nil { rpcErr = &rpcError{Code: -32000, Message: err.Error()} break