@@ -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...)
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
@@ -229,7 +230,9 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
ctx = withPrincipal(ctx, principal, viaStatic)
|
ctx = withPrincipal(ctx, principal, viaStatic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
start := time.Now()
|
||||||
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
out, err := s.handleCall(ctx, p.Name, p.Arguments)
|
||||||
|
logToolCall(ctx, p.Name, p.Arguments, start, err)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
rpcErr = &rpcError{Code: -32000, Message: err.Error()}
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user