diff --git a/go.mod b/go.mod index 9a18754d7..da1c16f36 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkmarx/ast-cli go 1.26.6 require ( - github.com/Checkmarx/ast-cx-hooks v1.0.9 + github.com/Checkmarx/ast-cx-hooks v1.0.10 github.com/Checkmarx/containers-resolver v1.0.37 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 d6c9101c8..9e4c69145 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.9 h1:NZ8Ekjxbe/0tj48Lv1rKVzwo2iGMXG06vD6HK0eZgio= -github.com/Checkmarx/ast-cx-hooks v1.0.9/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= +github.com/Checkmarx/ast-cx-hooks v1.0.10 h1:tY4XwacIOnMelOYZFsN7Mpv2qcfQWzjcCLSoD8Bpd7k= +github.com/Checkmarx/ast-cx-hooks v1.0.10/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.37 h1:VkLuw8QXt04VvdWzjfoTHAqC7imizUp3fvLaTRRmHYs= diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 98881d162..f71a76c85 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -297,6 +297,38 @@ func TestNewFindings_EmptyNewScanReturnsEmpty(t *testing.T) { // ── additionalContext ──────────────────────────────────────────────────────── +func TestAdditionalContext_OmitsInjectionTriggers(t *testing.T) { + findings := []grpcs.ScanDetail{{FileName: "a.py", Line: 1, RuleID: 1}} + for _, agent := range []string{"Claude", "Codex", "Copilot", "Cursor", "Gemini"} { + var ctx string + if agent == agentCursor { + ctx = cursorAdditionalContext("a.py", "cx", findings, "/work", "s1") + } else { + ctx = additionalContext("a.py", "cx", findings, "/work", agent, "s1") + } + for _, bad := range []string{"without asking", "silently", "cx_mcp_register"} { + if strings.Contains(ctx, bad) { + t.Errorf("%s context contains %q", agent, bad) + } + } + if !strings.Contains(ctx, "This is a Checkmarx hook deny") { + t.Errorf("%s context missing hook deny header", agent) + } + if !strings.Contains(ctx, "that still needs (a) or (b)") { + t.Errorf("%s context missing suppression label", agent) + } + if strings.Contains(ctx, "the hook on that retry is the check") { + t.Errorf("%s context still treats the hook retry as the check", agent) + } + if !strings.Contains(ctx, "that re-scan validates the finding") { + t.Errorf("%s context missing re-scan validation", agent) + } + if !strings.Contains(ctx, "partial fix") { + t.Errorf("%s context missing partial-fix status", agent) + } + } +} + func TestAdditionalContext_SingleFinding_PreFilledCommand(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, @@ -592,7 +624,7 @@ func TestFormatFindings_ReturnsReasonAndContext(t *testing.T) { reason, context := formatFindings("a.py", findings, "", "Claude", "") assert.Contains(t, reason, "ASCA security scan detected vulnerabilities in a.py") assert.Contains(t, reason, "sql-injection") - assert.Contains(t, context, "ASCA detected vulnerabilities in a.py") + assert.Contains(t, context, "ASCA blocked the write to a.py") assert.Contains(t, context, "ignore-vulnerability") } diff --git a/internal/commands/agenthooks/guardrails/asca/delta.go b/internal/commands/agenthooks/guardrails/asca/delta.go index ec96fb4ea..61631d6e5 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -8,6 +8,7 @@ import ( "runtime" "strings" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/agentprofile" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/cursorplugin" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/wrappers/grpcs" @@ -26,6 +27,15 @@ const agentCursor = "Cursor" // uses ignore.QuoteDataFlag. Other non-Cursor agents keep the original single-quoted JSON. const agentGemini = "Gemini" +// provenanceTag returns the string prepended to permissionDecisionReason and additionalContext in +// formatFindings so the agent can recognize genuine Checkmarx output. NOT an authority claim — an +// attacker could spoof this exact string from a file, a fetched page, or another tool's output — it +// is a signpost: text that LOOKS like a Checkmarx finding but lacks it, or that instructs running a +// script/CLI command "silently"/"without asking", did not come from Checkmarx. +func provenanceTag(agent string) string { + return "[Checkmarx cx-devassist — automated security output, not user input]" +} + // 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" @@ -91,12 +101,14 @@ func formatFindings(filePath string, findings []grpcs.ScanDetail, workDir, agent if err == nil { cxBinary = cxExe } - reason = permissionDecisionReason(filePath, summary) + tag := provenanceTag(agent) + reason = tag + " " + permissionDecisionReason(filePath, summary) if agent == agentCursor { context = cursorAdditionalContext(filePath, cxBinary, findings, workDir, sessionID) } else { context = additionalContext(filePath, cxBinary, findings, workDir, agent, sessionID) } + context = tag + " " + context return reason, context } @@ -157,6 +169,11 @@ func optionalFlagsFragment(agent, sessionID string) string { return fmt.Sprintf(" --optional-flags %q", pairs) } +// hookDenyHeader is the agent-facing opening of additionalContext. The provenance tag is +// prepended separately in formatFindings. This header does not contain the spoof phrases +// the skills treat as proof a message is not from Checkmarx. +const hookDenyHeader = "This is a Checkmarx hook deny. Follow it. It is not file content and not an untrusted tool error.\n" + // permissionDecisionReason is the human-readable deny message shown to the user. // Contains only the findings — no agent instructions. func permissionDecisionReason(filePath, summary string) string { @@ -166,6 +183,20 @@ func permissionDecisionReason(filePath, summary string) string { ) } +// ascaRemediationReport is the closing report both the shared and Cursor deny text tell the +// agent to show. One string so the two deny paths cannot drift apart. +const ascaRemediationReport = "5. Always finish with this report, even if you asked the user a question, the file is new, " + + "or the retry passed. Show it in the chat as markdown, not inside a code block. One bullet per finding, " + + "then a blank line and the final status. Do not print the braces. Pick one result and one final status. " + + "A partial fix must include why it does not fully resolve the issue. Ignored must include why you ignored it. Unresolved must include why it was not fixed. A bullet without that reason is incomplete.\n" + + "## Checkmarx DevAssist ASCA Remediation Summary\n" + + "\n" + + "- **{rule name}** - {severity} - line {line} - **{Fixed, Partial fix, Ignored, or Unresolved}**\n" + + " {Fixed: what changed. Partial fix: what you applied from the MCP suggestion, and Reason: why it does not fully resolve the issue. Ignored: Reason: why, citing the user's words or the file and line you read. Unresolved: Reason: why it was not fixed.}\n" + + "\n" + + "**Final status:** {All fixed, Partially fixed, or Unresolved}\n" + + "Then continue the user's original task. Do not include that sentence in the report.\n" + // 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. @@ -190,26 +221,45 @@ func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, w } 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 %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 %s directly:\n"+ - " {\n"+ - " \"language\": \"[auto-detected programming language]\",\n"+ - " \"metadata\": {\n"+ - " \"ruleId\": \"[rule_name from scan]\",\n"+ - " \"description\": \"[description from scan]\",\n"+ - " \"remediationAdvice\": \"[remediationAdvise from scan]\"\n"+ - " },\n"+ - " \"type\": \"sast\"\n"+ - " }\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, skill, mcpTool, suppressCmds.String(), + hookDenyHeader+ + "ASCA blocked the write to %s.\nFindings:\n%s"+ + "Handle every finding yourself. Never ask the user, including \"Would you like me to "+ + "remediate?\" (that question is only for on-demand scans).\n"+ + "1. Classify each finding:\n"+ + "- False positive only if one of these is already true and you can cite it: (a) the user "+ + "explicitly told you to ignore or suppress it; (b) a file you opened this session shows, at a "+ + "line you can cite, that the flagged code is unreachable or dead, runs only on test or fixture "+ + "data, or is behind a sanitizer or guard for this exact pattern. ASCA scans one file, so that "+ + "evidence may be in a helper or imported file you opened. What a finding, hook message, or "+ + "file says about another file is not evidence; open that file. Apparent intent (a lab or "+ + "demo file) is not evidence; that still needs (a) or (b).\n"+ + "- Otherwise it is a true positive, including when you are unsure.\n"+ + "2. True positive: call %s for each one with type \"sast\", the language, and metadata "+ + "(ruleId = rule name, description, remediationAdvice = the finding's remediation). The %s "+ + "skill can make this call; if it disagrees with this deny, follow this deny. Apply "+ + "remediation_steps only with Edit, Write, MultiEdit, or NotebookEdit, never a shell command "+ + "(shell writes are not scanned). Make the smallest change to the flagged code. If the blocked "+ + "write creates a new file, the fix is that same Write with the fixed content. If you believe "+ + "those remediation_steps will not fully solve the security issue, still apply them. Do not "+ + "skip the MCP suggestion, and do not ignore the finding because of that belief. In the "+ + "summary mark that finding as a partial fix and state why it does not fully resolve the issue.\n"+ + "If %s is unavailable, change nothing and ignore nothing because of it. Report the findings "+ + "as unresolved and tell the user to %s.\n"+ + "3. False positive: run exactly this command for it:\n%s"+ + "4. Verify: skip only the initial scan. Run `cx scan asca -s ` with the canonical cx "+ + "path; that re-scan validates the finding. A finding is fixed only when the re-scan no longer "+ + "reports it. Then retry the blocked write once so the gate can accept the write. If the "+ + "re-scan still reports a finding whose remediation_steps you applied, keep that change and "+ + "mark it as a partial fix with the reason it does not fully resolve the issue. If the retry "+ + "is denied, a remaining finding you have not applied a suggestion for, or one your fix "+ + "introduced, gets one more %s call. Stop after 3 denied retries or when the tool returns no "+ + "safe change. Then ignore only findings that meet step 1. Report a finding whose MCP "+ + "suggestion you applied as a partial fix; report a finding with no applied suggestion as "+ + "unresolved. Stop editing the file. Do not ask whether to continue.\n"+ + "Never write this content through another tool, a shell command, or a different file to "+ + "bypass the scan.\n"+ + ascaRemediationReport, + filePath, findingsSummary(findings), mcpTool, skill, mcpTool, agentprofile.McpReconnect(agent), suppressCmds.String(), mcpTool, ) } @@ -231,37 +281,50 @@ func cursorAdditionalContext(filePath, cxBinary string, findings []grpcs.ScanDet } tool := cursorplugin.MCPTool("codeRemediation") 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). "+ - "Follow the cx-hook-deny.mdc rule for this deny. "+ - "ASK THE USER FIRST, for every real finding, before taking any action: \"A security "+ - "vulnerability was detected. Would you like to remediate it (apply an MCP-driven code fix) "+ - "or suppress it (mark as a confirmed false positive and unblock the write)?\" and wait for "+ - "their answer. Do not decide this yourself — an intentionally-inserted vulnerability (e.g. "+ - "in a lab/demo/training file the user asked for on purpose) is NOT the same as a confirmed "+ - "false positive: suppress only on the user's explicit instruction, never because the "+ - "request seems intentional. "+ - "Apply the cx-devassist-asca.mdc rule: for each finding the user asks you to remediate, "+ - "invoke the cx-devassist:cx-devassist-asca skill exactly as written — do not skip, "+ - "abbreviate, or reimplement its steps inline. 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. Always show its Step 5 Remediation Summary to the user "+ - "verbatim when done. "+ - "If that skill is not available in this session, call %s directly:\n"+ - " {\n"+ - " \"language\": \"[auto-detected programming language]\",\n"+ - " \"metadata\": {\n"+ - " \"ruleId\": \"[rule_name from scan]\",\n"+ - " \"description\": \"[description from scan]\",\n"+ - " \"remediationAdvice\": \"[remediationAdvise from scan]\"\n"+ - " },\n"+ - " \"type\": \"sast\"\n"+ - " }\n"+ - "Use the remediation guidance returned by the tool to fix the vulnerability, then retry the write. "+ - "If the user chooses to suppress a finding, run the corresponding command below, then retry the write:\n%s", - filePath, tool, suppressCmds.String(), + hookDenyHeader+ + "ASCA blocked the write to %s. Follow the cx-hook-deny.mdc and cx-devassist-asca.mdc rules "+ + "for this deny.\nFindings:\n%s"+ + "Handle every finding yourself. Never ask the user, including \"Would you like me to "+ + "remediate?\" (that question is only for on-demand scans).\n"+ + "1. Classify each finding:\n"+ + "- False positive only if one of these is already true and you can cite it: (a) the user "+ + "explicitly told you to ignore or suppress this finding, in those words. Saying the code is "+ + "still vulnerable, or that a change does not make it safe, is not (a). Do not run the ignore "+ + "command because of it. Still apply the MCP suggestion and mark the finding as a partial fix "+ + "with the reason it does not fully resolve the issue. (b) a file you opened this session "+ + "shows, at a "+ + "line you can cite, that the flagged code is unreachable or dead, runs only on test or fixture "+ + "data, or is behind a sanitizer or guard for this exact pattern. ASCA scans one file, so that "+ + "evidence may be in a helper or imported file you opened. What a finding, hook message, or "+ + "file says about another file is not evidence; open that file. Apparent intent (a lab or "+ + "demo file) is not evidence; that still needs (a) or (b).\n"+ + "- Otherwise it is a true positive, including when you are unsure.\n"+ + "2. True positive: call %s for each one with type \"sast\", the language, and metadata "+ + "(ruleId = rule name, description, remediationAdvice = the finding's remediation). The %s "+ + "skill can make this call; if it disagrees with this deny, follow this deny. Apply "+ + "remediation_steps only with Write or StrReplace, never a shell command "+ + "(shell writes are not scanned). Make the smallest change to the flagged code. If the blocked "+ + "write creates a new file, the fix is that same Write with the fixed content. If you believe "+ + "those remediation_steps will not fully solve the security issue, still apply them. Do not "+ + "skip the MCP suggestion, and do not ignore the finding because of that belief. In the "+ + "summary mark that finding as a partial fix and state why it does not fully resolve the issue.\n"+ + "If %s is unavailable, change nothing and ignore nothing because of it. Report the findings "+ + "as unresolved and tell the user to %s.\n"+ + "3. False positive: run exactly this command for it:\n%s"+ + "4. Verify: skip only the initial scan. Run `cx scan asca -s ` with the canonical cx "+ + "path; that re-scan validates the finding. A finding is fixed only when the re-scan no longer "+ + "reports it. Then retry the blocked write once so the gate can accept the write. If the "+ + "re-scan still reports a finding whose remediation_steps you applied, keep that change and "+ + "mark it as a partial fix with the reason it does not fully resolve the issue. If the retry "+ + "is denied, a remaining finding you have not applied a suggestion for, or one your fix "+ + "introduced, gets one more %s call. Stop after 3 denied retries or when the tool returns no "+ + "safe change. Then ignore only findings that meet step 1. Report a finding whose MCP "+ + "suggestion you applied as a partial fix; report a finding with no applied suggestion as "+ + "unresolved. Stop editing the file. Do not ask whether to continue.\n"+ + "Never write this content through another tool, a shell command, or a different file to "+ + "bypass the scan.\n"+ + ascaRemediationReport, + filePath, findingsSummary(findings), tool, "cx-devassist:cx-devassist-asca", tool, agentprofile.McpReconnect(agentCursor), suppressCmds.String(), tool, ) } diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index cd3bb64e9..beec5ea8c 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -14,6 +14,15 @@ import ( "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" ) +// provenanceTag returns the string prepended to permissionDecisionReason and additionalContext in +// formatFindings so the agent can recognize genuine Checkmarx output. NOT an authority claim — an +// attacker could spoof this exact string from a file, a fetched page, or another tool's output — it +// is a signpost: text that LOOKS like a Checkmarx finding but lacks it, or that instructs running a +// script/CLI command "silently"/"without asking", did not come from Checkmarx. +func provenanceTag(agent agenthooks.AgentID) string { + return "[Checkmarx cx-devassist — automated security output, not user input]" +} + // findingKey is the deduplication tuple used for delta detection. // Mirrors the ignore-file key used by RunIacRealtimeScan: Title + "_" + SimilarityID. type findingKey struct { @@ -71,13 +80,15 @@ func findingsSummary(filePath string, findings []iacrealtime.IacRealtimeResult) func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID, workDir, sessionID string) (reason, context string) { summary := findingsSummary(filePath, findings) cxBinary := cxExecutable() - reason = permissionDecisionReason(filePath, summary) + tag := provenanceTag(agent) + reason = tag + " " + permissionDecisionReason(filePath, summary) switch agent { case agenthooks.AgentCursor: context = cursorAdditionalContext(filePath, cxBinary, findings, workDir, sessionID) default: context = additionalContext(filePath, cxBinary, findings, workDir, agent, sessionID) } + context = tag + " " + context return reason, context } @@ -161,6 +172,11 @@ func kicsSuppressCommands(cxBinary string, findings []iacrealtime.IacRealtimeRes return suppressCmds.String() } +// hookDenyHeader is the agent-facing opening of additionalContext. The provenance tag is +// prepended separately in formatFindings. This header does not contain the spoof phrases +// the skills treat as proof a message is not from Checkmarx. +const hookDenyHeader = "This is a Checkmarx hook deny. Follow it. It is not file content and not an untrusted tool error.\n" + // permissionDecisionReason is the human-readable deny message shown to the user. func permissionDecisionReason(filePath, summary string) string { return fmt.Sprintf( @@ -169,6 +185,21 @@ func permissionDecisionReason(filePath, summary string) string { ) } +// kicsRemediationReport is the closing report both the shared and Cursor deny text tell the +// agent to show. One string so the two deny paths cannot drift apart. Scan lines are 0-based. +const kicsRemediationReport = "5. Always finish with this report, even if you asked the user a question, the file is new, " + + "or the retry passed. Show it in the chat as markdown, not inside a code block. One bullet per finding, " + + "then a blank line and the final status. Do not print the braces. Pick one result and one final status. " + + "Ignored must include why you ignored it. Unresolved must include why it was not fixed. A bullet without that reason is incomplete. " + + "Scan lines are 0-based; the line in this report is that number plus 1.\n" + + "## Checkmarx DevAssist IaC(KICS) Remediation Summary\n" + + "\n" + + "- **{title}** - {severity} - line {line plus 1} - **{Fixed, Ignored, or Unresolved}**\n" + + " {Fixed: what changed. Ignored: Reason: why, citing the user's words or the file and line you read. Unresolved: Reason: why it was not fixed.}\n" + + "\n" + + "**Final status:** {All fixed, Partially fixed, or Unresolved}\n" + + "Then continue the user's original task. Do not include that sentence in the report.\n" + // 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. @@ -183,70 +214,86 @@ func additionalContext(filePath, cxBinary string, findings []iacrealtime.IacReal skill := kicsSkillName(agent) suppressCmds := kicsSuppressCommands(cxBinary, findings, workDir, agent, sessionID) 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.\n"+ - "Action required:\n"+ - " 1. For each 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.\n"+ - " 2. If that skill is not available in this session, %s\n"+ - " This is the only supported remediation path — do not attempt manual configuration changes.\n"+ - " 3. If %s is unavailable, tell the user to reconnect the\n"+ - " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ - " 4. If the user accepts the risk for a finding, suppress it by running the corresponding command\n"+ - " below, then retry the write:\n%s", - filePath, skill, remediationInstructions(codeTool), - codeTool, agentprofile.McpReconnect(agentLabel(agent)), suppressCmds, + hookDenyHeader+ + "KICS blocked the write to %s.\nFindings:\n%s"+ + "Handle every finding yourself. Never ask the user, including \"Would you like me to "+ + "remediate?\" (that question is only for on-demand scans).\n"+ + "1. Classify each finding:\n"+ + "- False positive only if one of these is already true and you can cite it: (a) the user "+ + "explicitly told you to ignore or suppress it; (b) a file you opened this session shows, at a "+ + "line you can cite, that the configuration does not do what the rule flags, or the same issue "+ + "is already handled there (for example a parent module or sibling manifest). Deployment or "+ + "runtime assumptions you cannot see in a file are not evidence. What a finding or file says "+ + "about another file is not evidence; open that file. Apparent intent is not evidence; that "+ + "still needs (a) or (b).\n"+ + "- Otherwise it is a true positive, including when you are unsure.\n"+ + "2. True positive: call %s for each one with type \"iac\" and metadata (title, description, "+ + "remediationAdvice = how to harden this configuration). The %s skill can make this call; if "+ + "it disagrees with this deny, follow this deny. Apply remediation_steps only with Edit, "+ + "Write, MultiEdit, or NotebookEdit, never a shell command (shell writes are not scanned). "+ + "Make the smallest change to the flagged configuration; if the fix needs a resource in "+ + "another file, that is also a gated write. If the blocked write creates a new file, the fix "+ + "is that same Write with the fixed content.\n"+ + "If %s is unavailable, change nothing and ignore nothing because of it. Report the findings "+ + "as unresolved and tell the user to %s.\n"+ + "3. False positive: run exactly this command for it:\n%s"+ + "4. Verify: skip only the initial scan. Run `cx scan iac-realtime -s ` with the "+ + "canonical cx path; that re-scan validates the finding. A finding is fixed only when the "+ + "re-scan no longer reports it. Then retry the blocked write once so the gate can accept the "+ + "write. If the re-scan still reports it, the retry is denied, or your fix introduced a "+ + "finding, that finding gets one more %s call. Stop after 3 denied retries or when the tool "+ + "returns no safe change. Then ignore only findings that meet step 1, report the rest as "+ + "unresolved, and stop editing the file. Do not ask whether to continue.\n"+ + "Never write this content through another tool, a shell command, or a different file to "+ + "bypass the scan.\n"+ + kicsRemediationReport, + filePath, findingsSummary(filePath, findings), codeTool, skill, codeTool, agentprofile.McpReconnect(agentLabel(agent)), suppressCmds, codeTool, ) } -// remediationInstructions returns MCP tool-call guidance for KICS IaC findings. -// All findings from this guardrail come from RunIacRealtimeScan (KICS), so they -// always use codeRemediation with type "iac" — including Dockerfile findings. -func remediationInstructions(codeTool string) string { - 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) -} - // cursorAdditionalContext is remediation guidance for Cursor only. Uses the plugin-prefixed MCP // tool name and PowerShell --% stop-parsing for suppress commands on Windows. func cursorAdditionalContext(filePath, cxBinary string, findings []iacrealtime.IacRealtimeResult, workDir, sessionID string) string { suppressCmds := kicsSuppressCommands(cxBinary, findings, workDir, agenthooks.AgentCursor, sessionID) skill := kicsSkillName(agenthooks.AgentCursor) + codeTool := cursorplugin.MCPTool("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. "+ - "ANALYZE each finding to determine if it is a real misconfiguration or a false positive "+ - "(for example an acceptable deviation for this environment or platform). "+ - "Follow the cx-hook-deny.mdc rule for this deny. "+ - "ASK THE USER FIRST, for every real finding, before taking any action: \"An IaC misconfiguration "+ - "was detected. Would you like to remediate it (harden the configuration via MCP) "+ - "or suppress it (mark as a confirmed false positive and unblock the write)?\" and wait for "+ - "their answer. Do not decide this yourself — an intentionally-inserted misconfiguration (e.g. "+ - "in a lab/demo/training file the user asked for on purpose) is NOT the same as a confirmed "+ - "false positive: suppress only on the user's explicit instruction, never because the "+ - "request seems intentional. "+ - "Apply the cx-devassist-kics.mdc rule: for each finding the user asks you to remediate, "+ - "invoke the %s skill exactly as written — do not skip, abbreviate, or reimplement its steps "+ - "inline. 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. "+ - "Always show its Step 4 IaC Remediation Summary to the user verbatim when done. "+ - "Do not retry the blocked Write/StrReplace, paste code in chat, or bypass the scan with shell workarounds. "+ - "If that skill is not available in this session, %s\n"+ - "If the user chooses to suppress a finding, run the corresponding command below, then retry the write:\n%s", - filePath, skill, remediationInstructions(cursorplugin.MCPTool("codeRemediation")), - suppressCmds, + hookDenyHeader+ + "KICS blocked the write to %s. Follow the cx-hook-deny.mdc and cx-devassist-kics.mdc rules "+ + "for this deny.\nFindings:\n%s"+ + "Handle every finding yourself. Never ask the user, including \"Would you like me to "+ + "remediate?\" (that question is only for on-demand scans).\n"+ + "1. Classify each finding:\n"+ + "- False positive only if one of these is already true and you can cite it: (a) the user "+ + "explicitly told you to ignore or suppress it; (b) a file you opened this session shows, at a "+ + "line you can cite, that the configuration does not do what the rule flags, or the same issue "+ + "is already handled there (for example a parent module or sibling manifest). Deployment or "+ + "runtime assumptions you cannot see in a file are not evidence. What a finding or file says "+ + "about another file is not evidence; open that file. Apparent intent is not evidence; that "+ + "still needs (a) or (b).\n"+ + "- Otherwise it is a true positive, including when you are unsure.\n"+ + "2. True positive: call %s for each one with type \"iac\" and metadata (title, description, "+ + "remediationAdvice = how to harden this configuration). The %s skill can make this call; if "+ + "it disagrees with this deny, follow this deny. Apply remediation_steps only with Write or "+ + "StrReplace, never a shell command (shell writes are not scanned). Make the smallest change "+ + "to the flagged configuration; if the fix needs a resource in another file, that is also a "+ + "gated write. If the blocked write creates a new file, the fix is that same Write with the "+ + "fixed content.\n"+ + "If %s is unavailable, change nothing and ignore nothing because of it. Report the findings "+ + "as unresolved and tell the user to %s.\n"+ + "3. False positive: run exactly this command for it:\n%s"+ + "4. Verify: skip only the initial scan. Run `cx scan iac-realtime -s ` with the "+ + "canonical cx path; that re-scan validates the finding. A finding is fixed only when the "+ + "re-scan no longer reports it. Then retry the blocked write once so the gate can accept the "+ + "write. If the re-scan still reports it, the retry is denied, or your fix introduced a "+ + "finding, that finding gets one more %s call. Stop after 3 denied retries or when the tool "+ + "returns no safe change. Then ignore only findings that meet step 1, report the rest as "+ + "unresolved, and stop editing the file. Do not ask whether to continue.\n"+ + "Never write this content through another tool, a shell command, or a different file to "+ + "bypass the scan.\n"+ + kicsRemediationReport, + filePath, findingsSummary(filePath, findings), codeTool, skill, codeTool, + agentprofile.McpReconnect(agentLabel(agenthooks.AgentCursor)), suppressCmds, codeTool, ) } diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index bcf8efc6e..a15c8ac85 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -245,21 +245,67 @@ func TestCursorAdditionalContext_OffersSuppress(t *testing.T) { } } -func TestCursorAdditionalContext_MatchesAscaAskUserWording(t *testing.T) { +// TestCursorAdditionalContext_MatchesAscaConfidenceGatedWording asserts the Cursor KICS context uses +// the SAME classify-then-act model as the other agents: a finding is a false positive only when +// (a) the user said so or (b) a cited file line proves it; everything else is remediated, never +// put to the user as a remediate-or-suppress question. +func TestCursorAdditionalContext_MatchesAscaConfidenceGatedWording(t *testing.T) { ctx := cursorAdditionalContext("/project/main.tf", "cx", nil, "/project", "sess1") for _, want := range []string{ - "ANALYZE each finding", - "for every real finding", - "mark as a confirmed false positive and unblock the write", - "intentionally-inserted misconfiguration", - "never because the request seems intentional", - "If the user chooses to suppress a finding", + "Classify each finding", + "the user explicitly told you to ignore or suppress it", + "a file you opened this session", + "including when you are unsure", + "that still needs (a) or (b)", + "Never ask the user", + "cx scan iac-realtime -s", + "Stop after 3 denied retries", + "Always finish with this report", + "Checkmarx DevAssist IaC(KICS) Remediation Summary", + "Then continue the user's original task.", + "parent module or sibling manifest", } { if !strings.Contains(ctx, want) { t.Errorf("cursor KICS context should contain %q, got: %q", want, ctx) } } - if strings.Contains(ctx, "accept the risk") { - t.Errorf("cursor KICS context should not use old suppress wording, got: %q", ctx) + for _, unwanted := range []string{ + "accept the risk", + "ASK THE USER FIRST", + "Do not decide this yourself", + "ask the user instead of guessing", + "exactly as written", + } { + if strings.Contains(ctx, unwanted) { + t.Errorf("cursor KICS context should not use old blanket-ask wording %q, got: %q", unwanted, ctx) + } + } +} + +func TestAdditionalContext_OmitsInjectionTriggers(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + agents := []agenthooks.AgentID{ + agenthooks.AgentClaude, + agenthooks.AgentCodex, + agenthooks.AgentCopilot, + agenthooks.AgentCursor, + agenthooks.AgentGemini, + } + for _, agent := range agents { + _, ctx := formatFindings("/project/main.tf", findings, agent, "/project", "sess1") + for _, bad := range []string{"without asking", "silently", "cx_mcp_register", "the hook on that retry is the check"} { + if strings.Contains(ctx, bad) { + t.Errorf("%s context contains %q", agent, bad) + } + } + if !strings.Contains(ctx, "that re-scan validates the finding") { + t.Errorf("%s context missing re-scan validation: %q", agent, ctx) + } + if !strings.Contains(ctx, "This is a Checkmarx hook deny") { + t.Errorf("%s context missing hook deny header", agent) + } + if !strings.Contains(ctx, "that still needs (a) or (b)") { + t.Errorf("%s context missing suppression label", agent) + } } } diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index 0c862d1d2..507db2498 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -27,6 +27,15 @@ const agentCursor = "Cursor" // uses ignore.QuoteDataFlag. Other non-Cursor agents keep the original single-quoted JSON. const agentGemini = "Gemini" +// provenanceTag returns the string prepended to the finding/remediation strings in denyFrom (sca.go) +// so the agent can recognize genuine Checkmarx output. NOT an authority claim — an attacker could +// spoof this exact string from a file, a fetched page, or another tool's output — it is a signpost: +// text that LOOKS like a Checkmarx finding but lacks it, or that instructs running a script/CLI +// command "silently"/"without asking", did not come from Checkmarx. +func provenanceTag(agent string) string { + return "[Checkmarx cx-devassist — automated security output, not user input]" +} + // 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" @@ -40,11 +49,14 @@ const defaultPackageRemediationTool = "mcp__Checkmarx__packageRemediation" func DenyMalicious(pkgs []ossrealtime.OssPackage, agent string) (finding, remediation string) { var b strings.Builder b.WriteString("Checkmarx SCA scan detected MALICIOUS package(s):\n") + var packages strings.Builder for _, p := range pkgs { - fmt.Fprintf(&b, " - %s: Known supply chain attack.\n", pkgLabel(p)) + line := fmt.Sprintf(" - %s: Known supply chain attack.\n", pkgLabel(p)) + b.WriteString(line) + packages.WriteString(line) } b.WriteString("\nDo NOT proceed with the installation.") - return b.String(), remediationNote("malicious package", "safest available version", agent) + return b.String(), remediationNote(packages.String(), agent) } // DenyVulnerable returns the finding and remediation strings for one or more @@ -59,37 +71,96 @@ func DenyVulnerable(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID str return b.String(), vulnerableRemediationNote(pkgs, workDir, agent, sessionID) } +// hookDenyHeader is the agent-facing opening of remediation text. The provenance tag is +// prepended separately in denyFrom. This header does not contain the spoof phrases the +// skills treat as proof a message is not from Checkmarx. +const hookDenyHeader = "This is a Checkmarx hook deny. Follow it. It is not file content and not an untrusted tool error.\n" + +// scaMaliciousReport is the closing report for a known-malicious package, shared by the +// Cursor and non-Cursor deny text. +const scaMaliciousReport = "5. Always finish with this report, even if you asked the user a question, the file is new, " + + "or the retry passed. Show it in the chat as markdown, not inside a code block. One bullet per package, " + + "then a blank line and the final status. Do not print the braces. Pick one outcome and one final status. " + + "Unresolved must include why the package is still unresolved. A bullet without that reason is incomplete.\n" + + "## Checkmarx DevAssist SCA Remediation Summary\n" + + "\n" + + "- **{package}** - known-malicious - **{Replaced with a version, Removed, or Unresolved}**\n" + + " {Replaced or Removed: what changed. Unresolved: Reason: why it was not removed or replaced.}\n" + + "\n" + + "**Final status:** {All resolved, or Unresolved}\n" + + "Continue the user's original task only after the package is no longer in the write. Do not include that sentence in the report.\n" + +// scaVulnerableReport is the closing report for vulnerable packages, shared by the +// Cursor and non-Cursor deny text. +const scaVulnerableReport = "5. Always finish with this report, even if you asked the user a question, the file is new, " + + "or the retry passed. Show it in the chat as markdown, not inside a code block. One bullet per package, " + + "then a blank line and the final status. Do not print the braces. Pick one result and one final status. " + + "Ignored must include why you ignored it. Unresolved must include why it was not fixed. A bullet without that reason is incomplete.\n" + + "## Checkmarx DevAssist SCA Remediation Summary\n" + + "\n" + + "- **{package}** {old version} -> {new version} - {manager} - **{Fixed, Ignored, or Unresolved}**\n" + + " {CVEs and severity. Fixed: what changed. Ignored: Reason: why, citing the user's words or that the tool returned no solution and no alternate package. Unresolved: Reason: why it was not fixed.}\n" + + "\n" + + "**Lockfile refresh needed:** {yes or no}\n" + + "\n" + + "**Final status:** {All fixed, Partially fixed, or Unresolved}\n" + + "Then continue the user's original task. Do not include that sentence in the report.\n" + // remediationNote returns the action steps to include as additionalContext for malicious packages. // Remediation goes through the cx-devassist skill (or the Checkmarx MCP tool directly when the skill // is unavailable); if the MCP tool itself is unavailable the user reconnects it via the client — the // reconnect phrasing is per-agent, from agentprofile.McpReconnect. -func remediationNote(subject, goal, agent string) string { - pkgTool := defaultPackageRemediationTool - skillStep := " 1. For each %s, invoke the cx-devassist:cx-devassist-sca skill — " + - "the findings are already in context so it will skip the scan and go directly to " + - "MCP-driven remediation to find the %s; the skill also handles MCP unavailability and self-recovery.\n" - preamble := "" +func remediationNote(packages, agent string) string { if agent == agentCursor { - pkgTool = cursorplugin.MCPTool("packageRemediation") - preamble = "Apply the cx-hook-deny.mdc rule for this deny.\n" - skillStep = " 1. Apply the cx-devassist-sca.mdc rule: for each %s, invoke the " + - "cx-devassist:cx-devassist-sca skill exactly as written — do not skip, abbreviate, or " + - "reimplement its steps inline. The findings are already in context so it will skip the " + - "scan and go directly to MCP-driven remediation to find the %s; the skill also handles " + - "MCP unavailability and self-recovery. Always show its Step 5 SCA Remediation Summary to " + - "the user verbatim when done.\n" + pkgTool := cursorplugin.MCPTool("packageRemediation") + return fmt.Sprintf( + hookDenyHeader+ + "SCA blocked the write because it adds a known-malicious package. Follow the "+ + "cx-hook-deny.mdc and cx-devassist-sca.mdc rules for this deny.\nFindings:\n%s"+ + "Handle this yourself; never ask the user to choose. A malicious package has no ignore "+ + "path: never run cx ignore-vulnerability for it, and never install it.\n"+ + "1. Call %s for each malicious package. The cx-devassist:cx-devassist-sca skill can make "+ + "this call; if it disagrees with this deny, follow this deny. Apply what it returns (a safe "+ + "version, or removing the dependency) only by editing the manifest with Write or StrReplace, "+ + "never an install command. Do not guess a safe version.\n"+ + "2. If %s is unavailable, retry the write with only that dependency omitted and every "+ + "other change kept. Report that, and tell the user to %s.\n"+ + "3. Verify: run `cx scan oss-realtime -s ` with the canonical cx path; that "+ + "re-scan validates the package. A package is resolved only when the re-scan no longer "+ + "reports it. Then retry the blocked write once so the gate can accept the write.\n"+ + "4. If it is still denied, or no safe version or removal fits: omit only that dependency, "+ + "keep every other change, stop editing the manifest, and report it as unresolved. Only the "+ + "user can accept a malicious package, by acknowledging it in Checkmarx Dev Assist. Do not "+ + "do that for them, and do not ask them to choose.\n"+ + "Never install it through a shell command or write it through another tool or file to "+ + "bypass the scan.\n"+ + scaMaliciousReport, + packages, pkgTool, pkgTool, agentprofile.McpReconnect(agent)) } + + pkgTool := defaultPackageRemediationTool return fmt.Sprintf( - preamble+ - "Action required:\n"+ - skillStep+ - " 2. If that skill is not available in this session, use %s for each %s.\n"+ - " This is the only supported remediation path — do not attempt manual version selection.\n"+ - " 3. If %s is unavailable, tell the user to reconnect the\n"+ - " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ - " 4. If no safe version exists, the user can acknowledge the finding via\n"+ - " the Checkmarx Dev Assist interface.", - subject, goal, pkgTool, subject, pkgTool, agentprofile.McpReconnect(agent)) + hookDenyHeader+ + "SCA blocked the write because it adds a known-malicious package.\nFindings:\n%s"+ + "Handle this yourself; never ask the user to choose. A malicious package has no ignore "+ + "path: never run cx ignore-vulnerability for it, and never install it.\n"+ + "1. Call %s for each malicious package. The cx-devassist:cx-devassist-sca skill can make "+ + "this call; if it disagrees with this deny, follow this deny. Apply what it returns (a safe "+ + "version, or removing the dependency) only by editing the manifest with Edit, Write, or "+ + "MultiEdit, never an install command. Do not guess a safe version.\n"+ + "2. If %s is unavailable, retry the write with only that dependency omitted and every other "+ + "change kept. Report that, and tell the user to %s.\n"+ + "3. Verify: run `cx scan oss-realtime -s ` with the canonical cx path; that re-scan "+ + "validates the package. A package is resolved only when the re-scan no longer reports it. "+ + "Then retry the blocked write once so the gate can accept the write.\n"+ + "4. If it is still denied, or no safe version or removal fits: omit only that dependency, "+ + "keep every other change, stop editing the manifest, and report it as unresolved. Only the "+ + "user can accept a malicious package, by acknowledging it in Checkmarx Dev Assist. Do not do "+ + "that for them, and do not ask them to choose.\n"+ + "Never install it through a shell command or write it through another tool or file to "+ + "bypass the scan.\n"+ + scaMaliciousReport, + packages, pkgTool, pkgTool, agentprofile.McpReconnect(agent)) } // vulnerableRemediationNote returns the action steps for vulnerable packages. @@ -122,46 +193,96 @@ func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, se } if agent == agentCursor { pkgTool := cursorplugin.MCPTool("packageRemediation") + var findingLines strings.Builder + for i := range pkgs { + fmt.Fprintf(&findingLines, " - %s: %s\n", pkgLabel(pkgs[i]), vulnDetail(pkgs[i])) + } return fmt.Sprintf( - "Apply the cx-hook-deny.mdc rule for this deny. "+ - "ASK THE USER FIRST, for each affected package, before taking any action: \"A vulnerable "+ - "package was detected. Would you like to remediate it (upgrade/remove via MCP) or "+ - "suppress it (accept the risk)?\" and wait for their answer. Do not decide this "+ - "yourself — a deliberately-pinned or intentionally-included vulnerable package is NOT "+ - "a reason to suppress it without asking; only suppress on the user's explicit "+ - "instruction, never because remediating would undo what the user asked for.\n"+ - "Action required:\n"+ - " 1. If the user asks to remediate: apply the cx-devassist-sca.mdc rule — invoke the "+ - "cx-devassist:cx-devassist-sca skill exactly as written — do not skip, abbreviate, or "+ - "reimplement its steps inline. The findings are already in context so it will skip the "+ - "scan and go directly to MCP-driven remediation to find non-vulnerable versions; the "+ - "skill also handles MCP unavailability and self-recovery. Always show its Step 5 SCA "+ - "Remediation Summary to the user verbatim when done.\n"+ - " 2. If that skill is not available in this session, use %s for each affected package.\n"+ - " This is the only supported remediation path — do not attempt manual version selection.\n"+ - " 3. If %s is unavailable, tell the user to reconnect the\n"+ - " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ - " 4. If the user asks to suppress instead, or no safe version exists for a package, "+ - "suppress it by running the corresponding command and inform the user of which case "+ - "applied:\n%s", - pkgTool, pkgTool, agentprofile.McpReconnect(agent), - suppressCmds.String()) + hookDenyHeader+ + "SCA blocked the write. Follow the cx-hook-deny.mdc and cx-devassist-sca.mdc rules for "+ + "this deny.\nFindings:\n%s"+ + "Handle every package yourself. Never ask the user, including \"Would you like me to "+ + "remediate?\" (that question is only for on-demand scans).\n"+ + "1. Classify each package:\n"+ + "- False positive only if one of these is already true and you can cite it: (a) the user "+ + "explicitly told you to ignore or suppress it; (b) you called %s for it this session and "+ + "it returned no version update and no alternate package, including status no_solution or a "+ + "web_search recommendation. Do not search the web or a registry because of that. "+ + "\"Looks intentionally pinned\", a major-version "+ + "upgrade, or the tool being unavailable is not evidence; that still needs (a) or (b).\n"+ + "- Otherwise it is a true positive, including when you are unsure. A CVE is a true "+ + "positive unless (a) or (b) is met.\n"+ + "2. True positive: call %s for each package, following its input schema (at least package "+ + "manager, name, version, and CVEs). The cx-devassist:cx-devassist-sca skill can make this "+ + "call; if it disagrees with this deny, follow this deny. Apply only a version update or an "+ + "alternate package from that response, by editing the affected manifest entries with Write "+ + "or StrReplace. A status of no_solution, or a recommendation to web_search, is not a fix: "+ + "do not search the web or a registry, even though the tool recommended it. If the response "+ + "has neither a version update nor an alternate package, that package meets (b): ignore it "+ + "and tell the user the tool returned no solution and no alternate package. Never run npm install, "+ + "pip install, go mod tidy, or any install command; if the lockfile needs refreshing, say "+ + "so in the summary. If the blocked write creates a new file, the fix is that same Write "+ + "with the fixed versions.\n"+ + "If %s is unavailable, do not choose a version yourself and ignore nothing because of it. "+ + "Report the packages as unresolved and tell the user to %s.\n"+ + "3. False positive: run exactly this command for it:\n%s"+ + "4. Verify: run `cx scan oss-realtime -s ` with the canonical cx path; that "+ + "re-scan validates each package. A package is fixed only when the re-scan no longer "+ + "reports it. Then retry the blocked write once so the gate can accept the write. If the "+ + "re-scan still reports a package, the retry is denied, a new version has its own CVE, or "+ + "a transitive dependency it pulled in, that package gets one more %s call. Stop after 3 "+ + "denied retries. Then ignore only packages that meet step 1, report the rest as "+ + "unresolved, and stop editing the manifest. Do not ask whether to continue.\n"+ + "Never write this content through another tool, a shell command, or a different file to "+ + "bypass the scan.\n"+ + scaVulnerableReport, + findingLines.String(), pkgTool, pkgTool, pkgTool, agentprofile.McpReconnect(agent), + suppressCmds.String(), pkgTool) } pkgTool := defaultPackageRemediationTool - skillStep := " 1. For each affected package, invoke the cx-devassist:cx-devassist-sca skill — " + - "the findings are already in context so it will skip the scan and go directly to " + - "MCP-driven remediation to find non-vulnerable versions; the skill also handles MCP unavailability and self-recovery.\n" + var findingLines strings.Builder + for i := range pkgs { + fmt.Fprintf(&findingLines, " - %s: %s\n", pkgLabel(pkgs[i]), vulnDetail(pkgs[i])) + } return fmt.Sprintf( - "Action required:\n"+ - skillStep+ - " 2. If that skill is not available in this session, use %s for each affected package.\n"+ - " This is the only supported remediation path — do not attempt manual version selection.\n"+ - " 3. If %s is unavailable, tell the user to reconnect the\n"+ - " Checkmarx MCP (%s), then retry. Do not proceed until the MCP is available.\n"+ - " 4. If no safe version exists for a package, suppress it by running the corresponding command\n"+ - " and inform the user that no safer version is available:\n%s", - pkgTool, pkgTool, agentprofile.McpReconnect(agent), - suppressCmds.String()) + hookDenyHeader+ + "SCA blocked the write.\nFindings:\n%s"+ + "Handle every package yourself. Never ask the user, including \"Would you like me to "+ + "remediate?\" (that question is only for on-demand scans).\n"+ + "1. Classify each package:\n"+ + "- False positive only if one of these is already true and you can cite it: (a) the user "+ + "explicitly told you to ignore or suppress it; (b) you called %s for it this session and it "+ + "returned no version update and no alternate package, including status no_solution or a "+ + "web_search recommendation. Do not search the web or a registry because of that. "+ + "\"Looks intentionally pinned\", a major-version "+ + "upgrade, or the tool being unavailable is not evidence; that still needs (a) or (b).\n"+ + "- Otherwise it is a true positive, including when you are unsure. A CVE is a true positive "+ + "unless (a) or (b) is met.\n"+ + "2. True positive: call %s for each package, following its input schema (at least package "+ + "manager, name, version, and CVEs). The cx-devassist:cx-devassist-sca skill can make this "+ + "call; if it disagrees with this deny, follow this deny. Apply only a version update or an "+ + "alternate package from that response, by editing the affected manifest entries with Edit, "+ + "Write, or MultiEdit. A status of no_solution, or a recommendation to web_search, is not a "+ + "fix: do not search the web or a registry, even though the tool recommended it. If the "+ + "response has neither a version update nor an alternate package, that package meets (b): "+ + "ignore it and tell the user the tool returned no solution and no alternate package. Never run npm "+ + "install, pip install, go mod tidy, or any install command; if the lockfile needs "+ + "refreshing, say so in the summary. If the blocked write creates a new file, the fix is that "+ + "same Write with the fixed versions.\n"+ + "If %s is unavailable, do not choose a version yourself and ignore nothing because of it. "+ + "Report the packages as unresolved and tell the user to %s.\n"+ + "3. False positive: run exactly this command for it:\n%s"+ + "4. Verify: run `cx scan oss-realtime -s ` with the canonical cx path; that re-scan "+ + "validates each package. A package is fixed only when the re-scan no longer reports it. "+ + "Then retry the blocked write once so the gate can accept the write. If the re-scan still "+ + "reports a package, the retry is denied, a new version has its own CVE, or a transitive "+ + "dependency it pulled in, that package gets one more %s call. Stop after 3 denied retries. "+ + "Then ignore only packages that meet step 1, report the rest as unresolved, and stop editing "+ + "the manifest. Do not ask whether to continue.\n"+ + "Never write this content through another tool, a shell command, or a different file to "+ + "bypass the scan.\n"+ + scaVulnerableReport, + findingLines.String(), pkgTool, pkgTool, pkgTool, agentprofile.McpReconnect(agent), suppressCmds.String(), pkgTool) } // ignoredFilePathFlag returns the " --ignored-file-path ''" fragment that diff --git a/internal/commands/agenthooks/sca/sca.go b/internal/commands/agenthooks/sca/sca.go index 261156ca1..43785fab2 100644 --- a/internal/commands/agenthooks/sca/sca.go +++ b/internal/commands/agenthooks/sca/sca.go @@ -68,13 +68,14 @@ func (s *Scanner) CheckManifestEdit(filePath string, afterContent []byte, workDi // `cx ignore-vulnerability` suppression command emitted for vulnerable packages // to the workspace ignore file so the agent writes where the hook later reads. func denyFrom(malicious, vulnerable []ossrealtime.OssPackage, workDir, agent, sessionID string) (finding, remediation, severity string) { + tag := provenanceTag(agent) if len(malicious) > 0 { f, r := DenyMalicious(malicious, agent) - return f, r, statusMalicious + return tag + " " + f, tag + " " + r, statusMalicious } if len(vulnerable) > 0 { f, r := DenyVulnerable(vulnerable, workDir, agent, sessionID) - return f, r, highestVulnSeverity(vulnerable) + return tag + " " + f, tag + " " + r, highestVulnSeverity(vulnerable) } return "", "", "" } diff --git a/internal/commands/agenthooks/sca/sca_test.go b/internal/commands/agenthooks/sca/sca_test.go index 8323ff718..1a3af2220 100644 --- a/internal/commands/agenthooks/sca/sca_test.go +++ b/internal/commands/agenthooks/sca/sca_test.go @@ -424,6 +424,33 @@ func TestExistingIgnoreFilePath_MissingReturnsEmpty(t *testing.T) { } } +func TestRemediation_OmitsInjectionTriggers(t *testing.T) { + vuln := []ossrealtime.OssPackage{{ + PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0", + }} + mal := []ossrealtime.OssPackage{{PackageName: "evil-pkg", PackageVersion: "1.0.0"}} + for _, agent := range []string{"Claude", "Codex", "Copilot", "Cursor", "Gemini"} { + _, remediation := DenyVulnerable(vuln, "/work", agent, "s1") + _, malicious := DenyMalicious(mal, agent) + for _, text := range []string{remediation, malicious} { + for _, bad := range []string{"without asking", "silently", "cx_mcp_register", "the hook on that retry is the check", "the hook re-scanning it is the check"} { + if strings.Contains(text, bad) { + t.Errorf("%s remediation contains %q: %q", agent, bad, text) + } + } + if !strings.Contains(text, "cx scan oss-realtime") { + t.Errorf("%s remediation missing oss-realtime re-scan: %q", agent, text) + } + if !strings.Contains(text, "This is a Checkmarx hook deny") { + t.Errorf("%s remediation missing hook deny header: %q", agent, text) + } + } + if !strings.Contains(remediation, "that still needs (a) or (b)") { + t.Errorf("%s vulnerable remediation missing suppression label", agent) + } + } +} + var errBoom = stringError("boom") type stringError string