diff --git a/.changeset/patch-surface-audit-guard-policy-events.md b/.changeset/patch-surface-audit-guard-policy-events.md new file mode 100644 index 00000000000..0470bc8bc88 --- /dev/null +++ b/.changeset/patch-surface-audit-guard-policy-events.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Surface MCP Gateway guard policy enforcement events in `gh aw audit`, including parsed guard-policy error details, aggregated block metrics, and new audit report sections for block reasons, affected tools, and server-level blocked counts. diff --git a/pkg/cli/audit_report.go b/pkg/cli/audit_report.go index a9db5b783c9..297d067ccac 100644 --- a/pkg/cli/audit_report.go +++ b/pkg/cli/audit_report.go @@ -149,10 +149,11 @@ type ToolUsageInfo struct { // MCPToolUsageData contains detailed MCP tool usage statistics and individual call records type MCPToolUsageData struct { - Summary []MCPToolSummary `json:"summary"` // Aggregated statistics per tool - ToolCalls []MCPToolCall `json:"tool_calls"` // Individual tool call records - Servers []MCPServerStats `json:"servers,omitempty"` // Server-level statistics - FilteredEvents []DifcFilteredEvent `json:"filtered_events,omitempty"` // DIFC filtered events + Summary []MCPToolSummary `json:"summary"` // Aggregated statistics per tool + ToolCalls []MCPToolCall `json:"tool_calls"` // Individual tool call records + Servers []MCPServerStats `json:"servers,omitempty"` // Server-level statistics + FilteredEvents []DifcFilteredEvent `json:"filtered_events,omitempty"` // DIFC filtered events + GuardPolicySummary *GuardPolicySummary `json:"guard_policy_summary,omitempty"` // Guard policy enforcement summary } // MCPToolSummary contains aggregated statistics for a single MCP tool @@ -193,6 +194,22 @@ type MCPServerStats struct { ErrorCount int `json:"error_count,omitempty" console:"header:Errors,omitempty"` } +// GuardPolicySummary contains summary statistics for guard policy enforcement. +// Guard policies control which tool calls the MCP Gateway allows based on +// repository scope (repos) and content integrity level (min-integrity). +type GuardPolicySummary struct { + TotalBlocked int `json:"total_blocked"` + IntegrityBlocked int `json:"integrity_blocked"` // Blocked by min-integrity (-32006) + RepoScopeBlocked int `json:"repo_scope_blocked"` // Blocked by repos scope (-32002) + AccessDenied int `json:"access_denied"` // General access denied (-32001) + BlockedUserDenied int `json:"blocked_user_denied,omitempty"` // Content from blocked user (-32005) + PermissionDenied int `json:"permission_denied,omitempty"` // Insufficient permissions (-32003) + PrivateRepoDenied int `json:"private_repo_denied,omitempty"` // Private repository denied (-32004) + Events []GuardPolicyEvent `json:"events"` + BlockedToolCounts map[string]int `json:"blocked_tool_counts,omitempty"` // tool name -> blocked count + BlockedServerCounts map[string]int `json:"blocked_server_counts,omitempty"` // server ID -> blocked count +} + // PolicySummaryDisplay is a display-optimized version of PolicyAnalysis for console rendering type PolicySummaryDisplay struct { Policy string `console:"header:Policy"` diff --git a/pkg/cli/audit_report_render.go b/pkg/cli/audit_report_render.go index 066df4bf2e0..cbf76c577f5 100644 --- a/pkg/cli/audit_report_render.go +++ b/pkg/cli/audit_report_render.go @@ -6,6 +6,7 @@ import ( "math" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -514,6 +515,89 @@ func renderMCPToolUsageTable(mcpData *MCPToolUsageData) { fmt.Fprint(os.Stderr, console.RenderTable(toolConfig)) } + + // Render guard policy summary + if mcpData.GuardPolicySummary != nil && mcpData.GuardPolicySummary.TotalBlocked > 0 { + renderGuardPolicySummary(mcpData.GuardPolicySummary) + } +} + +// renderGuardPolicySummary renders the guard policy enforcement summary +func renderGuardPolicySummary(summary *GuardPolicySummary) { + auditReportLog.Printf("Rendering guard policy summary: %d total blocked", summary.TotalBlocked) + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage( + fmt.Sprintf("Guard Policy: %d tool call(s) blocked", summary.TotalBlocked))) + fmt.Fprintln(os.Stderr) + + // Breakdown by reason + fmt.Fprintln(os.Stderr, " Block Reasons:") + if summary.IntegrityBlocked > 0 { + fmt.Fprintf(os.Stderr, " Integrity below minimum : %d\n", summary.IntegrityBlocked) + } + if summary.RepoScopeBlocked > 0 { + fmt.Fprintf(os.Stderr, " Repository not allowed : %d\n", summary.RepoScopeBlocked) + } + if summary.AccessDenied > 0 { + fmt.Fprintf(os.Stderr, " Access denied : %d\n", summary.AccessDenied) + } + if summary.BlockedUserDenied > 0 { + fmt.Fprintf(os.Stderr, " Blocked user : %d\n", summary.BlockedUserDenied) + } + if summary.PermissionDenied > 0 { + fmt.Fprintf(os.Stderr, " Insufficient permissions: %d\n", summary.PermissionDenied) + } + if summary.PrivateRepoDenied > 0 { + fmt.Fprintf(os.Stderr, " Private repo denied : %d\n", summary.PrivateRepoDenied) + } + fmt.Fprintln(os.Stderr) + + // Most frequently blocked tools + if len(summary.BlockedToolCounts) > 0 { + toolNames := sliceutil.MapToSlice(summary.BlockedToolCounts) + sort.Slice(toolNames, func(i, j int) bool { + return summary.BlockedToolCounts[toolNames[i]] > summary.BlockedToolCounts[toolNames[j]] + }) + + toolRows := make([][]string, 0, len(toolNames)) + for _, name := range toolNames { + toolRows = append(toolRows, []string{name, strconv.Itoa(summary.BlockedToolCounts[name])}) + } + fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ + Title: "Most Blocked Tools", + Headers: []string{"Tool", "Blocked"}, + Rows: toolRows, + })) + } + + // Guard policy event details + if len(summary.Events) > 0 { + fmt.Fprintln(os.Stderr) + eventRows := make([][]string, 0, len(summary.Events)) + for _, evt := range summary.Events { + message := evt.Message + if len(message) > 60 { + message = message[:57] + "..." + } + repo := evt.Repository + if repo == "" { + repo = "-" + } + eventRows = append(eventRows, []string{ + stringutil.Truncate(evt.ServerID, 20), + stringutil.Truncate(evt.ToolName, 25), + evt.Reason, + message, + repo, + }) + } + fmt.Fprint(os.Stderr, console.RenderTable(console.TableConfig{ + Title: "Guard Policy Events", + Headers: []string{"Server", "Tool", "Reason", "Message", "Repository"}, + Rows: eventRows, + })) + } } // renderFirewallAnalysis renders firewall analysis with summary and domain breakdown diff --git a/pkg/cli/gateway_logs.go b/pkg/cli/gateway_logs.go index 7211187f5fb..3bab3f8a0e9 100644 --- a/pkg/cli/gateway_logs.go +++ b/pkg/cli/gateway_logs.go @@ -76,15 +76,67 @@ type DifcFilteredEvent struct { Number string `json:"number,omitempty"` } +// Guard policy error codes from MCP Gateway. +// These JSON-RPC error codes indicate guard policy enforcement decisions. +const ( + guardPolicyErrorCodeAccessDenied = -32001 // General access denied + guardPolicyErrorCodeRepoNotAllowed = -32002 // Repository not in allowlist (repos) + guardPolicyErrorCodeInsufficientPerms = -32003 // Insufficient permissions (roles) + guardPolicyErrorCodePrivateRepoDenied = -32004 // Private repository access denied + guardPolicyErrorCodeBlockedUser = -32005 // Content from blocked user + guardPolicyErrorCodeIntegrityBelowMin = -32006 // Content integrity below minimum threshold (min-integrity) +) + +// GuardPolicyEvent represents a guard policy enforcement decision from the MCP Gateway. +// These events are extracted from JSON-RPC error responses with specific error codes +// (-32001 to -32006) in rpc-messages.jsonl. +type GuardPolicyEvent struct { + Timestamp string `json:"timestamp"` + ServerID string `json:"server_id"` + ToolName string `json:"tool_name"` + ErrorCode int `json:"error_code"` + Reason string `json:"reason"` // e.g., "repository_not_allowed", "min_integrity" + Message string `json:"message"` // Error message from JSON-RPC response + Details string `json:"details,omitempty"` // Additional details from error data + Repository string `json:"repository,omitempty"` // Repository involved (for repo scope blocks) +} + +// isGuardPolicyErrorCode returns true if the JSON-RPC error code indicates a +// guard policy enforcement decision. +func isGuardPolicyErrorCode(code int) bool { + return code >= guardPolicyErrorCodeIntegrityBelowMin && code <= guardPolicyErrorCodeAccessDenied +} + +// guardPolicyReasonFromCode returns a human-readable reason string for a guard policy error code. +func guardPolicyReasonFromCode(code int) string { + switch code { + case guardPolicyErrorCodeAccessDenied: + return "access_denied" + case guardPolicyErrorCodeRepoNotAllowed: + return "repo_not_allowed" + case guardPolicyErrorCodeInsufficientPerms: + return "insufficient_permissions" + case guardPolicyErrorCodePrivateRepoDenied: + return "private_repo_denied" + case guardPolicyErrorCodeBlockedUser: + return "blocked_user" + case guardPolicyErrorCodeIntegrityBelowMin: + return "integrity_below_minimum" + default: + return "unknown" + } +} + // GatewayServerMetrics represents usage metrics for a single MCP server type GatewayServerMetrics struct { - ServerName string - RequestCount int - ToolCallCount int - TotalDuration float64 // in milliseconds - ErrorCount int - FilteredCount int // number of DIFC_FILTERED events for this server - Tools map[string]*GatewayToolMetrics + ServerName string + RequestCount int + ToolCallCount int + TotalDuration float64 // in milliseconds + ErrorCount int + FilteredCount int // number of DIFC_FILTERED events for this server + GuardPolicyBlocked int // number of tool calls blocked by guard policies for this server + Tools map[string]*GatewayToolMetrics } // GatewayToolMetrics represents usage metrics for a specific tool @@ -102,15 +154,17 @@ type GatewayToolMetrics struct { // GatewayMetrics represents aggregated metrics from gateway logs type GatewayMetrics struct { - TotalRequests int - TotalToolCalls int - TotalErrors int - TotalFiltered int // number of DIFC_FILTERED events - Servers map[string]*GatewayServerMetrics - FilteredEvents []DifcFilteredEvent - StartTime time.Time - EndTime time.Time - TotalDuration float64 // in milliseconds + TotalRequests int + TotalToolCalls int + TotalErrors int + TotalFiltered int // number of DIFC_FILTERED events + TotalGuardBlocked int // number of tool calls blocked by guard policies + Servers map[string]*GatewayServerMetrics + FilteredEvents []DifcFilteredEvent + GuardPolicyEvents []GuardPolicyEvent + StartTime time.Time + EndTime time.Time + TotalDuration float64 // in milliseconds } // RPCMessageEntry represents a single entry from rpc-messages.jsonl. @@ -154,8 +208,17 @@ type rpcResponsePayload struct { // rpcError represents a JSON-RPC error object. type rpcError struct { - Code int `json:"code"` - Message string `json:"message"` + Code int `json:"code"` + Message string `json:"message"` + Data *rpcErrorData `json:"data,omitempty"` +} + +// rpcErrorData represents the optional data field in a JSON-RPC error, used by +// guard policy enforcement to communicate the reason and context for a denial. +type rpcErrorData struct { + Reason string `json:"reason,omitempty"` + Repository string `json:"repository,omitempty"` + Details string `json:"details,omitempty"` } // rpcPendingRequest tracks an in-flight tool call for duration calculation. @@ -287,11 +350,45 @@ func parseRPCMessages(logPath string, verbose bool) (*GatewayMetrics, error) { continue } - // Track errors + // Track errors and detect guard policy blocks if resp.Error != nil { metrics.TotalErrors++ server := getOrCreateServer(metrics, entry.ServerID) server.ErrorCount++ + + // Detect guard policy enforcement errors + if isGuardPolicyErrorCode(resp.Error.Code) { + metrics.TotalGuardBlocked++ + server.GuardPolicyBlocked++ + + // Determine tool name from pending request if available + toolName := "" + if resp.ID != nil { + key := fmt.Sprintf("%s/%v", entry.ServerID, resp.ID) + if pending, ok := pendingRequests[key]; ok { + toolName = pending.ToolName + } + } + + reason := guardPolicyReasonFromCode(resp.Error.Code) + if resp.Error.Data != nil && resp.Error.Data.Reason != "" { + reason = resp.Error.Data.Reason + } + + evt := GuardPolicyEvent{ + Timestamp: entry.Timestamp, + ServerID: entry.ServerID, + ToolName: toolName, + ErrorCode: resp.Error.Code, + Reason: reason, + Message: resp.Error.Message, + } + if resp.Error.Data != nil { + evt.Details = resp.Error.Data.Details + evt.Repository = resp.Error.Data.Repository + } + metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, evt) + } } // Calculate duration by matching with pending request @@ -476,6 +573,28 @@ func processGatewayLogEntry(entry *GatewayLogEntry, metrics *GatewayMetrics, ver return } + // Handle GUARD_POLICY_BLOCKED events from gateway.jsonl + if entry.Type == "GUARD_POLICY_BLOCKED" { + metrics.TotalGuardBlocked++ + serverKey := entry.ServerID + if serverKey == "" { + serverKey = entry.ServerName + } + if serverKey != "" { + server := getOrCreateServer(metrics, serverKey) + server.GuardPolicyBlocked++ + } + metrics.GuardPolicyEvents = append(metrics.GuardPolicyEvents, GuardPolicyEvent{ + Timestamp: entry.Timestamp, + ServerID: serverKey, + ToolName: entry.ToolName, + Reason: entry.Reason, + Message: entry.Message, + Details: entry.Description, + }) + return + } + // Track errors if entry.Status == "error" || entry.Error != "" { metrics.TotalErrors++ @@ -595,6 +714,9 @@ func renderGatewayMetricsTable(metrics *GatewayMetrics, verbose bool) string { if metrics.TotalFiltered > 0 { fmt.Fprintf(&output, "Total DIFC Filtered: %d\n", metrics.TotalFiltered) } + if metrics.TotalGuardBlocked > 0 { + fmt.Fprintf(&output, "Total Guard Policy Blocked: %d\n", metrics.TotalGuardBlocked) + } fmt.Fprintf(&output, "Servers: %d\n", len(metrics.Servers)) if !metrics.StartTime.IsZero() && !metrics.EndTime.IsZero() { @@ -610,6 +732,7 @@ func renderGatewayMetricsTable(metrics *GatewayMetrics, verbose bool) string { serverNames := getSortedServerNames(metrics) hasFiltered := metrics.TotalFiltered > 0 + hasGuardPolicy := metrics.TotalGuardBlocked > 0 serverRows := make([][]string, 0, len(serverNames)) for _, serverName := range serverNames { server := metrics.Servers[serverName] @@ -627,6 +750,9 @@ func renderGatewayMetricsTable(metrics *GatewayMetrics, verbose bool) string { if hasFiltered { row = append(row, strconv.Itoa(server.FilteredCount)) } + if hasGuardPolicy { + row = append(row, strconv.Itoa(server.GuardPolicyBlocked)) + } serverRows = append(serverRows, row) } @@ -634,6 +760,9 @@ func renderGatewayMetricsTable(metrics *GatewayMetrics, verbose bool) string { if hasFiltered { headers = append(headers, "Filtered") } + if hasGuardPolicy { + headers = append(headers, "Guard Blocked") + } output.WriteString(console.RenderTable(console.TableConfig{ Title: "Server Usage", Headers: headers, @@ -664,6 +793,34 @@ func renderGatewayMetricsTable(metrics *GatewayMetrics, verbose bool) string { })) } + // Guard policy events table + if len(metrics.GuardPolicyEvents) > 0 { + output.WriteString("\n") + guardRows := make([][]string, 0, len(metrics.GuardPolicyEvents)) + for _, gpe := range metrics.GuardPolicyEvents { + message := gpe.Message + if len(message) > 60 { + message = message[:57] + "..." + } + repo := gpe.Repository + if repo == "" { + repo = "-" + } + guardRows = append(guardRows, []string{ + gpe.ServerID, + gpe.ToolName, + gpe.Reason, + message, + repo, + }) + } + output.WriteString(console.RenderTable(console.TableConfig{ + Title: "Guard Policy Blocked Events", + Headers: []string{"Server", "Tool", "Reason", "Message", "Repository"}, + Rows: guardRows, + })) + } + // Tool metrics table (if verbose) if verbose { output.WriteString("\n") @@ -880,6 +1037,11 @@ func extractMCPToolUsageData(logDir string, verbose bool) (*MCPToolUsageData, er FilteredEvents: gatewayMetrics.FilteredEvents, } + // Build guard policy summary if there are guard policy events + if len(gatewayMetrics.GuardPolicyEvents) > 0 { + mcpData.GuardPolicySummary = buildGuardPolicySummary(gatewayMetrics) + } + // Read the log file again to get individual tool call records. // Prefer gateway.jsonl; fall back to rpc-messages.jsonl when not available. gatewayLogPath := filepath.Join(logDir, "gateway.jsonl") @@ -1040,6 +1202,46 @@ func extractMCPToolUsageData(logDir string, verbose bool) (*MCPToolUsageData, er return mcpData, nil } +// buildGuardPolicySummary creates a GuardPolicySummary from GatewayMetrics. +func buildGuardPolicySummary(metrics *GatewayMetrics) *GuardPolicySummary { + summary := &GuardPolicySummary{ + TotalBlocked: metrics.TotalGuardBlocked, + Events: metrics.GuardPolicyEvents, + BlockedToolCounts: make(map[string]int), + BlockedServerCounts: make(map[string]int), + } + + for _, evt := range metrics.GuardPolicyEvents { + // Categorize by error code + switch evt.ErrorCode { + case guardPolicyErrorCodeIntegrityBelowMin: + summary.IntegrityBlocked++ + case guardPolicyErrorCodeRepoNotAllowed: + summary.RepoScopeBlocked++ + case guardPolicyErrorCodeAccessDenied: + summary.AccessDenied++ + case guardPolicyErrorCodeBlockedUser: + summary.BlockedUserDenied++ + case guardPolicyErrorCodeInsufficientPerms: + summary.PermissionDenied++ + case guardPolicyErrorCodePrivateRepoDenied: + summary.PrivateRepoDenied++ + } + + // Track per-tool blocked counts + if evt.ToolName != "" { + summary.BlockedToolCounts[evt.ToolName]++ + } + + // Track per-server blocked counts + if evt.ServerID != "" { + summary.BlockedServerCounts[evt.ServerID]++ + } + } + + return summary +} + // displayAggregatedGatewayMetrics aggregates and displays gateway metrics across all processed runs func displayAggregatedGatewayMetrics(processedRuns []ProcessedRun, outputDir string, verbose bool) { // Aggregate gateway metrics from all runs @@ -1068,8 +1270,10 @@ func displayAggregatedGatewayMetrics(processedRuns []ProcessedRun, outputDir str aggregated.TotalToolCalls += runMetrics.TotalToolCalls aggregated.TotalErrors += runMetrics.TotalErrors aggregated.TotalFiltered += runMetrics.TotalFiltered + aggregated.TotalGuardBlocked += runMetrics.TotalGuardBlocked aggregated.TotalDuration += runMetrics.TotalDuration aggregated.FilteredEvents = append(aggregated.FilteredEvents, runMetrics.FilteredEvents...) + aggregated.GuardPolicyEvents = append(aggregated.GuardPolicyEvents, runMetrics.GuardPolicyEvents...) // Merge server metrics for serverName, serverMetrics := range runMetrics.Servers { @@ -1079,6 +1283,7 @@ func displayAggregatedGatewayMetrics(processedRuns []ProcessedRun, outputDir str aggServer.TotalDuration += serverMetrics.TotalDuration aggServer.ErrorCount += serverMetrics.ErrorCount aggServer.FilteredCount += serverMetrics.FilteredCount + aggServer.GuardPolicyBlocked += serverMetrics.GuardPolicyBlocked // Merge tool metrics for toolName, toolMetrics := range serverMetrics.Tools { diff --git a/pkg/cli/gateway_logs_test.go b/pkg/cli/gateway_logs_test.go index c9fb235c707..fd059219481 100644 --- a/pkg/cli/gateway_logs_test.go +++ b/pkg/cli/gateway_logs_test.go @@ -795,3 +795,215 @@ func TestBuildToolCallsFromRPCMessagesNullID(t *testing.T) { assert.True(t, toolNames["list_issues"], "should include list_issues") assert.True(t, toolNames["issue_read"], "should include issue_read") } + +func TestParseRPCMessagesGuardPolicyErrors(t *testing.T) { + tmpDir := t.TempDir() + + // Test rpc-messages.jsonl with guard policy error responses: + // - A tools/call request followed by a -32006 (integrity below minimum) error response + // - A tools/call request followed by a -32002 (repository not allowed) error response + // - A tools/call request followed by a normal success response + content := `{"timestamp":"2024-01-12T10:00:00.000000000Z","direction":"OUT","type":"REQUEST","server_id":"github","payload":{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"pull_request_read","arguments":{}}}} +{"timestamp":"2024-01-12T10:00:00.100000000Z","direction":"IN","type":"RESPONSE","server_id":"github","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32006,"message":"Content integrity below minimum threshold","data":{"reason":"integrity_below_minimum","details":"Content integrity 'unapproved' is below minimum 'approved'"}}}} +{"timestamp":"2024-01-12T10:00:01.000000000Z","direction":"OUT","type":"REQUEST","server_id":"github","payload":{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_file_contents","arguments":{}}}} +{"timestamp":"2024-01-12T10:00:01.100000000Z","direction":"IN","type":"RESPONSE","server_id":"github","payload":{"jsonrpc":"2.0","id":2,"error":{"code":-32002,"message":"Repository not in allowlist","data":{"reason":"repository_not_allowed","repository":"owner/private-repo","details":"Repository 'owner/private-repo' does not match any repos patterns"}}}} +{"timestamp":"2024-01-12T10:00:02.000000000Z","direction":"OUT","type":"REQUEST","server_id":"github","payload":{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"list_issues","arguments":{}}}} +{"timestamp":"2024-01-12T10:00:02.200000000Z","direction":"IN","type":"RESPONSE","server_id":"github","payload":{"jsonrpc":"2.0","id":3,"result":{"content":[]}}} +` + logPath := filepath.Join(tmpDir, "rpc-messages.jsonl") + require.NoError(t, os.WriteFile(logPath, []byte(content), 0644)) + + metrics, err := parseRPCMessages(logPath, false) + require.NoError(t, err) + require.NotNil(t, metrics) + + // Should count 3 requests total + assert.Equal(t, 3, metrics.TotalRequests, "should count 3 requests") + assert.Equal(t, 3, metrics.TotalToolCalls, "should count 3 tool calls") + + // 2 errors total (guard policy errors are also errors) + assert.Equal(t, 2, metrics.TotalErrors, "should count 2 errors") + + // 2 guard policy blocks + assert.Equal(t, 2, metrics.TotalGuardBlocked, "should count 2 guard policy blocks") + + // Check guard policy events + require.Len(t, metrics.GuardPolicyEvents, 2, "should have 2 guard policy events") + + // First event: integrity below minimum + evt1 := metrics.GuardPolicyEvents[0] + assert.Equal(t, "github", evt1.ServerID) + assert.Equal(t, "pull_request_read", evt1.ToolName) + assert.Equal(t, -32006, evt1.ErrorCode) + assert.Equal(t, "integrity_below_minimum", evt1.Reason) + assert.Equal(t, "Content integrity below minimum threshold", evt1.Message) + assert.Contains(t, evt1.Details, "below minimum") + + // Second event: repository not allowed + evt2 := metrics.GuardPolicyEvents[1] + assert.Equal(t, "github", evt2.ServerID) + assert.Equal(t, "get_file_contents", evt2.ToolName) + assert.Equal(t, -32002, evt2.ErrorCode) + assert.Equal(t, "repository_not_allowed", evt2.Reason) + assert.Equal(t, "owner/private-repo", evt2.Repository) + + // Server should have GuardPolicyBlocked = 2 + githubServer := metrics.Servers["github"] + require.NotNil(t, githubServer) + assert.Equal(t, 2, githubServer.GuardPolicyBlocked, "server should have 2 guard policy blocks") + assert.Equal(t, 2, githubServer.ErrorCount, "server should have 2 errors") +} + +func TestParseRPCMessagesGuardPolicyWithoutData(t *testing.T) { + tmpDir := t.TempDir() + + // Guard policy error without the optional data field + content := `{"timestamp":"2024-01-12T10:00:00.000000000Z","direction":"OUT","type":"REQUEST","server_id":"github","payload":{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"issue_read","arguments":{}}}} +{"timestamp":"2024-01-12T10:00:00.050000000Z","direction":"IN","type":"RESPONSE","server_id":"github","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32005,"message":"Content from blocked user"}}} +` + logPath := filepath.Join(tmpDir, "rpc-messages.jsonl") + require.NoError(t, os.WriteFile(logPath, []byte(content), 0644)) + + metrics, err := parseRPCMessages(logPath, false) + require.NoError(t, err) + require.NotNil(t, metrics) + + assert.Equal(t, 1, metrics.TotalGuardBlocked, "should count 1 guard policy block") + require.Len(t, metrics.GuardPolicyEvents, 1) + + evt := metrics.GuardPolicyEvents[0] + assert.Equal(t, -32005, evt.ErrorCode) + assert.Equal(t, "blocked_user", evt.Reason, "should use default reason from error code") + assert.Equal(t, "Content from blocked user", evt.Message) + assert.Empty(t, evt.Details, "details should be empty without data field") + assert.Empty(t, evt.Repository, "repository should be empty without data field") +} + +func TestIsGuardPolicyErrorCode(t *testing.T) { + tests := []struct { + code int + expected bool + }{ + {-32001, true}, // Access denied + {-32002, true}, // Repo not allowed + {-32003, true}, // Insufficient permissions + {-32004, true}, // Private repo denied + {-32005, true}, // Blocked user + {-32006, true}, // Integrity below minimum + {-32000, false}, // Regular JSON-RPC error + {-32007, false}, // Out of range + {0, false}, + {-1, false}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, isGuardPolicyErrorCode(tt.code), + "isGuardPolicyErrorCode(%d) should be %v", tt.code, tt.expected) + } +} + +func TestGuardPolicyReasonFromCode(t *testing.T) { + assert.Equal(t, "access_denied", guardPolicyReasonFromCode(-32001)) + assert.Equal(t, "repo_not_allowed", guardPolicyReasonFromCode(-32002)) + assert.Equal(t, "insufficient_permissions", guardPolicyReasonFromCode(-32003)) + assert.Equal(t, "private_repo_denied", guardPolicyReasonFromCode(-32004)) + assert.Equal(t, "blocked_user", guardPolicyReasonFromCode(-32005)) + assert.Equal(t, "integrity_below_minimum", guardPolicyReasonFromCode(-32006)) + assert.Equal(t, "unknown", guardPolicyReasonFromCode(-32000)) +} + +func TestProcessGatewayLogEntryGuardPolicyBlocked(t *testing.T) { + metrics := &GatewayMetrics{ + Servers: make(map[string]*GatewayServerMetrics), + } + + entry := &GatewayLogEntry{ + Timestamp: "2024-01-12T10:00:00Z", + Type: "GUARD_POLICY_BLOCKED", + ServerID: "github", + ToolName: "pull_request_read", + Reason: "integrity_below_minimum", + Message: "Content integrity below minimum threshold", + Description: "Content integrity 'unapproved' is below minimum 'approved'", + } + + processGatewayLogEntry(entry, metrics, false) + + assert.Equal(t, 0, metrics.TotalRequests, "GUARD_POLICY_BLOCKED should not increment TotalRequests") + assert.Equal(t, 1, metrics.TotalGuardBlocked, "should increment TotalGuardBlocked") + require.Len(t, metrics.GuardPolicyEvents, 1, "should record one guard policy event") + + evt := metrics.GuardPolicyEvents[0] + assert.Equal(t, "github", evt.ServerID) + assert.Equal(t, "pull_request_read", evt.ToolName) + assert.Equal(t, "integrity_below_minimum", evt.Reason) + assert.Equal(t, "Content integrity below minimum threshold", evt.Message) + assert.Equal(t, "Content integrity 'unapproved' is below minimum 'approved'", evt.Details) + + githubServer := metrics.Servers["github"] + require.NotNil(t, githubServer) + assert.Equal(t, 1, githubServer.GuardPolicyBlocked) +} + +func TestBuildGuardPolicySummary(t *testing.T) { + metrics := &GatewayMetrics{ + TotalGuardBlocked: 5, + GuardPolicyEvents: []GuardPolicyEvent{ + // Two identical pull_request_read events to verify per-tool count aggregation + {ServerID: "github", ToolName: "pull_request_read", ErrorCode: guardPolicyErrorCodeIntegrityBelowMin, Reason: "integrity_below_minimum"}, + {ServerID: "github", ToolName: "pull_request_read", ErrorCode: guardPolicyErrorCodeIntegrityBelowMin, Reason: "integrity_below_minimum"}, + {ServerID: "github", ToolName: "get_file_contents", ErrorCode: guardPolicyErrorCodeRepoNotAllowed, Reason: "repo_not_allowed", Repository: "owner/repo"}, + {ServerID: "github", ToolName: "issue_read", ErrorCode: guardPolicyErrorCodeBlockedUser, Reason: "blocked_user"}, + {ServerID: "other-server", ToolName: "list_issues", ErrorCode: guardPolicyErrorCodeAccessDenied, Reason: "access_denied"}, + }, + Servers: make(map[string]*GatewayServerMetrics), + } + + summary := buildGuardPolicySummary(metrics) + require.NotNil(t, summary) + + assert.Equal(t, 5, summary.TotalBlocked) + assert.Equal(t, 2, summary.IntegrityBlocked, "should have 2 integrity blocks") + assert.Equal(t, 1, summary.RepoScopeBlocked, "should have 1 repo scope block") + assert.Equal(t, 1, summary.BlockedUserDenied, "should have 1 blocked user") + assert.Equal(t, 1, summary.AccessDenied, "should have 1 access denied") + assert.Equal(t, 0, summary.PermissionDenied, "should have 0 permission denied") + assert.Equal(t, 0, summary.PrivateRepoDenied, "should have 0 private repo denied") + + // Check per-tool blocked counts + assert.Equal(t, 2, summary.BlockedToolCounts["pull_request_read"]) + assert.Equal(t, 1, summary.BlockedToolCounts["get_file_contents"]) + assert.Equal(t, 1, summary.BlockedToolCounts["issue_read"]) + assert.Equal(t, 1, summary.BlockedToolCounts["list_issues"]) + + // Check per-server blocked counts + assert.Equal(t, 4, summary.BlockedServerCounts["github"]) + assert.Equal(t, 1, summary.BlockedServerCounts["other-server"]) +} + +func TestExtractMCPToolUsageDataWithGuardPolicy(t *testing.T) { + tmpDir := t.TempDir() + + // Create rpc-messages.jsonl with guard policy errors + content := `{"timestamp":"2024-01-12T10:00:00.000000000Z","direction":"OUT","type":"REQUEST","server_id":"github","payload":{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"pull_request_read","arguments":{}}}} +{"timestamp":"2024-01-12T10:00:00.100000000Z","direction":"IN","type":"RESPONSE","server_id":"github","payload":{"jsonrpc":"2.0","id":1,"error":{"code":-32006,"message":"Integrity below minimum","data":{"reason":"integrity_below_minimum"}}}} +{"timestamp":"2024-01-12T10:00:01.000000000Z","direction":"OUT","type":"REQUEST","server_id":"github","payload":{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_issues","arguments":{}}}} +{"timestamp":"2024-01-12T10:00:01.200000000Z","direction":"IN","type":"RESPONSE","server_id":"github","payload":{"jsonrpc":"2.0","id":2,"result":{"content":[]}}} +` + // Create in mcp-logs subdirectory to test the fallback path + mcpLogsDir := filepath.Join(tmpDir, "mcp-logs") + require.NoError(t, os.MkdirAll(mcpLogsDir, 0755)) + logPath := filepath.Join(mcpLogsDir, "rpc-messages.jsonl") + require.NoError(t, os.WriteFile(logPath, []byte(content), 0644)) + + mcpData, err := extractMCPToolUsageData(tmpDir, false) + require.NoError(t, err) + require.NotNil(t, mcpData) + + // Guard policy summary should be populated + require.NotNil(t, mcpData.GuardPolicySummary, "guard policy summary should be populated") + assert.Equal(t, 1, mcpData.GuardPolicySummary.TotalBlocked) + assert.Equal(t, 1, mcpData.GuardPolicySummary.IntegrityBlocked) + require.Len(t, mcpData.GuardPolicySummary.Events, 1) + assert.Equal(t, "pull_request_read", mcpData.GuardPolicySummary.Events[0].ToolName) +}