Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions internal/commands/agenthooks/guardrails/asca/asca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,34 @@ func TestAdditionalContext_SingleFinding_PreFilledCommand(t *testing.T) {
}
}

func TestAdditionalContext_EmitsProvenanceOptionalFlags(t *testing.T) {
findings := []grpcs.ScanDetail{
{FileName: "billing.py", Line: 5, RuleID: 4059},
}
ctx := additionalContext("billing.py", "cx", findings, "", "Claude", "sess-123")
want := ` --optional-flags "aiProvider=Claude;agent=Claude-cli;aiAgentSessionId=sess-123"`
if !strings.Contains(ctx, want) {
t.Errorf("expected provenance flags %q in ignore command, got %q", want, ctx)
}
// Empty agent → no provenance fragment (backward-compatible default).
if noAgent := additionalContext("billing.py", "cx", findings, "", "", ""); strings.Contains(noAgent, "--optional-flags") {
t.Errorf("expected no --optional-flags when agent is empty, got %q", noAgent)
}
}

func TestAdditionalContext_FileNameWithPercent_NotMisformatted(t *testing.T) {
findings := []grpcs.ScanDetail{
{FileName: "a%s.py", Line: 5, RuleID: 4059},
}
ctx := additionalContext("a%s.py", "cx", findings, "", "Claude", "sess-1")
if strings.Contains(ctx, "%!s") || strings.Contains(ctx, "MISSING") {
t.Errorf("a %%-containing filename leaked a format verb into the output: %q", ctx)
}
if !strings.Contains(ctx, `"FileName":"a%s.py"`) {
t.Errorf("expected the literal filename in the ignore command, got %q", ctx)
}
}

func TestAdditionalContext_MultipleFindings_EachGetsCommand(t *testing.T) {
findings := []grpcs.ScanDetail{
{FileName: "billing.py", Line: 5, RuleID: 4059},
Expand Down Expand Up @@ -397,6 +425,13 @@ func TestFormatFindings_RoutesCursorQuoting(t *testing.T) {
if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data '`) {
t.Fatalf("claude agent should get single-quoted suppress command, got %q", ctx)
}
if runtime.GOOS == goosWindows && strings.Contains(ctx, `\"FileName\"`) {
t.Fatalf("claude agent should not use QuoteDataFlag Windows escaping, got %q", ctx)
}
_, ctx = formatFindings("a.py", findings, "", agentGemini, "sess-1")
if !strings.Contains(ctx, "ignore-vulnerability --scan-type asca --data "+ignore.QuoteDataFlag([]byte(`{"FileName":"a.py","Line":1,"RuleID":1}`))) {
t.Fatalf("gemini agent should get QuoteDataFlag suppress command, got %q", ctx)
}
}

func TestAdditionalContext_PinsIgnoredFilePathToWorkDir(t *testing.T) {
Expand Down Expand Up @@ -640,3 +675,39 @@ func TestHighestSeverity_MixedValidAndInvalid(t *testing.T) {
got := highestSeverity(findings)
assert.Equal(t, "High", got)
}

func TestAdditionalContext_GeminiUsesGeminiSkillAndMCPTool(t *testing.T) {
ctx := additionalContext("main.py", "cx", nil, "", agentGemini, "")
if !strings.Contains(ctx, "/cx-security-asca") {
t.Errorf("expected Gemini skill path, got %q", ctx)
}
if !strings.Contains(ctx, "mcp_Checkmarx_codeRemediation") {
t.Errorf("expected Gemini MCP tool name, got %q", ctx)
}
if strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
t.Errorf("Claude MCP tool name should not appear for Gemini, got %q", ctx)
}
}

func TestAdditionalContext_GeminiUsesQuoteDataFlag(t *testing.T) {
findings := []grpcs.ScanDetail{
{FileName: "billing.py", Line: 5, RuleID: 4059},
}
data := []byte(`{"FileName":"billing.py","Line":5,"RuleID":4059}`)
ctx := additionalContext("billing.py", "cx", findings, "", agentGemini, "")
want := "ignore-vulnerability --scan-type asca --data " + ignore.QuoteDataFlag(data)
if !strings.Contains(ctx, want) {
t.Errorf("expected Gemini suppress command %q, got %q", want, ctx)
}
}

func TestAdditionalContext_OtherAgentsUseUnescapedData(t *testing.T) {
findings := []grpcs.ScanDetail{
{FileName: "billing.py", Line: 5, RuleID: 4059},
}
ctx := additionalContext("billing.py", "cx", findings, "", "Claude", "")
want := `ignore-vulnerability --scan-type asca --data '{"FileName":"billing.py","Line":5,"RuleID":4059}'`
if !strings.Contains(ctx, want) {
t.Errorf("expected other agents to use unescaped --data %q, got %q", want, ctx)
}
}
36 changes: 30 additions & 6 deletions internal/commands/agenthooks/guardrails/asca/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -162,8 +167,11 @@ func permissionDecisionReason(filePath, summary string) string {
}

// additionalContext is injected into the agent's context window to drive remediation.
// Contains all action instructions — not shown directly to the user.
// Used for Claude, Copilot, and other non-Cursor agents.
// Contains all action instructions — not shown directly to the user on Claude; on Gemini
// BeforeTool it is folded into the hook deny reason by the ast-cx-hooks gemini adapter.
// Used for Claude, Copilot, Gemini, and other non-Cursor agents. Gemini suppress commands
// use ignore.QuoteDataFlag (PowerShell-safe quoting on Windows); other agents keep the
// original single-quoted JSON payload.
func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string {
provenance := optionalFlagsFragment(agent, sessionID)
var suppressCmds strings.Builder
Expand All @@ -174,17 +182,22 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w
RuleID: f.RuleID,
})
ignoreFlag := ignoredFilePathFlag(workDir)
fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance)
if agent == agentGemini {
fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data %s%s%s\n", cxBinary, ignore.QuoteDataFlag(data), ignoreFlag, provenance)
} else {
fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance)
}
}
skill, mcpTool := remediationTargets(agent)
return fmt.Sprintf(
"ASCA detected vulnerabilities in %s. "+
"Do not bypass the scan by writing the same content through another tool or shell command. "+
"ANALYZE each finding to determine if it is a real vulnerability or a false positive "+
"caused by ASCA's single-file scope (it cannot see imported modules or helper files). "+
"For each real finding, invoke the cx-devassist:cx-devassist-asca skill — "+
"For each real finding, invoke the %s skill — "+
"the findings are already in context so it will skip the scan and go directly to "+
"MCP-driven remediation; the skill also handles MCP unavailability and self-recovery. "+
"If that skill is not available in this session, call mcp__Checkmarx__codeRemediation directly:\n"+
"If that skill is not available in this session, call %s directly:\n"+
" {\n"+
" \"language\": \"[auto-detected programming language]\",\n"+
" \"metadata\": {\n"+
Expand All @@ -196,7 +209,7 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w
" }\n"+
"Use the remediation guidance returned by the tool to fix the vulnerability, then retry the write. "+
"If a finding is a confirmed false positive, suppress it by running the corresponding command below, then retry the write:\n%s",
filePath, suppressCmds.String(),
filePath, skill, mcpTool, suppressCmds.String(),
)
}

Expand Down Expand Up @@ -251,3 +264,14 @@ func cursorAdditionalContext(filePath, cxBinary string, findings []grpcs.ScanDet
filePath, tool, suppressCmds.String(),
)
}

// remediationTargets returns the skill invocation and MCP tool name for the agent.
// Gemini CLI's skills are invoked as a bare "/name" slash command and its MCP tool
// names use single underscores (no "__"), unlike Claude Code's "plugin:skill" and
// "mcp__Server__tool" conventions.
func remediationTargets(agent string) (skill, mcpTool string) {
if agent == agentGemini {
return "/cx-security-asca", "mcp_Checkmarx_codeRemediation"
}
return "cx-devassist:cx-devassist-asca", "mcp__Checkmarx__codeRemediation"
}
63 changes: 34 additions & 29 deletions internal/commands/agenthooks/guardrails/kics/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,15 @@ func findingsSummary(filePath string, findings []iacrealtime.IacRealtimeResult)

// formatFindings builds the two verdict fields delivered to the agent.
// Cursor receives cursorAdditionalContext (folded into agent_message); other agents
// receive the original additionalContext (e.g. Claude additionalContext).
// (including Gemini) receive additionalContext, with MCP tool names adjusted per agent.
func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) (reason, context string) {
summary := findingsSummary(filePath, findings)
reason = permissionDecisionReason(filePath, summary)
if agent == agenthooks.AgentCursor {
switch agent {
case agenthooks.AgentCursor:
context = cursorAdditionalContext(filePath, findings)
} else {
context = additionalContext(filePath, findings)
default:
context = additionalContext(filePath, findings, agent)
}
return reason, context
}
Expand Down Expand Up @@ -124,8 +125,8 @@ func isDockerImageFileByName(filePath string) bool {
// KICS is a deterministic IaC rule engine: unlike ASCA, its findings are not caused by
// missing cross-file context, so the agent is NOT given discretion to treat findings as
// false positives. Every new finding must be fixed.
// Used for Claude, Copilot, and other non-Cursor agents.
func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult) string {
// Used for Claude, Gemini, Copilot, and other non-Cursor agents.
func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) string {
var findingList strings.Builder
for _, f := range findings {
line := 0
Expand All @@ -135,6 +136,10 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult
fmt.Fprintf(&findingList, " - line %d [%s] %s: %s\n",
line, f.Severity, f.Title, f.Description)
}
imageTool, codeTool := "mcp__Checkmarx__imageRemediation", "mcp__Checkmarx__codeRemediation"
if agent == agenthooks.AgentGemini {
imageTool, codeTool = "mcp_Checkmarx_imageRemediation", "mcp_Checkmarx_codeRemediation"
}
return fmt.Sprintf(
"KICS detected IaC misconfigurations in %s. These are deterministic rule matches "+
"against the configuration itself — they are NOT false positives caused by code "+
Expand All @@ -144,7 +149,7 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult
"Fix every finding below, then retry the write:\n"+
"%s"+
"%s",
filePath, findingList.String(), remediationInstructions(filePath, findings),
filePath, findingList.String(), remediationInstructions(filePath, findings, imageTool, codeTool),
)
}

Expand All @@ -153,30 +158,30 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult
// through imageRemediation (base image CVEs, safer tags, hardening). All other
// KICS-supported files (Terraform, Kubernetes manifests, CloudFormation, etc.) are
// generic IaC misconfigurations and go through codeRemediation.
func remediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult) string {
func remediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult, imageTool, codeTool string) string {
if isDockerImageFinding(filePath, findings) {
return "For each finding, call the mcp__Checkmarx__imageRemediation tool with:\n" +
" {\n" +
" \"imageName\": \"[image name from the finding/file, without the tag]\",\n" +
" \"imageTag\": \"[image tag from the finding/file, e.g. latest]\",\n" +
" \"fileType\": \"[Dockerfile or DockerCompose, matching this file]\"\n" +
" }\n" +
"Apply the remediation guidance the tool returns (safer base image, pinned digest, " +
"hardening steps), then retry the write."
return fmt.Sprintf("For each finding, call the %s tool with:\n"+
" {\n"+
" \"imageName\": \"[image name from the finding/file, without the tag]\",\n"+
" \"imageTag\": \"[image tag from the finding/file, e.g. latest]\",\n"+
" \"fileType\": \"[Dockerfile or DockerCompose, matching this file]\"\n"+
" }\n"+
"Apply the remediation guidance the tool returns (safer base image, pinned digest, "+
"hardening steps), then retry the write.", imageTool)
}
return "For each finding, call the mcp__Checkmarx__codeRemediation tool with:\n" +
" {\n" +
" \"type\": \"iac\",\n" +
" \"metadata\": {\n" +
" \"title\": \"[Title from finding]\",\n" +
" \"description\": \"[Description from finding]\",\n" +
" \"remediationAdvice\": \"[how to harden this configuration]\"\n" +
" }\n" +
" }\n" +
"Apply the remediation guidance the tool returns, then retry the write. If a fix " +
"genuinely requires resources outside this file (for example a separate KMS key or " +
"a centrally-managed policy), add them as part of your change rather than skipping " +
"the finding."
return fmt.Sprintf("For each finding, call the %s tool with:\n"+
" {\n"+
" \"type\": \"iac\",\n"+
" \"metadata\": {\n"+
" \"title\": \"[Title from finding]\",\n"+
" \"description\": \"[Description from finding]\",\n"+
" \"remediationAdvice\": \"[how to harden this configuration]\"\n"+
" }\n"+
" }\n"+
"Apply the remediation guidance the tool returns, then retry the write. If a fix "+
"genuinely requires resources outside this file (for example a separate KMS key or "+
"a centrally-managed policy), add them as part of your change rather than skipping "+
"the finding.", codeTool)
}

func cursorRemediationInstructions(filePath string, findings []iacrealtime.IacRealtimeResult) string {
Expand Down
29 changes: 29 additions & 0 deletions internal/commands/agenthooks/guardrails/kics/delta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,3 +251,32 @@ func TestFormatFindings_RoutesCursorContext(t *testing.T) {
t.Fatalf("claude KICS context should reference codeRemediation, got %q", ctx)
}
}

func TestAdditionalContext_GeminiUsesUnderscoreMCPNames(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"),
}
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentGemini)
if !strings.Contains(ctx, "mcp_Checkmarx_imageRemediation") {
t.Errorf("Gemini context should use underscore MCP name, got: %q", ctx)
}
if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
t.Errorf("Gemini context should not use double-underscore MCP name, got: %q", ctx)
}
}

func TestAdditionalContext_ClaudeDoesNotOfferSuppress(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)}
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude)
if strings.Contains(ctx, "ignore-vulnerability") {
t.Errorf("Claude context should not include suppress commands, got %q", ctx)
}
}

func TestCursorAdditionalContext_DoesNotOfferSuppress(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)}
ctx := cursorAdditionalContext("/project/Dockerfile", findings)
if strings.Contains(ctx, "ignore-vulnerability") {
t.Errorf("cursor context should not include suppress commands, got %q", ctx)
}
}
15 changes: 13 additions & 2 deletions internal/commands/agenthooks/sca/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -106,7 +113,11 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, se
suppressCmds.WriteString("\n")
} else {
ignoreFlag := ignoredFilePathFlag(workDir)
fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance)
if agent == agentGemini {
fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data %s%s%s\n", cxBinary, ignore.QuoteDataFlag(data), ignoreFlag, provenance)
} else {
fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance)
}
}
}
if agent == agentCursor {
Expand Down
26 changes: 26 additions & 0 deletions internal/commands/agenthooks/sca/sca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
22 changes: 22 additions & 0 deletions internal/services/realtimeengine/ignore/shellquote.go
Original file line number Diff line number Diff line change
@@ -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 + "'"
}
Loading
Loading