feat: add configurable tool call blocking and monitoring

This commit is contained in:
Ed1s0nZ
2026-09-08 09:44:32 +08:00
parent c70da22de7
commit 6ad9ea2d13
54 changed files with 4635 additions and 152 deletions
+86
View File
@@ -0,0 +1,86 @@
package mcp
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestBlockedExecutionIsTerminalAndNotFailed(t *testing.T) {
for _, blocked := range []bool{true, false} {
name := "error"
want := ToolExecutionStatusFailed
if blocked {
name, want = "blocked", ToolExecutionStatusBlocked
}
t.Run(name, func(t *testing.T) {
service := NewExecutionService(nil, nil)
handle, err := service.Submit(context.Background(), ExecutionRequest{
ToolName: "test",
Run: func(context.Context) (*ToolResult, error) {
// Identical text must not turn ordinary failures into policy blocks.
return &ToolResult{Content: []Content{{Type: "text", Text: toolGuardBlockedPrefix}}, IsError: true, Blocked: blocked}, nil
},
})
if err != nil {
t.Fatal(err)
}
snap, err := service.Wait(context.Background(), handle.ID, time.Second)
if err != nil || snap.Execution.Status != want || snap.Execution.Result.Blocked != blocked || snap.Execution.Error == "" {
t.Fatalf("incorrect classification: snapshot=%#v err=%v", snap, err)
}
if !isExecutionTerminal(want) || executionStatusCountsAsFailed(want) == blocked {
t.Fatalf("incorrect terminal/failure classification for %s", want)
}
if service.Cancel(handle.ID, "cancel after completion") {
t.Fatal("terminal execution must not be cancellable")
}
after, _ := service.Get(handle.ID)
if after.Execution.Status != want {
t.Fatalf("cancel reclassified terminal execution: %s", after.Execution.Status)
}
})
}
}
func TestBlockedMarkerSurvivesNormalizationAndMCPProtocol(t *testing.T) {
original := &ToolResult{Content: []Content{{Type: "text", Text: strings.Repeat("refused ", 2000)}}, IsError: true, Blocked: true}
bounded := NormalizeToolResultForStorageWithSpill(original, 1000, ToolResultSpillConfig{RootDir: t.TempDir(), ExecutionID: "blocked"})
if !bounded.Blocked || !bounded.IsError || ToolResultPlainText(bounded) == ToolResultPlainText(original) {
t.Fatal("normalization must retain classification while bounding long output")
}
wire, err := json.Marshal(CallToolResponse{Content: bounded.Content, IsError: bounded.IsError, Blocked: bounded.Blocked, Meta: toolResultProtocolMeta(bounded)})
if err != nil {
t.Fatal(err)
}
var decoded ToolResult
if err := json.Unmarshal(wire, &decoded); err != nil || !decoded.Blocked || !decoded.IsError {
t.Fatalf("application protocol lost block marker: %#v err=%v", decoded, err)
}
var sdkResult sdkmcp.CallToolResult
if err := json.Unmarshal(wire, &sdkResult); err != nil {
t.Fatal(err)
}
converted := sdkCallToolResultToOurs(&sdkResult)
if !converted.Blocked || !converted.IsError {
t.Fatalf("SDK round trip lost block marker: %#v", converted)
}
}
func TestToolStatsSeparateBlockedFromFailures(t *testing.T) {
server := NewServer(nil)
manager := NewExternalMCPManager(nil)
for _, status := range []string{ToolExecutionStatusCompleted, ToolExecutionStatusFailed, ToolExecutionStatusBlocked, ToolExecutionStatusCancelled} {
server.updateStats("test", status)
manager.updateStats("test", status)
}
for name, stat := range map[string]*ToolStats{"internal": server.stats["test"], "external": manager.stats["test"]} {
if stat.TotalCalls != 4 || stat.SuccessCalls != 1 || stat.FailedCalls != 1 || stat.BlockedCalls != 1 {
t.Fatalf("%s stats = %#v", name, stat)
}
}
}
+2
View File
@@ -308,9 +308,11 @@ func sdkCallToolResultToOurs(res *mcp.CallToolResult) *ToolResult {
return &ToolResult{Content: []Content{}}
}
content := sdkContentToOurs(res.Content)
blocked, _ := res.Meta[toolGuardBlockedMetaKey].(bool)
return &ToolResult{
Content: content,
IsError: res.IsError,
Blocked: blocked,
}
}
+3
View File
@@ -190,6 +190,9 @@ func formatExecutionForModel(exec *ToolExecution, opts executionFormatOptions) s
if exec.Result != nil {
payload["result"] = ToolResultPlainText(exec.Result)
payload["is_error"] = exec.Result.IsError
if exec.Result.Blocked {
payload["blocked"] = true
}
}
if opts.includePartialOutput && exec.PartialOutput != "" {
partial := tailStringBytes(exec.PartialOutput, opts.partialMaxBytes)
+38 -8
View File
@@ -18,6 +18,7 @@ const (
ToolExecutionStatusQueued = "queued"
ToolExecutionStatusRunning = "running"
ToolExecutionStatusCompleted = "completed"
ToolExecutionStatusBlocked = "blocked"
ToolExecutionStatusFailed = "failed"
ToolExecutionStatusCancelled = "cancelled"
ToolExecutionStatusHardTimeout = "hard_timeout"
@@ -224,6 +225,10 @@ func (s *ExecutionService) markEntryRunning(entry *executionEntry) {
func (s *ExecutionService) finishEntry(ctx context.Context, entry *executionEntry, result *ToolResult, err error, onDone ExecutionDoneFunc) {
id := entry.exec.ID
var blockedErr *toolGuardBlockError
if errors.As(err, &blockedErr) {
result, err = blockedErr.result, nil
}
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(id, &result, &err)
now := time.Now()
@@ -258,6 +263,10 @@ func (s *ExecutionService) finishEntry(ctx context.Context, entry *executionEntr
entry.exec.Status = ToolExecutionStatusFailed
entry.exec.Error = err.Error()
}
} else if result != nil && result.Blocked {
entry.exec.Status = ToolExecutionStatusBlocked
entry.exec.Error = firstToolResultText(result, "工具调用已被安全规则拦截")
entry.exec.Result = result
} else if result != nil && result.IsError {
if cancelledWithUserNote {
entry.exec.Status = ToolExecutionStatusCancelled
@@ -318,10 +327,11 @@ func (s *ExecutionService) Wait(ctx context.Context, executionID string, timeout
if entry == nil {
return s.getPersistedSnapshot(executionID)
}
if isExecutionTerminal(entry.exec.Status) {
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
select {
case <-entry.done:
return s.snapshotEntry(entry), nil
default:
}
var timeoutCh <-chan time.Time
var timer *time.Timer
if timeout > 0 {
@@ -332,18 +342,26 @@ func (s *ExecutionService) Wait(ctx context.Context, executionID string, timeout
select {
case <-entry.done:
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
return s.snapshotEntry(entry), nil
case <-timeoutCh:
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, ErrExecutionWaitTimeout
return s.snapshotEntry(entry), ErrExecutionWaitTimeout
case <-ctxDone(ctx):
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, ctx.Err()
return s.snapshotEntry(entry), ctx.Err()
}
}
// snapshotEntry synchronizes snapshots with worker state and partial output
// updates. Wait uses done to also observe persistence and completion callbacks.
func (s *ExecutionService) snapshotEntry(entry *executionEntry) *ExecutionSnapshot {
s.mu.Lock()
defer s.mu.Unlock()
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}
}
func (s *ExecutionService) Get(executionID string) (*ExecutionSnapshot, error) {
entry := s.getEntry(executionID)
if entry != nil {
return &ExecutionSnapshot{Execution: cloneToolExecution(entry.exec)}, nil
return s.snapshotEntry(entry), nil
}
return s.getPersistedSnapshot(executionID)
}
@@ -464,6 +482,9 @@ func (s *ExecutionService) applyAbortUserNoteToCancelledToolResult(executionID s
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if hasRes && (*result).Blocked {
return false
}
if !hasErr && !hasRes {
return false
}
@@ -549,7 +570,16 @@ func isBackgroundWaitToolResult(result *ToolResult) bool {
func isExecutionTerminal(status string) bool {
switch strings.TrimSpace(strings.ToLower(status)) {
case ToolExecutionStatusCompleted, ToolExecutionStatusFailed, ToolExecutionStatusCancelled, ToolExecutionStatusHardTimeout, ToolExecutionStatusOrphaned:
case ToolExecutionStatusCompleted, ToolExecutionStatusBlocked, ToolExecutionStatusFailed, ToolExecutionStatusCancelled, ToolExecutionStatusHardTimeout, ToolExecutionStatusOrphaned:
return true
default:
return false
}
}
func executionStatusCountsAsFailed(status string) bool {
switch status {
case ToolExecutionStatusFailed, ToolExecutionStatusHardTimeout, ToolExecutionStatusOrphaned:
return true
default:
return false
+44 -8
View File
@@ -11,6 +11,7 @@ import (
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/toolguard"
"go.uber.org/zap"
)
@@ -74,6 +75,7 @@ type ExternalMCPManager struct {
reconnectLastTry map[string]time.Time
reconnectAttempts map[string]int
toolAuthorizer func(context.Context, string, map[string]interface{}) error
toolGuard *toolguard.Manager
executionService *ExecutionService
toolWaitTimeout time.Duration
toolResultMaxBytes int
@@ -96,6 +98,23 @@ func (m *ExternalMCPManager) SetToolAuthorizer(authorizer func(context.Context,
m.mu.Unlock()
}
// SetToolGuard installs safety rules evaluated before dispatch to external MCPs.
func (m *ExternalMCPManager) SetToolGuard(guard *toolguard.Manager) {
if m == nil {
return
}
m.mu.Lock()
m.toolGuard = guard
m.mu.Unlock()
}
func (m *ExternalMCPManager) checkToolGuard(toolName string, args map[string]interface{}) *ToolResult {
m.mu.RLock()
guard := m.toolGuard
m.mu.RUnlock()
return toolGuardBlockedResult(guard, toolName, args)
}
// NewExternalMCPManagerWithStorage 创建外部MCP管理器(带持久化存储)
func NewExternalMCPManagerWithStorage(logger *zap.Logger, storage MonitorStorage) *ExternalMCPManager {
manager := &ExternalMCPManager{
@@ -685,6 +704,7 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
}
var mcpName, actualToolName string
var client ExternalMCPClient
var blockedByGuard bool
handle, err := m.executionService.Submit(ctx, ExecutionRequest{
ToolName: toolName,
Arguments: args,
@@ -702,6 +722,10 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
} else if authenticated {
return nil, fmt.Errorf("external tool authorization policy is not configured")
}
if blocked := m.checkToolGuard(toolName, args); blocked != nil {
blockedByGuard = true
return nil, &toolGuardBlockError{result: blocked}
}
// 解析工具名称:name::toolName
if idx := findSubstring(toolName, "::"); idx > 0 {
@@ -741,6 +765,11 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return release, nil
},
Run: func(runCtx context.Context) (*ToolResult, error) {
// Rules may have changed while this execution waited for a slot.
if blocked := m.checkToolGuard(toolName, args); blocked != nil {
blockedByGuard = true
return blocked, nil
}
result, callErr := client.CallTool(runCtx, actualToolName, args)
if callErr != nil {
m.handleConnectionDead(mcpName, client, callErr)
@@ -748,11 +777,13 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return result, callErr
},
OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
if mcpName != "" {
failed := exec != nil && executionStatusCountsAsFailed(exec.Status)
if mcpName != "" && !blockedByGuard && (exec == nil || exec.Status != ToolExecutionStatusBlocked) {
m.recordExternalMCPResult(mcpName, failed)
}
m.updateStats(toolName, failed)
if exec != nil {
m.updateStats(toolName, exec.Status)
}
},
})
if err != nil {
@@ -941,6 +972,9 @@ func (m *ExternalMCPManager) applyAbortUserNoteToCancelledToolResult(executionID
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if hasRes && (*result).Blocked {
return false
}
if !hasErr && !hasRes {
return false
}
@@ -1098,15 +1132,15 @@ func (m *ExternalMCPManager) ActiveRunningExecutionIDs() map[string]struct{} {
}
// updateStats 更新统计信息
func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
func (m *ExternalMCPManager) updateStats(toolName string, status string) {
now := time.Now()
if m.storage != nil {
totalCalls := 1
successCalls := 0
failedCalls := 0
if failed {
if executionStatusCountsAsFailed(status) {
failedCalls = 1
} else {
} else if status == ToolExecutionStatusCompleted {
successCalls = 1
}
if err := m.storage.UpdateToolStats(toolName, totalCalls, successCalls, failedCalls, &now); err != nil {
@@ -1128,10 +1162,12 @@ func (m *ExternalMCPManager) updateStats(toolName string, failed bool) {
stats.TotalCalls++
stats.LastCallTime = &now
if failed {
if executionStatusCountsAsFailed(status) {
stats.FailedCalls++
} else {
} else if status == ToolExecutionStatusCompleted {
stats.SuccessCalls++
} else if status == ToolExecutionStatusBlocked {
stats.BlockedCalls++
}
}
@@ -72,7 +72,9 @@ func TestExternalMCPManager_CallToolBoundedWaitThenContinue(t *testing.T) {
manager.ConfigureToolWaitTimeoutSeconds(1)
manager.toolWaitTimeout = 10 * time.Millisecond
client := newBlockingExternalMCPClient("slow result ready")
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
callCtx, callCancel := context.WithCancel(context.Background())
result, executionID, err := manager.CallTool(callCtx, "lab::slow_tool", map[string]interface{}{"target": "example"})
@@ -117,7 +119,9 @@ func TestExecutionControlWaitToolReturnsCompletedResult(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
manager.toolWaitTimeout = 10 * time.Millisecond
client := newBlockingExternalMCPClient("control wait result")
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
result, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", nil)
if err != nil {
@@ -157,7 +161,9 @@ func TestExternalMCPManager_PerServerConcurrencyLimitsWorkers(t *testing.T) {
CircuitCooldown: time.Second,
})
client := newBlockingExternalMCPClient("ok")
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
done1 := make(chan struct{})
go func() {
@@ -217,7 +223,9 @@ func TestExternalMCPManager_CircuitBreakerOpensAfterFailures(t *testing.T) {
CircuitFailureThreshold: 1,
CircuitCooldown: time.Minute,
})
manager.mu.Lock()
manager.clients["lab"] = &failingExternalMCPClient{}
manager.mu.Unlock()
_, _, err := manager.CallTool(context.Background(), "lab::fail_tool", nil)
if err == nil || !strings.Contains(err.Error(), "boom") {
+54 -16
View File
@@ -16,6 +16,7 @@ import (
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/mcp/builtin"
"cyberstrike-ai/internal/toolguard"
"github.com/google/uuid"
"go.uber.org/zap"
@@ -53,6 +54,7 @@ type Server struct {
httpToolTimeoutMinutes *int
httpToolTimeoutMu sync.RWMutex
toolAuthorizer func(context.Context, string, map[string]interface{}) error
toolGuard *toolguard.Manager
executionService *ExecutionService
toolWaitTimeout time.Duration
toolResultMaxBytes int
@@ -72,6 +74,23 @@ func (s *Server) SetToolAuthorizer(authorizer func(context.Context, string, map[
s.mu.Unlock()
}
// SetToolGuard installs the runtime safety rules shared by HTTP and internal calls.
func (s *Server) SetToolGuard(guard *toolguard.Manager) {
if s == nil {
return
}
s.mu.Lock()
s.toolGuard = guard
s.mu.Unlock()
}
func (s *Server) checkToolGuard(toolName string, args map[string]interface{}) *ToolResult {
s.mu.RLock()
guard := s.toolGuard
s.mu.RUnlock()
return toolGuardBlockedResult(guard, toolName, args)
}
type sseClient struct {
id string
send chan []byte
@@ -566,7 +585,7 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
s.mu.Unlock()
}
s.updateStats(req.Name, true)
s.updateStats(req.Name, ToolExecutionStatusFailed)
return &Message{
ID: msg.ID,
@@ -590,10 +609,13 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
zap.Any("arguments", req.Arguments),
)
result, err := handler(execCtx, req.Arguments)
result := s.checkToolGuard(req.Name, req.Arguments)
var err error
if result == nil {
result, err = handler(execCtx, req.Arguments)
}
cancelledWithUserNote := s.applyAbortUserNoteToCancelledToolResult(executionID, &result, &err)
now := time.Now()
var failed bool
var finalResult *ToolResult
s.mu.Lock()
@@ -604,13 +626,15 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
st, msg := executionStatusAndMessage(err)
execution.Status = st
execution.Error = msg
failed = st != "cancelled"
} else if result != nil && result.Blocked {
execution.Status = ToolExecutionStatusBlocked
execution.Error = firstToolResultText(result, toolGuardBlockedPrefix)
execution.Result = result
} else if result != nil && result.IsError {
if cancelledWithUserNote {
execution.Status = "cancelled"
execution.Error = ""
execution.Result = result
failed = false
} else {
execution.Status = "failed"
if len(result.Content) > 0 {
@@ -619,7 +643,6 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
execution.Error = "工具执行返回错误结果"
}
execution.Result = result
failed = true
}
} else {
execution.Status = "completed"
@@ -631,7 +654,6 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
}
}
execution.Result = result
failed = false
}
finalResult = execution.Result
@@ -643,7 +665,7 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
}
}
s.updateStats(req.Name, failed)
s.updateStats(req.Name, execution.Status)
if s.storage != nil {
s.mu.Lock()
@@ -683,6 +705,8 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
errorResult, _ := json.Marshal(CallToolResponse{
Content: finalResult.Content,
IsError: true,
Blocked: finalResult.Blocked,
Meta: toolResultProtocolMeta(finalResult),
})
return &Message{
ID: msg.ID,
@@ -719,15 +743,15 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
}
// updateStats 更新统计信息
func (s *Server) updateStats(toolName string, failed bool) {
func (s *Server) updateStats(toolName string, status string) {
now := time.Now()
if s.storage != nil {
totalCalls := 1
successCalls := 0
failedCalls := 0
if failed {
if executionStatusCountsAsFailed(status) {
failedCalls = 1
} else {
} else if status == ToolExecutionStatusCompleted {
successCalls = 1
}
if err := s.storage.UpdateToolStats(toolName, totalCalls, successCalls, failedCalls, &now); err != nil {
@@ -749,10 +773,12 @@ func (s *Server) updateStats(toolName string, failed bool) {
stats.TotalCalls++
stats.LastCallTime = &now
if failed {
if executionStatusCountsAsFailed(status) {
stats.FailedCalls++
} else {
} else if status == ToolExecutionStatusCompleted {
stats.SuccessCalls++
} else if status == ToolExecutionStatusBlocked {
stats.BlockedCalls++
}
}
@@ -925,11 +951,15 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
if !exists {
return nil, fmt.Errorf("工具 %s 未找到", toolName)
}
if blocked := s.checkToolGuard(toolName, args); blocked != nil {
return blocked, nil
}
return handler(runCtx, args)
},
OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
s.updateStats(toolName, failed)
if exec != nil {
s.updateStats(toolName, exec.Status)
}
},
})
if err != nil {
@@ -1111,7 +1141,7 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
}
}
s.updateStats(exec.ToolName, failed)
s.updateStats(exec.ToolName, exec.Status)
if s.storage != nil {
s.mu.Lock()
@@ -1155,6 +1185,11 @@ func (s *Server) UpdateToolExecutionResult(executionID string, result *ToolResul
if executionID == "" || result == nil {
return nil
}
if previous, ok := s.GetExecution(executionID); ok && previous != nil &&
(previous.Status == ToolExecutionStatusBlocked || previous.Result != nil && previous.Result.Blocked) {
result = cloneToolResult(result)
result.Blocked, result.IsError = true, true
}
s.mu.Lock()
spill := ToolResultSpillConfig{
RootDir: s.spillRootDir,
@@ -1270,6 +1305,9 @@ func (s *Server) applyAbortUserNoteToCancelledToolResult(executionID string, res
}
hasErr := err != nil && *err != nil
hasRes := result != nil && *result != nil
if hasRes && (*result).Blocked {
return false
}
if !hasErr && !hasRes {
return false
}
+41
View File
@@ -0,0 +1,41 @@
package mcp
import (
"fmt"
"strings"
"cyberstrike-ai/internal/toolguard"
)
const toolGuardBlockedPrefix = "工具调用已被安全规则拦截"
const toolGuardBlockedMetaKey = "cyberstrike.ai/blocked"
// toolGuardBlockError carries structured policy results through pre-run hooks.
type toolGuardBlockError struct{ result *ToolResult }
func (e *toolGuardBlockError) Error() string { return ToolResultPlainText(e.result) }
func toolResultProtocolMeta(result *ToolResult) map[string]interface{} {
if result != nil && result.Blocked {
return map[string]interface{}{toolGuardBlockedMetaKey: true}
}
return nil
}
// toolGuardBlockedResult uses the standard MCP error result so the refusal is
// visible both to the model and in persisted execution monitoring records.
func toolGuardBlockedResult(guard *toolguard.Manager, toolName string, args map[string]interface{}) *ToolResult {
if guard == nil {
return nil
}
match := guard.Check(toolName, args)
if match == nil {
return nil
}
message := toolGuardBlockedPrefix
if custom := strings.TrimSpace(match.Message); custom != "" {
message += "" + custom
}
message += fmt.Sprintf("\n规则: %s (%s)\n匹配内容: %q", match.RuleName, match.RuleID, match.MatchedText)
return &ToolResult{Content: []Content{{Type: "text", Text: message}}, IsError: true, Blocked: true}
}
+239
View File
@@ -0,0 +1,239 @@
package mcp
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
"cyberstrike-ai/internal/toolguard"
"go.uber.org/zap"
)
func testToolGuard(t *testing.T, enabled bool) *toolguard.Manager {
t.Helper()
guard, err := toolguard.NewManager(toolguard.DefaultConfig())
if err != nil {
t.Fatal(err)
}
if err := guard.Update(toolguard.Config{Enabled: enabled, Rules: []toolguard.Rule{{
ID: "government", Name: "政府网站保护", Enabled: true,
Pattern: `(?i)[a-z0-9.-]+\.gov(?:\.[a-z0-9.-]+)?`,
Message: "识别到 {match},禁止攻击政府网站,请检查目标授权。",
}}}); err != nil {
t.Fatal(err)
}
return guard
}
func assertGuardRefusal(t *testing.T, result *ToolResult, err error) {
t.Helper()
message := ToolResultPlainText(result)
if err != nil {
t.Fatalf("expected structured refusal, got error: %v", err)
} else if result == nil || !result.IsError || !result.Blocked {
t.Fatalf("expected tool error result, got %#v", result)
}
for _, text := range []string{toolGuardBlockedPrefix, "禁止攻击政府网站", "agency.gov.cn", "government"} {
if !strings.Contains(message, text) {
t.Errorf("refusal %q missing %q", message, text)
}
}
}
func TestServerToolGuardBlocksBeforeHandlerAndUpdatesLive(t *testing.T) {
storage := newInMemoryMonitorStorage()
server := NewServerWithStorage(zap.NewNop(), storage)
guard := testToolGuard(t, true)
server.SetToolGuard(guard)
var calls, authorized atomic.Int32
server.SetToolAuthorizer(func(context.Context, string, map[string]interface{}) error {
authorized.Add(1)
return nil
})
server.RegisterTool(Tool{Name: "scan"}, func(context.Context, map[string]interface{}) (*ToolResult, error) {
calls.Add(1)
return &ToolResult{Content: []Content{{Type: "text", Text: "ok"}}}, nil
})
args := map[string]interface{}{"command": "scan https://agency.gov.cn"}
result, executionID, err := server.CallTool(context.Background(), "scan", args)
assertGuardRefusal(t, result, err)
if calls.Load() != 0 || authorized.Load() != 1 {
t.Fatalf("calls=%d authorized=%d, want 0 and 1", calls.Load(), authorized.Load())
}
execution, err := storage.GetToolExecution(executionID)
if err != nil || execution == nil || execution.Status != ToolExecutionStatusBlocked || !strings.Contains(execution.Error, toolGuardBlockedPrefix) {
t.Fatalf("expected persisted blocked execution, got %#v, err=%v", execution, err)
}
result, _, err = server.CallTool(context.Background(), "scan", map[string]interface{}{"target": "example.org"})
if err != nil || result.IsError || calls.Load() != 1 {
t.Fatalf("allowed target did not execute: result=%#v calls=%d err=%v", result, calls.Load(), err)
}
cfg := guard.Config()
cfg.Rules[0].Enabled = false
if err := guard.Update(cfg); err != nil {
t.Fatal(err)
}
result, _, err = server.CallTool(context.Background(), "scan", args)
if err != nil || result.IsError || calls.Load() != 2 {
t.Fatalf("disabled rule did not take effect: result=%#v calls=%d err=%v", result, calls.Load(), err)
}
}
func TestHTTPToolGuardReturnsMCPErrorAndPersistsRefusal(t *testing.T) {
storage := newInMemoryMonitorStorage()
server := NewServerWithStorage(zap.NewNop(), storage)
server.SetToolGuard(testToolGuard(t, true))
var calls int
server.RegisterTool(Tool{Name: "scan"}, func(context.Context, map[string]interface{}) (*ToolResult, error) {
calls++
return &ToolResult{Content: []Content{{Type: "text", Text: "ok"}}}, nil
})
for _, tc := range []struct {
target string
blocked bool
}{
{target: "https://agency.gov.cn", blocked: true},
{target: "https://example.org", blocked: false},
} {
body, err := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]interface{}{"name": "scan", "arguments": map[string]interface{}{"target": tc.target}},
})
if err != nil {
t.Fatal(err)
}
recorder := httptest.NewRecorder()
server.HandleHTTP(recorder, httptest.NewRequest(http.MethodPost, "/api/mcp", strings.NewReader(string(body))))
var response Message
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if recorder.Code != http.StatusOK || response.Error != nil {
t.Fatalf("expected MCP tool result, status=%d body=%s", recorder.Code, recorder.Body)
}
var result ToolResult
if err := json.Unmarshal(response.Result, &result); err != nil {
t.Fatal(err)
}
if tc.blocked {
assertGuardRefusal(t, &result, nil)
if calls != 0 {
t.Fatal("HTTP tool handler ran for a blocked target")
}
executions, err := storage.LoadToolExecutions()
if err != nil || len(executions) != 1 || executions[0].Status != ToolExecutionStatusBlocked || !strings.Contains(executions[0].Error, toolGuardBlockedPrefix) {
t.Fatalf("expected persisted HTTP refusal, got %#v err=%v", executions, err)
}
} else if result.IsError || calls != 1 {
t.Fatalf("allowed HTTP target did not execute: result=%#v calls=%d", result, calls)
}
}
}
func TestExternalToolGuardBlocksBeforeClientAndUpdatesLive(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
t.Cleanup(manager.StopAll)
guard := testToolGuard(t, true)
manager.SetToolGuard(guard)
client := newBlockingExternalMCPClient("ok")
close(client.release)
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
args := map[string]interface{}{"target": "https://agency.gov.cn"}
result, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", args)
assertGuardRefusal(t, result, err)
if client.count.Load() != 0 {
t.Fatal("external client ran for a blocked target")
}
execution, ok := manager.GetExecution(executionID)
if !ok || execution.Status != ToolExecutionStatusBlocked || !strings.Contains(execution.Error, toolGuardBlockedPrefix) {
t.Fatalf("expected blocked external execution, got %#v", execution)
}
cfg := guard.Config()
cfg.Enabled = false
if err := guard.Update(cfg); err != nil {
t.Fatal(err)
}
result, _, err = manager.CallTool(context.Background(), "lab::slow_tool", args)
if err != nil || result.IsError || client.count.Load() != 1 {
t.Fatalf("disabled guard did not take effect: result=%#v calls=%d err=%v", result, client.count.Load(), err)
}
}
func TestExternalToolGuardRechecksQueuedCallsWithoutTrippingCircuit(t *testing.T) {
manager := NewExternalMCPManager(zap.NewNop())
t.Cleanup(manager.StopAll)
manager.toolWaitTimeout = 10 * time.Millisecond
manager.ConfigureResilience(ExternalMCPResilienceConfig{
MaxConcurrentPerServer: 1, MaxConcurrentTotal: 4,
CircuitFailureThreshold: 1, CircuitCooldown: time.Minute,
})
guard := testToolGuard(t, false)
manager.SetToolGuard(guard)
client := newBlockingExternalMCPClient("ok")
close(client.release)
manager.mu.Lock()
manager.clients["lab"] = client
manager.mu.Unlock()
// Occupy the provider slot so the call passes its initial policy check and
// remains queued until a live rule update is applied.
release, err := manager.acquireExternalMCPCallSlot(context.Background(), "lab")
if err != nil {
t.Fatal(err)
}
released := false
t.Cleanup(func() {
if !released {
release()
}
})
_, executionID, err := manager.CallTool(context.Background(), "lab::slow_tool", map[string]interface{}{"target": "agency.gov.cn"})
if err != nil || executionID == "" {
t.Fatalf("failed to queue external call: id=%q err=%v", executionID, err)
}
deadline := time.After(time.Second)
ticker := time.NewTicker(time.Millisecond)
defer ticker.Stop()
for len(manager.globalSemaphore) != 2 {
select {
case <-deadline:
t.Fatal("execution did not reach the provider slot queue")
case <-ticker.C:
}
}
cfg := guard.Config()
cfg.Enabled = true
if err := guard.Update(cfg); err != nil {
t.Fatal(err)
}
release()
released = true
snapshot, err := manager.executionService.Wait(context.Background(), executionID, time.Second)
if err != nil || snapshot == nil || snapshot.Execution == nil || snapshot.Execution.Status != ToolExecutionStatusBlocked {
t.Fatalf("expected queued execution to be blocked on policy recheck, got %#v err=%v", snapshot, err)
}
assertGuardRefusal(t, snapshot.Execution.Result, nil)
if client.count.Load() != 0 {
t.Fatal("queued call bypassed the updated guard")
}
manager.mu.RLock()
runtime := manager.serverRuntimes["lab"]
failures, openUntil := runtime.consecutiveFailures, runtime.circuitOpenUntil
manager.mu.RUnlock()
if failures != 0 || !openUntil.IsZero() {
t.Fatalf("local policy refusal affected provider circuit: failures=%d openUntil=%v", failures, openUntil)
}
result, _, err := manager.CallTool(context.Background(), "lab::slow_tool", map[string]interface{}{"target": "example.org"})
if err != nil || result.IsError || client.count.Load() != 1 {
t.Fatalf("allowed call failed after policy refusal: result=%#v calls=%d err=%v", result, client.count.Load(), err)
}
}
+9 -3
View File
@@ -116,6 +116,9 @@ type ToolCall struct {
type ToolResult struct {
Content []Content `json:"content"`
IsError bool `json:"isError,omitempty"`
// Blocked means policy stopped the call before execution. IsError remains
// true for MCP/model handling, while monitoring uses a distinct status.
Blocked bool `json:"blocked,omitempty"`
}
// Content 表示内容
@@ -184,8 +187,10 @@ type CallToolRequest struct {
// CallToolResponse 调用工具响应
type CallToolResponse struct {
Content []Content `json:"content"`
IsError bool `json:"isError,omitempty"`
Content []Content `json:"content"`
IsError bool `json:"isError,omitempty"`
Blocked bool `json:"blocked,omitempty"`
Meta map[string]interface{} `json:"_meta,omitempty"`
}
// ToolExecution 工具执行记录
@@ -193,7 +198,7 @@ type ToolExecution struct {
ID string `json:"id"`
ToolName string `json:"toolName"`
Arguments map[string]interface{} `json:"arguments"`
Status string `json:"status"` // pending, running, completed, failed, cancelled
Status string `json:"status"` // queued, running, completed, blocked, failed, cancelled, hard_timeout, orphaned
Result *ToolResult `json:"result,omitempty"`
Error string `json:"error,omitempty"`
StartTime time.Time `json:"startTime"`
@@ -216,6 +221,7 @@ type ToolStats struct {
TotalCalls int `json:"totalCalls"`
SuccessCalls int `json:"successCalls"`
FailedCalls int `json:"failedCalls"`
BlockedCalls int `json:"blockedCalls"`
LastCallTime *time.Time `json:"lastCallTime,omitempty"`
}