feat: audit mcp tool calls and guard against fleet-wide fan-out

This commit is contained in:
2026-09-08 13:50:28 +00:00
parent 5943d98681
commit 674236bb76
2 changed files with 140 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
package mcp
import (
"errors"
"fmt"
"sort"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// FanOutLimit is how many servers a write tool may touch before it demands
// explicit confirmation. Cheap insurance against a mis-parsed selector reaching
// the whole fleet on one badly phrased instruction.
const FanOutLimit = 25
// ErrConfirmRequired is returned to the model as a tool error it can act on:
// it says what would have happened and how to proceed deliberately.
var ErrConfirmRequired = errors.New("confirmation required")
// CheckFanOut refuses a write that would touch more servers than FanOutLimit
// unless the call passed confirm:true.
func CheckFanOut(count int, args map[string]any) error {
if count <= FanOutLimit {
return nil
}
if confirm, ok := args["confirm"].(bool); ok && confirm {
return nil
}
return fmt.Errorf("%w: this would affect %d servers, above the limit of %d; "+
"call again with confirm:true if that is intended",
ErrConfirmRequired, count, FanOutLimit)
}
// SummariseArgs renders an argument object as a short, deterministic,
// bounded string for the audit log. Values are described rather than
// reproduced: an argument may carry arbitrary text a model generated.
func SummariseArgs(args map[string]any) string {
if len(args) == 0 {
return "no arguments"
}
keys := make([]string, 0, len(args))
for k := range args {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+summariseValue(args[k]))
}
out := strings.Join(parts, " ")
if len(out) > 200 {
out = out[:197] + "..."
}
return out
}
func summariseValue(v any) string {
switch t := v.(type) {
case string:
if len(t) > 40 {
return fmt.Sprintf("<%d chars>", len(t))
}
return t
case bool, float64, int:
return fmt.Sprint(t)
case []any:
return fmt.Sprintf("<%d items>", len(t))
case map[string]any:
return fmt.Sprintf("<%d fields>", len(t))
default:
return "<value>"
}
}
// LogCall records a successful tool call. Reads are recorded as well as writes:
// the point of an agent-facing surface is being able to reconstruct afterwards
// what the agent looked at, not only what it changed.
func LogCall(c Caller, t Tool, args map[string]any, servers int) {
detail := fmt.Sprintf("tool %s (%s)", t.Name, SummariseArgs(args))
if servers > 0 {
detail += fmt.Sprintf(", %d server(s) affected", servers)
}
services.LogEvent(c.InstanceID, "mcp.tool_call", c.TokenName, "", "", detail)
}
// LogDenied records a refusal and which gate refused, which is what turns "the
// agent said it could not" into a diagnosable event.
func LogDenied(c Caller, toolName, gate string) {
services.LogEvent(c.InstanceID, "mcp.tool_denied", c.TokenName, "", "",
fmt.Sprintf("tool %s refused by %s", toolName, gate))
}
+45
View File
@@ -0,0 +1,45 @@
package mcp
import (
"strings"
"testing"
)
// Arguments can carry arbitrary model output and the audit log is read by
// humans in a UI, so they are summarised rather than dumped.
func TestSummariseArgsIsBoundedAndOrdered(t *testing.T) {
got := SummariseArgs(map[string]any{
"workflow_id": "wf-1",
"note": strings.Repeat("x", 500),
})
if len(got) > 200 {
t.Errorf("summary is %d chars, want at most 200", len(got))
}
if !strings.Contains(got, "workflow_id") {
t.Errorf("summary %q omits an argument name", got)
}
// Deterministic ordering, or two identical calls produce different audit
// rows and nothing can be compared.
if SummariseArgs(map[string]any{"b": 1, "a": 2}) != SummariseArgs(map[string]any{"a": 2, "b": 1}) {
t.Error("SummariseArgs is not deterministic")
}
}
func TestCheckFanOutRequiresConfirmation(t *testing.T) {
if err := CheckFanOut(5, nil); err != nil {
t.Errorf("CheckFanOut(5) = %v, want nil", err)
}
err := CheckFanOut(200, nil)
if err == nil {
t.Fatal("CheckFanOut(200) = nil, want a refusal")
}
if !strings.Contains(err.Error(), "200") {
t.Errorf("refusal %q does not say how many servers", err)
}
if err := CheckFanOut(200, map[string]any{"confirm": true}); err != nil {
t.Errorf("CheckFanOut(200, confirm) = %v, want nil", err)
}
}