mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-09-18 23:52:39 +02:00
feat: add configurable tool call blocking and monitoring
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user