Skip to content
Open
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
61 changes: 56 additions & 5 deletions internal/commands/agenthooks/guardrails/asca/asca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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)
}
}
23 changes: 18 additions & 5 deletions internal/commands/agenthooks/guardrails/asca/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"+
Expand All @@ -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"
}
2 changes: 1 addition & 1 deletion internal/commands/agenthooks/sca/prompts.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"+
Expand Down
18 changes: 18 additions & 0 deletions internal/services/realtimeengine/ignore/shellquote.go
Original file line number Diff line number Diff line change
@@ -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" {

Check failure on line 14 in internal/services/realtimeengine/ignore/shellquote.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

string `windows` has 3 occurrences, make it a constant (goconst)
return "'" + strings.ReplaceAll(s, `"`, `\"`) + "'"
}
return "'" + s + "'"
}
28 changes: 28 additions & 0 deletions internal/services/realtimeengine/ignore/shellquote_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading