diff --git a/go.mod b/go.mod index 27a6d5f8..4592cd80 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkmarx/ast-cli go 1.26.6 require ( - github.com/Checkmarx/ast-cx-hooks v1.0.6 + github.com/Checkmarx/ast-cx-hooks v1.0.8 github.com/Checkmarx/containers-resolver v1.0.34 github.com/Checkmarx/containers-types v1.0.9 github.com/Checkmarx/gen-ai-prompts v0.0.0-20240807143411-708ceec12b63 diff --git a/go.sum b/go.sum index 34b4cffe..aab3b450 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Checkmarx/ast-cx-hooks v1.0.6 h1:8/Kcl9V0XKeY1vgTKJR6eIfXXoa4c9DgUOBuY1Ms268= -github.com/Checkmarx/ast-cx-hooks v1.0.6/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= +github.com/Checkmarx/ast-cx-hooks v1.0.8 h1:Mjx/WNs7la80rKy5lm5WvFz5F67h05XIHOH7lb80IXs= +github.com/Checkmarx/ast-cx-hooks v1.0.8/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= github.com/Checkmarx/containers-images-extractor v1.0.22 h1:kJZgwk28LwJZ7Xky+kzwL+JSZOlpwrGsZQhhz4L2t6s= github.com/Checkmarx/containers-images-extractor v1.0.22/go.mod h1:HyzVb8TtTDf56hGlSakalPXtzjJ6VhTYe9fmAcOS+V8= github.com/Checkmarx/containers-resolver v1.0.34 h1:KULN8s8xb1tQtdH4yzHVdwN8GyLqtPCAkFWra10k7V0= diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 4e095f3c..f22169bc 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -305,17 +305,55 @@ func TestAdditionalContext_SingleFinding_PreFilledCommand(t *testing.T) { if !strings.Contains(ctx, "ignore-vulnerability") { t.Errorf("expected ignore-vulnerability command, got %q", ctx) } - if !strings.Contains(ctx, `"FileName":"billing.py"`) { + if !strings.Contains(ctx, quoteField(`"FileName":"billing.py"`)) { t.Errorf("expected FileName in command, got %q", ctx) } - if !strings.Contains(ctx, `"Line":5`) { + if !strings.Contains(ctx, quoteField(`"Line":5`)) { t.Errorf("expected Line in command, got %q", ctx) } - if !strings.Contains(ctx, `"RuleID":4059`) { + if !strings.Contains(ctx, quoteField(`"RuleID":4059`)) { t.Errorf("expected RuleID in command, got %q", ctx) } } +// quoteField adapts a raw JSON substring assertion for QuoteDataFlag's Windows +// escaping (embedded double quotes become \" so the ignore-vulnerability --data +// argument survives PowerShell's native-exe argument parsing). +func quoteField(raw string) string { + if runtime.GOOS == "windows" { + return strings.ReplaceAll(raw, `"`, `\"`) + } + return raw +} + +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, quoteField(`"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}, @@ -325,10 +363,10 @@ func TestAdditionalContext_MultipleFindings_EachGetsCommand(t *testing.T) { if strings.Count(ctx, "ignore-vulnerability") != 2 { t.Errorf("expected 2 ignore commands for 2 findings, got: %q", ctx) } - if !strings.Contains(ctx, `"RuleID":4059`) { + if !strings.Contains(ctx, quoteField(`"RuleID":4059`)) { t.Errorf("expected RuleID 4059, got %q", ctx) } - if !strings.Contains(ctx, `"RuleID":4027`) { + if !strings.Contains(ctx, quoteField(`"RuleID":4027`)) { t.Errorf("expected RuleID 4027, got %q", ctx) } } @@ -640,3 +678,16 @@ 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, "", "Gemini", "") + 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) + } +} diff --git a/internal/commands/agenthooks/guardrails/asca/delta.go b/internal/commands/agenthooks/guardrails/asca/delta.go index 45d9adca..b3cc9cc8 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -162,8 +162,9 @@ 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. func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string { provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder @@ -174,17 +175,18 @@ 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) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data %s%s%s\n", cxBinary, ignore.QuoteDataFlag(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 +198,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 +253,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 == "Gemini" { + 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..13ed80c8 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -1,15 +1,23 @@ package kics import ( + "encoding/json" "fmt" + "os" "path/filepath" + "runtime" "strings" agenthooks "github.com/Checkmarx/ast-cx-hooks" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/cursorplugin" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" + "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" ) +// 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" + // findingKey is the deduplication tuple used for delta detection. // Mirrors the ignore-file key used by RunIacRealtimeScan: Title + "_" + SimilarityID. type findingKey struct { @@ -62,19 +70,36 @@ 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). -func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) (reason, context string) { +// Cursor receives cursorAdditionalContext (folded into agent_message); Gemini receives +// suppress commands; other agents receive additionalContext (e.g. Claude additionalContext). +func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, workDir string, 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 { + case agenthooks.AgentGemini: + cxBinary := "cx" + if cxExe, err := os.Executable(); err == nil { + cxBinary = cxExe + } + context = geminiAdditionalContext(filePath, cxBinary, findings, workDir) + default: context = additionalContext(filePath, findings) } return reason, context } +// ignoredFilePathFlag returns the " --ignored-file-path ''" fragment that pins +// the suppression command to the workspace ignore file, anchored at the hook event's +// workDir. +func ignoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) +} + // permissionDecisionReason is the human-readable deny message shown to the user. func permissionDecisionReason(filePath, summary string) string { return fmt.Sprintf( @@ -120,6 +145,34 @@ func isDockerImageFileByName(filePath string) bool { name == "compose" || strings.HasPrefix(name, "compose.") } +// geminiIgnoredFilePathFlag pins suppression to the workspace ignore file. On Windows +// uses double quotes and forward slashes so the flag survives PowerShell argv parsing. +func geminiIgnoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + if runtime.GOOS == goosWindows { + p := filepath.ToSlash(ignore.PathFor(workDir)) + return fmt.Sprintf(" --ignored-file-path %q", p) + } + return ignoredFilePathFlag(workDir) +} + +func geminiSuppressCommands(cxBinary string, findings []iacrealtime.IacRealtimeResult, workDir string) string { + var suppressCmds strings.Builder + for i := range findings { + f := &findings[i] + data, _ := json.Marshal(iacrealtime.IgnoredIacFinding{ + Title: f.Title, + SimilarityID: f.SimilarityID, + }) + ignoreFlag := geminiIgnoredFilePathFlag(workDir) + suppressCmds.WriteString(cursorplugin.IgnoreVulnerabilityCommand(cxBinary, "iac", data, ignoreFlag, "")) + suppressCmds.WriteString("\n") + } + return suppressCmds.String() +} + // additionalContext is injected into the agent's context window to drive remediation. // 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 @@ -144,7 +197,26 @@ 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, + "mcp__Checkmarx__imageRemediation", "mcp__Checkmarx__codeRemediation", + ), + ) +} + +// geminiAdditionalContext adds suppress commands for Gemini CLI only. +func geminiAdditionalContext(filePath, cxBinary string, findings []iacrealtime.IacRealtimeResult, workDir string) string { + remediation := remediationInstructions( + filePath, findings, + "mcp_Checkmarx_imageRemediation", "mcp_Checkmarx_codeRemediation", + ) + return fmt.Sprintf( + "KICS detected IaC misconfigurations in %s. "+ + "Do not bypass the scan by writing the same content through another tool or shell command. "+ + "If the user chooses to remediate, follow the remediation instructions below. "+ + "If the user chooses to suppress a finding, run the corresponding command below, then retry the write:\n%s\n"+ + "%s", + filePath, geminiSuppressCommands(cxBinary, findings, workDir), remediation, ) } @@ -153,30 +225,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 "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"+ + " \"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 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..a64a15c3 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -3,6 +3,7 @@ package kics import ( + "runtime" "strings" "testing" @@ -90,7 +91,7 @@ func TestNewFindings_DeltaDedup_SameKeyNotDoubled(t *testing.T) { func TestFormatFindings_ReasonContainsKICS(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + reason, _ := formatFindings("/project/Dockerfile", findings, "", agenthooks.AgentClaude) if !strings.Contains(reason, "KICS") { t.Errorf("reason should contain KICS, got: %q", reason) } @@ -98,7 +99,7 @@ func TestFormatFindings_ReasonContainsKICS(t *testing.T) { func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + reason, _ := formatFindings("/project/Dockerfile", findings, "", agenthooks.AgentClaude) if !strings.Contains(reason, "/project/Dockerfile") { t.Errorf("reason should contain file path, got: %q", reason) } @@ -106,7 +107,7 @@ func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + reason, _ := formatFindings("/project/Dockerfile", findings, "", agenthooks.AgentClaude) if !strings.Contains(reason, "HIGH") { t.Errorf("reason should contain severity, got: %q", reason) } @@ -117,7 +118,7 @@ func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + _, ctx := formatFindings("/project/Dockerfile", findings, "", agenthooks.AgentClaude) if !strings.Contains(ctx, "fix") && !strings.Contains(ctx, "Fix") { t.Errorf("context should contain fix instruction, got: %q", ctx) } @@ -125,7 +126,7 @@ func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { func TestFormatFindings_ContextContainsDoNotBypass(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + _, ctx := formatFindings("/project/Dockerfile", findings, "", agenthooks.AgentClaude) if !strings.Contains(ctx, "bypass") { t.Errorf("context should warn against bypass, got: %q", ctx) } @@ -182,7 +183,7 @@ func TestFormatFindings_DockerfilePlatformUsesImageRemediation(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{ iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"), } - _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + _, ctx := formatFindings("/project/Dockerfile", findings, "", agenthooks.AgentClaude) if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { t.Errorf("Dockerfile context should call imageRemediation, got: %q", ctx) } @@ -195,7 +196,7 @@ func TestFormatFindings_DockerComposePlatformUsesImageRemediation(t *testing.T) findings := []iacrealtime.IacRealtimeResult{ iacResultWithPlatform("VulnerableBaseImage", "DockerCompose"), } - _, ctx := formatFindings("/project/stack.yml", findings, agenthooks.AgentClaude) + _, ctx := formatFindings("/project/stack.yml", findings, "", agenthooks.AgentClaude) if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { t.Errorf("docker-compose context should call imageRemediation, got: %q", ctx) } @@ -205,7 +206,7 @@ func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{ iacResultWithPlatform("OpenSecurityGroup", "Terraform"), } - _, ctx := formatFindings("/project/main.tf", findings, agenthooks.AgentClaude) + _, ctx := formatFindings("/project/main.tf", findings, "", agenthooks.AgentClaude) if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") { t.Errorf("Terraform context should call codeRemediation, got: %q", ctx) } @@ -230,7 +231,7 @@ func TestCursorAdditionalContext_UsesImageRemediation(t *testing.T) { func TestFormatFindings_RoutesCursorContext(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentCursor) + _, ctx := formatFindings("/project/Dockerfile", findings, "", agenthooks.AgentCursor) if !strings.Contains(ctx, "cx-devassist-kics.mdc") { t.Fatalf("cursor agent should get context with rule reference, got %q", ctx) } @@ -243,7 +244,7 @@ func TestFormatFindings_RoutesCursorContext(t *testing.T) { // Use a non-Docker path for the Claude assertion below: Dockerfile findings // always route through imageRemediation (see isDockerImageFinding), so // asserting codeRemediation here requires a generic IaC file instead. - _, ctx = formatFindings("/project/main.tf", findings, agenthooks.AgentClaude) + _, ctx = formatFindings("/project/main.tf", findings, "", agenthooks.AgentClaude) if strings.Contains(ctx, "cx-devassist-kics.mdc") { t.Fatalf("claude agent should not get cursor-specific rule reference, got %q", ctx) } @@ -251,3 +252,57 @@ func TestFormatFindings_RoutesCursorContext(t *testing.T) { t.Fatalf("claude KICS context should reference codeRemediation, got %q", ctx) } } + +func TestGeminiAdditionalContext_ContainsIgnoreVulnerability(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + ctx := geminiAdditionalContext("/project/Dockerfile", "cx", findings, "/project") + if !strings.Contains(ctx, "ignore-vulnerability") { + t.Errorf("expected ignore-vulnerability command, got %q", ctx) + } + if !strings.Contains(ctx, `--scan-type iac`) { + t.Errorf("expected iac scan type in suppress command, got %q", ctx) + } + if !strings.Contains(ctx, "PrivilegedContainer") { + t.Errorf("expected finding title in suppress command, got %q", ctx) + } + if !strings.Contains(ctx, "sim1") { + t.Errorf("expected similarity id in suppress command, got %q", ctx) + } + if runtime.GOOS == goosWindows { + if !strings.Contains(ctx, `--% ignore-vulnerability`) { + t.Errorf("expected PowerShell stop-parsing on Windows, got %q", ctx) + } + if !strings.Contains(ctx, `\"Title\":\"PrivilegedContainer\"`) { + t.Errorf("expected backslash-escaped JSON on Windows, 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/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index d57a9d4b..79f871cd 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -82,7 +82,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas // For new files (no original content), every finding is new if originalContent == "" { - r, c := formatFindings(ev.FilePath, newResults, ev.Agent) + r, c := formatFindings(ev.FilePath, newResults, ev.WorkDir, ev.Agent) return true, r, c } @@ -105,7 +105,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas return false, "", "" } - r, c := formatFindings(ev.FilePath, newFindings, ev.Agent) + r, c := formatFindings(ev.FilePath, newFindings, ev.WorkDir, ev.Agent) return true, r, c } diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index 3ba4417a..d19a3a91 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -106,7 +106,7 @@ 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) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data %s%s%s\n", cxBinary, ignore.QuoteDataFlag(data), ignoreFlag, provenance) } } if agent == agentCursor { 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) + } +}