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) + } +}