feat(brain-mcp): audit-log every tools/call with principal and scope

brain_query and brain_read left no record of who read what; only capture
and governance edits reached the audit sink (OWASP MCP08).

One slog record per call: tool, principal, origin (static/jwt/unknown),
wing/hall when given, outcome, duration. Content is reduced to
content_bytes so the audit line never copies brain content.

Refs #144

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:44:21 +02:00
co-authored by Claude Opus 5.5
parent f21be1aef6
commit a9a9eebe26
3 changed files with 139 additions and 0 deletions
+53
View File
@@ -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...)
}