diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 4e095f3c..98881d16 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -316,6 +316,34 @@ func TestAdditionalContext_SingleFinding_PreFilledCommand(t *testing.T) { } } +func TestAdditionalContext_EmitsProvenanceOptionalFlags(t *testing.T) { + findings := []grpcs.ScanDetail{ + {FileName: "billing.py", Line: 5, RuleID: 4059}, + } + ctx := additionalContext("billing.py", "cx", findings, "", "Claude", "sess-123") + want := ` --optional-flags "aiProvider=Claude;agent=Claude-cli;aiAgentSessionId=sess-123"` + if !strings.Contains(ctx, want) { + t.Errorf("expected provenance flags %q in ignore command, got %q", want, ctx) + } + // Empty agent → no provenance fragment (backward-compatible default). + if noAgent := additionalContext("billing.py", "cx", findings, "", "", ""); strings.Contains(noAgent, "--optional-flags") { + t.Errorf("expected no --optional-flags when agent is empty, got %q", noAgent) + } +} + +func TestAdditionalContext_FileNameWithPercent_NotMisformatted(t *testing.T) { + findings := []grpcs.ScanDetail{ + {FileName: "a%s.py", Line: 5, RuleID: 4059}, + } + ctx := additionalContext("a%s.py", "cx", findings, "", "Claude", "sess-1") + if strings.Contains(ctx, "%!s") || strings.Contains(ctx, "MISSING") { + t.Errorf("a %%-containing filename leaked a format verb into the output: %q", ctx) + } + if !strings.Contains(ctx, `"FileName":"a%s.py"`) { + t.Errorf("expected the literal filename in the ignore command, got %q", ctx) + } +} + func TestAdditionalContext_MultipleFindings_EachGetsCommand(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, @@ -397,6 +425,13 @@ func TestFormatFindings_RoutesCursorQuoting(t *testing.T) { if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data '`) { t.Fatalf("claude agent should get single-quoted suppress command, got %q", ctx) } + if runtime.GOOS == goosWindows && strings.Contains(ctx, `\"FileName\"`) { + t.Fatalf("claude agent should not use QuoteDataFlag Windows escaping, got %q", ctx) + } + _, ctx = formatFindings("a.py", findings, "", agentGemini, "sess-1") + if !strings.Contains(ctx, "ignore-vulnerability --scan-type asca --data "+ignore.QuoteDataFlag([]byte(`{"FileName":"a.py","Line":1,"RuleID":1}`))) { + t.Fatalf("gemini agent should get QuoteDataFlag suppress command, got %q", ctx) + } } func TestAdditionalContext_PinsIgnoredFilePathToWorkDir(t *testing.T) { @@ -640,3 +675,39 @@ func TestHighestSeverity_MixedValidAndInvalid(t *testing.T) { got := highestSeverity(findings) assert.Equal(t, "High", got) } + +func TestAdditionalContext_GeminiUsesGeminiSkillAndMCPTool(t *testing.T) { + ctx := additionalContext("main.py", "cx", nil, "", agentGemini, "") + if !strings.Contains(ctx, "/cx-security-asca") { + t.Errorf("expected Gemini skill path, got %q", ctx) + } + if !strings.Contains(ctx, "mcp_Checkmarx_codeRemediation") { + t.Errorf("expected Gemini MCP tool name, got %q", ctx) + } + if strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") { + t.Errorf("Claude MCP tool name should not appear for Gemini, got %q", ctx) + } +} + +func TestAdditionalContext_GeminiUsesQuoteDataFlag(t *testing.T) { + findings := []grpcs.ScanDetail{ + {FileName: "billing.py", Line: 5, RuleID: 4059}, + } + data := []byte(`{"FileName":"billing.py","Line":5,"RuleID":4059}`) + ctx := additionalContext("billing.py", "cx", findings, "", agentGemini, "") + want := "ignore-vulnerability --scan-type asca --data " + ignore.QuoteDataFlag(data) + if !strings.Contains(ctx, want) { + t.Errorf("expected Gemini suppress command %q, got %q", want, ctx) + } +} + +func TestAdditionalContext_OtherAgentsUseUnescapedData(t *testing.T) { + findings := []grpcs.ScanDetail{ + {FileName: "billing.py", Line: 5, RuleID: 4059}, + } + ctx := additionalContext("billing.py", "cx", findings, "", "Claude", "") + want := `ignore-vulnerability --scan-type asca --data '{"FileName":"billing.py","Line":5,"RuleID":4059}'` + if !strings.Contains(ctx, want) { + t.Errorf("expected other agents to use unescaped --data %q, got %q", want, ctx) + } +} diff --git a/internal/commands/agenthooks/guardrails/asca/delta.go b/internal/commands/agenthooks/guardrails/asca/delta.go index 45d9adca..ec96fb4e 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -21,6 +21,11 @@ import ( // --ignored-file-path, silently sending the suppression to the wrong file. const agentCursor = "Cursor" +// agentGemini identifies Gemini CLI. Its suppress commands run through PowerShell on +// Windows, which strips embedded double quotes from native-exe arguments, so Gemini +// uses ignore.QuoteDataFlag. Other non-Cursor agents keep the original single-quoted JSON. +const agentGemini = "Gemini" + // goosWindows is runtime.GOOS's value on Windows, factored out because the shell-quoting // checks below (and their tests) compare against it repeatedly. const goosWindows = "windows" @@ -162,8 +167,11 @@ func permissionDecisionReason(filePath, summary string) string { } // additionalContext is injected into the agent's context window to drive remediation. -// Contains all action instructions — not shown directly to the user. -// Used for Claude, Copilot, and other non-Cursor agents. +// Contains all action instructions — not shown directly to the user on Claude; on Gemini +// BeforeTool it is folded into the hook deny reason by the ast-cx-hooks gemini adapter. +// Used for Claude, Copilot, Gemini, and other non-Cursor agents. Gemini suppress commands +// use ignore.QuoteDataFlag (PowerShell-safe quoting on Windows); other agents keep the +// original single-quoted JSON payload. func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string { provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder @@ -174,17 +182,22 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w RuleID: f.RuleID, }) ignoreFlag := ignoredFilePathFlag(workDir) - fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + if agent == agentGemini { + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data %s%s%s\n", cxBinary, ignore.QuoteDataFlag(data), ignoreFlag, provenance) + } else { + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + } } + skill, mcpTool := remediationTargets(agent) return fmt.Sprintf( "ASCA detected vulnerabilities in %s. "+ "Do not bypass the scan by writing the same content through another tool or shell command. "+ "ANALYZE each finding to determine if it is a real vulnerability or a false positive "+ "caused by ASCA's single-file scope (it cannot see imported modules or helper files). "+ - "For each real finding, invoke the cx-devassist:cx-devassist-asca skill — "+ + "For each real finding, invoke the %s skill — "+ "the findings are already in context so it will skip the scan and go directly to "+ "MCP-driven remediation; the skill also handles MCP unavailability and self-recovery. "+ - "If that skill is not available in this session, call mcp__Checkmarx__codeRemediation directly:\n"+ + "If that skill is not available in this session, call %s directly:\n"+ " {\n"+ " \"language\": \"[auto-detected programming language]\",\n"+ " \"metadata\": {\n"+ @@ -196,7 +209,7 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w " }\n"+ "Use the remediation guidance returned by the tool to fix the vulnerability, then retry the write. "+ "If a finding is a confirmed false positive, suppress it by running the corresponding command below, then retry the write:\n%s", - filePath, suppressCmds.String(), + filePath, skill, mcpTool, suppressCmds.String(), ) } @@ -251,3 +264,14 @@ func cursorAdditionalContext(filePath, cxBinary string, findings []grpcs.ScanDet filePath, tool, suppressCmds.String(), ) } + +// remediationTargets returns the skill invocation and MCP tool name for the agent. +// Gemini CLI's skills are invoked as a bare "/name" slash command and its MCP tool +// names use single underscores (no "__"), unlike Claude Code's "plugin:skill" and +// "mcp__Server__tool" conventions. +func remediationTargets(agent string) (skill, mcpTool string) { + if agent == agentGemini { + return "/cx-security-asca", "mcp_Checkmarx_codeRemediation" + } + return "cx-devassist:cx-devassist-asca", "mcp__Checkmarx__codeRemediation" +} diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 883d430a..0387b8fc 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -63,14 +63,15 @@ func findingsSummary(filePath string, findings []iacrealtime.IacRealtimeResult) // formatFindings builds the two verdict fields delivered to the agent. // Cursor receives cursorAdditionalContext (folded into agent_message); other agents -// receive the original additionalContext (e.g. Claude additionalContext). +// (including Gemini) receive additionalContext, with MCP tool names adjusted per agent. func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) (reason, context string) { summary := findingsSummary(filePath, findings) reason = permissionDecisionReason(filePath, summary) - if agent == agenthooks.AgentCursor { + switch agent { + case agenthooks.AgentCursor: context = cursorAdditionalContext(filePath, findings) - } else { - context = additionalContext(filePath, findings) + default: + context = additionalContext(filePath, findings, agent) } return reason, context } @@ -124,8 +125,8 @@ func isDockerImageFileByName(filePath string) bool { // KICS is a deterministic IaC rule engine: unlike ASCA, its findings are not caused by // missing cross-file context, so the agent is NOT given discretion to treat findings as // false positives. Every new finding must be fixed. -// Used for Claude, Copilot, and other non-Cursor agents. -func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult) string { +// Used for Claude, Gemini, Copilot, and other non-Cursor agents. +func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) string { var findingList strings.Builder for _, f := range findings { line := 0 @@ -135,6 +136,10 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult fmt.Fprintf(&findingList, " - line %d [%s] %s: %s\n", line, f.Severity, f.Title, f.Description) } + imageTool, codeTool := "mcp__Checkmarx__imageRemediation", "mcp__Checkmarx__codeRemediation" + if agent == agenthooks.AgentGemini { + imageTool, codeTool = "mcp_Checkmarx_imageRemediation", "mcp_Checkmarx_codeRemediation" + } return fmt.Sprintf( "KICS detected IaC misconfigurations in %s. These are deterministic rule matches "+ "against the configuration itself — they are NOT false positives caused by code "+ @@ -144,7 +149,7 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult "Fix every finding below, then retry the write:\n"+ "%s"+ "%s", - filePath, findingList.String(), remediationInstructions(filePath, findings), + filePath, findingList.String(), remediationInstructions(filePath, findings, imageTool, codeTool), ) } @@ -153,30 +158,30 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult // through imageRemediation (base image CVEs, safer tags, hardening). All other // KICS-supported files (Terraform, Kubernetes manifests, CloudFormation, etc.) are // generic IaC misconfigurations and go through codeRemediation. -func remediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult) string { +func remediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult, imageTool, codeTool string) string { if isDockerImageFinding(filePath, findings) { - return "For each finding, call the mcp__Checkmarx__imageRemediation tool with:\n" + - " {\n" + - " \"imageName\": \"[image name from the finding/file, without the tag]\",\n" + - " \"imageTag\": \"[image tag from the finding/file, e.g. latest]\",\n" + - " \"fileType\": \"[Dockerfile or DockerCompose, matching this file]\"\n" + - " }\n" + - "Apply the remediation guidance the tool returns (safer base image, pinned digest, " + - "hardening steps), then retry the write." + return fmt.Sprintf("For each finding, call the %s tool with:\n"+ + " {\n"+ + " \"imageName\": \"[image name from the finding/file, without the tag]\",\n"+ + " \"imageTag\": \"[image tag from the finding/file, e.g. latest]\",\n"+ + " \"fileType\": \"[Dockerfile or DockerCompose, matching this file]\"\n"+ + " }\n"+ + "Apply the remediation guidance the tool returns (safer base image, pinned digest, "+ + "hardening steps), then retry the write.", imageTool) } - return "For each finding, call the mcp__Checkmarx__codeRemediation tool with:\n" + - " {\n" + - " \"type\": \"iac\",\n" + - " \"metadata\": {\n" + - " \"title\": \"[Title from finding]\",\n" + - " \"description\": \"[Description from finding]\",\n" + - " \"remediationAdvice\": \"[how to harden this configuration]\"\n" + - " }\n" + - " }\n" + - "Apply the remediation guidance the tool returns, then retry the write. If a fix " + - "genuinely requires resources outside this file (for example a separate KMS key or " + - "a centrally-managed policy), add them as part of your change rather than skipping " + - "the finding." + return fmt.Sprintf("For each finding, call the %s tool with:\n"+ + " {\n"+ + " \"type\": \"iac\",\n"+ + " \"metadata\": {\n"+ + " \"title\": \"[Title from finding]\",\n"+ + " \"description\": \"[Description from finding]\",\n"+ + " \"remediationAdvice\": \"[how to harden this configuration]\"\n"+ + " }\n"+ + " }\n"+ + "Apply the remediation guidance the tool returns, then retry the write. If a fix "+ + "genuinely requires resources outside this file (for example a separate KMS key or "+ + "a centrally-managed policy), add them as part of your change rather than skipping "+ + "the finding.", codeTool) } func cursorRemediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult) string { diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index a8d56766..a8f1f7e5 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -251,3 +251,32 @@ func TestFormatFindings_RoutesCursorContext(t *testing.T) { t.Fatalf("claude KICS context should reference codeRemediation, got %q", ctx) } } + +func TestAdditionalContext_GeminiUsesUnderscoreMCPNames(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{ + iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"), + } + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentGemini) + if !strings.Contains(ctx, "mcp_Checkmarx_imageRemediation") { + t.Errorf("Gemini context should use underscore MCP name, got: %q", ctx) + } + if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { + t.Errorf("Gemini context should not use double-underscore MCP name, got: %q", ctx) + } +} + +func TestAdditionalContext_ClaudeDoesNotOfferSuppress(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + if strings.Contains(ctx, "ignore-vulnerability") { + t.Errorf("Claude context should not include suppress commands, got %q", ctx) + } +} + +func TestCursorAdditionalContext_DoesNotOfferSuppress(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + ctx := cursorAdditionalContext("/project/Dockerfile", findings) + if strings.Contains(ctx, "ignore-vulnerability") { + t.Errorf("cursor context should not include suppress commands, got %q", ctx) + } +} diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index 3ba4417a..0c862d1d 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -22,6 +22,11 @@ import ( // --ignored-file-path, silently sending the suppression to the wrong file. const agentCursor = "Cursor" +// agentGemini identifies Gemini CLI. Its suppress commands run through PowerShell on +// Windows, which strips embedded double quotes from native-exe arguments, so Gemini +// uses ignore.QuoteDataFlag. Other non-Cursor agents keep the original single-quoted JSON. +const agentGemini = "Gemini" + // goosWindows is runtime.GOOS's value on Windows, factored out because the shell-quoting // checks below (and their tests) compare against it repeatedly. const goosWindows = "windows" @@ -89,7 +94,9 @@ func remediationNote(subject, goal, agent string) string { // vulnerableRemediationNote returns the action steps for vulnerable packages. // When no safe version is found, the agent runs the per-package ignore command -// and informs the user. +// and informs the user. Gemini suppress commands use ignore.QuoteDataFlag +// (PowerShell-safe quoting on Windows); other non-Cursor agents keep the +// original single-quoted JSON payload. func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID string) string { cxBinary := cxExecutable() provenance := optionalFlagsFragment(agent, sessionID) @@ -106,7 +113,11 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, se suppressCmds.WriteString("\n") } else { ignoreFlag := ignoredFilePathFlag(workDir) - fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + if agent == agentGemini { + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data %s%s%s\n", cxBinary, ignore.QuoteDataFlag(data), ignoreFlag, provenance) + } else { + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + } } } if agent == agentCursor { diff --git a/internal/commands/agenthooks/sca/sca_test.go b/internal/commands/agenthooks/sca/sca_test.go index 39fc8d6e..033d0fb6 100644 --- a/internal/commands/agenthooks/sca/sca_test.go +++ b/internal/commands/agenthooks/sca/sca_test.go @@ -271,6 +271,32 @@ func TestDenyVulnerable_CursorUsesPluginMCPToolAndStopParsingOnWindows(t *testin } } +func TestDenyVulnerable_GeminiUsesQuoteDataFlag(t *testing.T) { + pkgs := []ossrealtime.OssPackage{ + {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, + } + data := []byte(`[{"PackageManager":"npm","PackageName":"axios","PackageVersion":"0.21.0"}]`) + _, remediation := DenyVulnerable(pkgs, "", agentGemini, "sess-1") + want := "ignore-vulnerability --scan-type sca --data " + ignore.QuoteDataFlag(data) + if !strings.Contains(remediation, want) { + t.Errorf("expected Gemini suppress command %q, got %q", want, remediation) + } +} + +func TestDenyVulnerable_OtherAgentsUseUnescapedData(t *testing.T) { + pkgs := []ossrealtime.OssPackage{ + {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, + } + _, remediation := DenyVulnerable(pkgs, "", "Claude", "sess-1") + want := `ignore-vulnerability --scan-type sca --data '[{"PackageManager":"npm","PackageName":"axios","PackageVersion":"0.21.0"}]'` + if !strings.Contains(remediation, want) { + t.Errorf("expected other agents to use unescaped --data %q, got %q", want, remediation) + } + if runtime.GOOS == goosWindows && strings.Contains(remediation, `\"PackageName\"`) { + t.Errorf("claude agent should not use QuoteDataFlag Windows escaping, got %q", remediation) + } +} + func TestDenyVulnerable_MultiplePackages_EachGetsIgnoreCommand(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageManager: "npm", PackageName: "lodash", PackageVersion: "4.17.0"}, diff --git a/internal/services/realtimeengine/ignore/shellquote.go b/internal/services/realtimeengine/ignore/shellquote.go new file mode 100644 index 00000000..c9af319f --- /dev/null +++ b/internal/services/realtimeengine/ignore/shellquote.go @@ -0,0 +1,22 @@ +package ignore + +import ( + "runtime" + "strings" +) + +// goosWindows is runtime.GOOS's value on Windows, factored out because the shell-quoting +// check below (and its test) compare against it repeatedly. +const goosWindows = "windows" + +// QuoteDataFlag formats finding JSON for a shell --data argument. +// On Windows, PowerShell strips embedded double quotes when invoking native +// executables, yielding invalid JSON like {FileName:...}; inner quotes must be +// backslash-escaped inside a single-quoted argument. +func QuoteDataFlag(data []byte) string { + s := string(data) + if runtime.GOOS == goosWindows { + return "'" + strings.ReplaceAll(s, `"`, `\"`) + "'" + } + return "'" + s + "'" +} diff --git a/internal/services/realtimeengine/ignore/shellquote_test.go b/internal/services/realtimeengine/ignore/shellquote_test.go new file mode 100644 index 00000000..57462a79 --- /dev/null +++ b/internal/services/realtimeengine/ignore/shellquote_test.go @@ -0,0 +1,28 @@ +package ignore + +import ( + "runtime" + "testing" +) + +func TestQuoteDataFlag_Unix(t *testing.T) { + if runtime.GOOS == goosWindows { + t.Skip("unix quoting on windows host") + } + got := QuoteDataFlag([]byte(`{"FileName":"a.py","Line":1,"RuleID":2}`)) + want := `'{"FileName":"a.py","Line":1,"RuleID":2}'` + if got != want { + t.Fatalf("QuoteDataFlag() = %q, want %q", got, want) + } +} + +func TestQuoteDataFlag_Windows(t *testing.T) { + if runtime.GOOS != goosWindows { + t.Skip("windows quoting") + } + got := QuoteDataFlag([]byte(`{"FileName":"a.py","Line":1,"RuleID":2}`)) + want := `'{\"FileName\":\"a.py\",\"Line\":1,\"RuleID\":2}'` + if got != want { + t.Fatalf("QuoteDataFlag() = %q, want %q", got, want) + } +}