From b4ad873ca585a3025bdbd8539aed0dc1e0c06ca7 Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:12:16 +0530 Subject: [PATCH 1/7] gemini cli changes --- .../agenthooks/guardrails/asca/asca_test.go | 61 +++++++++++++++++-- .../agenthooks/guardrails/asca/delta.go | 23 +++++-- internal/commands/agenthooks/sca/prompts.go | 2 +- .../realtimeengine/ignore/shellquote.go | 18 ++++++ .../realtimeengine/ignore/shellquote_test.go | 28 +++++++++ 5 files changed, 121 insertions(+), 11 deletions(-) create mode 100644 internal/services/realtimeengine/ignore/shellquote.go create mode 100644 internal/services/realtimeengine/ignore/shellquote_test.go diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 4c04c10f..0317ee8a 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) } } @@ -581,3 +619,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 afcf92ab..2c0665ff 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -113,7 +113,8 @@ 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. +// 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. func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string { ignoreFlag := ignoredFilePathFlag(workDir) provenance := optionalFlagsFragment(agent, sessionID) @@ -124,17 +125,18 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w Line: f.Line, RuleID: f.RuleID, }) - 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"+ @@ -146,6 +148,17 @@ 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(), ) } + +// 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/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index 4be68391..95905773 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -68,7 +68,7 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, se "PackageName": p.PackageName, "PackageVersion": p.PackageVersion, }}) - 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) } return fmt.Sprintf( "Action required:\n"+ diff --git a/internal/services/realtimeengine/ignore/shellquote.go b/internal/services/realtimeengine/ignore/shellquote.go new file mode 100644 index 00000000..51df697d --- /dev/null +++ b/internal/services/realtimeengine/ignore/shellquote.go @@ -0,0 +1,18 @@ +package ignore + +import ( + "runtime" + "strings" +) + +// 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 == "windows" { + 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..c8ddb3c2 --- /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 == "windows" { + 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 != "windows" { + 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) + } +} From 5576d39327347388bfa5911c194cc3de40fe22bf Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:03:29 +0530 Subject: [PATCH 2/7] Update ast-cx-hooks to v1.0.8 Co-Authored-By: Claude Sonnet 5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 820d7faa..1ae33cc0 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkmarx/ast-cli go 1.26.5 require ( - github.com/Checkmarx/ast-cx-hooks v1.0.5 + 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 ecc531e6..14ed82f7 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.5 h1:4Og5JeBBg3SynAErAP76oGKrjoWrlduWRgg1V9IXjWo= -github.com/Checkmarx/ast-cx-hooks v1.0.5/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= From 105b5d1a65b4d3ffb05a6b921d223144641ec63a Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:03:34 +0530 Subject: [PATCH 3/7] Kics Changes --- .../agenthooks/guardrails/kics/delta.go | 125 ++++++++++++++---- .../agenthooks/guardrails/kics/delta_test.go | 75 +++++++++-- .../agenthooks/guardrails/kics/kics.go | 4 +- 3 files changed, 163 insertions(+), 41 deletions(-) diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 883d430a..20865d46 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -1,13 +1,17 @@ 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" ) // findingKey is the deduplication tuple used for delta detection. @@ -62,19 +66,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 +141,33 @@ 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 == "windows" { + 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 _, f := range findings { + 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 +192,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 +220,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..d2133459 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 == "windows" { + 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 } From 43ea0f640c95ea9d84f96f913bf112788796bc74 Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:48:23 +0530 Subject: [PATCH 4/7] Fix lint issues: goconst windows literal and gocritic rangeValCopy Extract goosWindows constant in kics and ignore packages to satisfy goconst, and avoid copying IacRealtimeResult per loop iteration in geminiSuppressCommands to satisfy gocritic. Co-Authored-By: Claude Sonnet 5 --- internal/commands/agenthooks/guardrails/kics/delta.go | 9 +++++++-- .../commands/agenthooks/guardrails/kics/delta_test.go | 2 +- internal/services/realtimeengine/ignore/shellquote.go | 6 +++++- .../services/realtimeengine/ignore/shellquote_test.go | 4 ++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 20865d46..13ed80c8 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -14,6 +14,10 @@ import ( "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 { @@ -147,7 +151,7 @@ func geminiIgnoredFilePathFlag(workDir string) string { if workDir == "" { return "" } - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { p := filepath.ToSlash(ignore.PathFor(workDir)) return fmt.Sprintf(" --ignored-file-path %q", p) } @@ -156,7 +160,8 @@ func geminiIgnoredFilePathFlag(workDir string) string { func geminiSuppressCommands(cxBinary string, findings []iacrealtime.IacRealtimeResult, workDir string) string { var suppressCmds strings.Builder - for _, f := range findings { + for i := range findings { + f := &findings[i] data, _ := json.Marshal(iacrealtime.IgnoredIacFinding{ Title: f.Title, SimilarityID: f.SimilarityID, diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index d2133459..a64a15c3 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -268,7 +268,7 @@ func TestGeminiAdditionalContext_ContainsIgnoreVulnerability(t *testing.T) { if !strings.Contains(ctx, "sim1") { t.Errorf("expected similarity id in suppress command, got %q", ctx) } - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { if !strings.Contains(ctx, `--% ignore-vulnerability`) { t.Errorf("expected PowerShell stop-parsing on Windows, got %q", ctx) } diff --git a/internal/services/realtimeengine/ignore/shellquote.go b/internal/services/realtimeengine/ignore/shellquote.go index 51df697d..c9af319f 100644 --- a/internal/services/realtimeengine/ignore/shellquote.go +++ b/internal/services/realtimeengine/ignore/shellquote.go @@ -5,13 +5,17 @@ import ( "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 == "windows" { + 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 index c8ddb3c2..57462a79 100644 --- a/internal/services/realtimeengine/ignore/shellquote_test.go +++ b/internal/services/realtimeengine/ignore/shellquote_test.go @@ -6,7 +6,7 @@ import ( ) func TestQuoteDataFlag_Unix(t *testing.T) { - if runtime.GOOS == "windows" { + if runtime.GOOS == goosWindows { t.Skip("unix quoting on windows host") } got := QuoteDataFlag([]byte(`{"FileName":"a.py","Line":1,"RuleID":2}`)) @@ -17,7 +17,7 @@ func TestQuoteDataFlag_Unix(t *testing.T) { } func TestQuoteDataFlag_Windows(t *testing.T) { - if runtime.GOOS != "windows" { + if runtime.GOOS != goosWindows { t.Skip("windows quoting") } got := QuoteDataFlag([]byte(`{"FileName":"a.py","Line":1,"RuleID":2}`)) From 339065d659572c66d8bc8d7c936e2c4280f0b418 Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Mon, 31 Aug 2026 13:14:23 +0530 Subject: [PATCH 5/7] Merge Gemini KICS guardrail context into shared additionalContext Gemini previously had its own additionalContext variant solely to generate suppress commands and use underscore-style MCP tool names. Since suppress isn't offered to Claude either, drop the Gemini-only path and instead parametrize additionalContext by agent so it picks the correct MCP tool name format (mcp_Checkmarx_... for Gemini vs mcp__Checkmarx__... for others), removing the now-dead suppress command plumbing. Co-Authored-By: Claude Sonnet 5 --- .../agenthooks/guardrails/kics/delta.go | 89 +++---------------- .../agenthooks/guardrails/kics/delta_test.go | 50 +++-------- .../agenthooks/guardrails/kics/kics.go | 4 +- 3 files changed, 25 insertions(+), 118 deletions(-) diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 13ed80c8..0387b8fc 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -1,23 +1,15 @@ 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 { @@ -70,36 +62,20 @@ func findingsSummary(filePath string, findings []iacrealtime.IacRealtimeResult) } // formatFindings builds the two verdict fields delivered to the agent. -// 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) { +// Cursor receives cursorAdditionalContext (folded into agent_message); other agents +// (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) switch agent { case agenthooks.AgentCursor: context = cursorAdditionalContext(filePath, findings) - 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) + context = additionalContext(filePath, findings, agent) } 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( @@ -145,40 +121,12 @@ 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 // 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 @@ -188,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 "+ @@ -197,26 +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, - "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, + filePath, findingList.String(), remediationInstructions(filePath, findings, imageTool, codeTool), ) } diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index a64a15c3..a8f1f7e5 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -3,7 +3,6 @@ package kics import ( - "runtime" "strings" "testing" @@ -91,7 +90,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) } @@ -99,7 +98,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) } @@ -107,7 +106,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) } @@ -118,7 +117,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) } @@ -126,7 +125,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) } @@ -183,7 +182,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) } @@ -196,7 +195,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) } @@ -206,7 +205,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) } @@ -231,7 +230,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) } @@ -244,7 +243,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) } @@ -253,36 +252,11 @@ func TestFormatFindings_RoutesCursorContext(t *testing.T) { } } -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) + _, 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) } @@ -293,7 +267,7 @@ func TestAdditionalContext_GeminiUsesUnderscoreMCPNames(t *testing.T) { func TestAdditionalContext_ClaudeDoesNotOfferSuppress(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, "ignore-vulnerability") { t.Errorf("Claude 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 79f871cd..d57a9d4b 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.WorkDir, ev.Agent) + r, c := formatFindings(ev.FilePath, newResults, 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.WorkDir, ev.Agent) + r, c := formatFindings(ev.FilePath, newFindings, ev.Agent) return true, r, c } From 1a784365914fb1711318ce343630fff71f4e504b Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:34:05 +0530 Subject: [PATCH 6/7] Use QuoteDataFlag for Gemini ASCA suppress commands only. Gemini CLI runs suppress commands through PowerShell on Windows, so its --data JSON needs shell-safe quoting; other agents keep the original single-quoted payload format. Co-authored-by: Cursor --- .../agenthooks/guardrails/asca/asca_test.go | 54 +++++++++++++------ .../agenthooks/guardrails/asca/delta.go | 17 ++++-- 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index f22169bc..98881d16 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -305,27 +305,17 @@ 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, quoteField(`"FileName":"billing.py"`)) { + if !strings.Contains(ctx, `"FileName":"billing.py"`) { t.Errorf("expected FileName in command, got %q", ctx) } - if !strings.Contains(ctx, quoteField(`"Line":5`)) { + if !strings.Contains(ctx, `"Line":5`) { t.Errorf("expected Line in command, got %q", ctx) } - if !strings.Contains(ctx, quoteField(`"RuleID":4059`)) { + if !strings.Contains(ctx, `"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}, @@ -349,7 +339,7 @@ func TestAdditionalContext_FileNameWithPercent_NotMisformatted(t *testing.T) { 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"`)) { + if !strings.Contains(ctx, `"FileName":"a%s.py"`) { t.Errorf("expected the literal filename in the ignore command, got %q", ctx) } } @@ -363,10 +353,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, quoteField(`"RuleID":4059`)) { + if !strings.Contains(ctx, `"RuleID":4059`) { t.Errorf("expected RuleID 4059, got %q", ctx) } - if !strings.Contains(ctx, quoteField(`"RuleID":4027`)) { + if !strings.Contains(ctx, `"RuleID":4027`) { t.Errorf("expected RuleID 4027, got %q", ctx) } } @@ -435,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) { @@ -680,7 +677,7 @@ func TestHighestSeverity_MixedValidAndInvalid(t *testing.T) { } func TestAdditionalContext_GeminiUsesGeminiSkillAndMCPTool(t *testing.T) { - ctx := additionalContext("main.py", "cx", nil, "", "Gemini", "") + ctx := additionalContext("main.py", "cx", nil, "", agentGemini, "") if !strings.Contains(ctx, "/cx-security-asca") { t.Errorf("expected Gemini skill path, got %q", ctx) } @@ -691,3 +688,26 @@ func TestAdditionalContext_GeminiUsesGeminiSkillAndMCPTool(t *testing.T) { 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 b3cc9cc8..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" @@ -164,7 +169,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 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. +// 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 @@ -175,7 +182,11 @@ 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, ignore.QuoteDataFlag(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( @@ -259,7 +270,7 @@ func cursorAdditionalContext(filePath, cxBinary string, findings []grpcs.ScanDet // 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" { + if agent == agentGemini { return "/cx-security-asca", "mcp_Checkmarx_codeRemediation" } return "cx-devassist:cx-devassist-asca", "mcp__Checkmarx__codeRemediation" From a1718277d9c231d5bb05904886afee943bda21b8 Mon Sep 17 00:00:00 2001 From: Rahul Pidde <206018639+cx-rahul-pidde@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:27:35 +0530 Subject: [PATCH 7/7] Use QuoteDataFlag for Gemini SCA suppress commands only. Gemini CLI runs suppress commands through PowerShell on Windows, so its --data JSON needs shell-safe quoting; other agents keep the original single-quoted payload format. Co-authored-by: Cursor --- internal/commands/agenthooks/sca/prompts.go | 15 +++++++++-- internal/commands/agenthooks/sca/sca_test.go | 26 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index d19a3a91..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, ignore.QuoteDataFlag(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"},