From c9b4b6faca2b7ba19a7014e893835f94b5927013 Mon Sep 17 00:00:00 2001 From: atishj99 Date: Thu, 6 Aug 2026 11:40:23 +0530 Subject: [PATCH 1/3] cursor changes --- go.mod | 2 +- go.sum | 4 +- internal/commands/agenthooks/cx/install.go | 3 +- .../agenthooks/guardrails/asca/asca_test.go | 43 ++++++++++++++ .../agenthooks/guardrails/asca/delta.go | 48 +++++++++++++++- .../agenthooks/guardrails/kics/delta.go | 37 +++++++++++- .../agenthooks/guardrails/kics/delta_test.go | 46 +++++++++++++-- .../agenthooks/guardrails/kics/kics.go | 20 ++++--- .../agenthooks/guardrails/kics/kics_test.go | 10 ++-- .../agenthooks/guardrails/kics/scanner.go | 8 +-- .../guardrails/kics/scanner_test.go | 56 +++++++++++++++++++ internal/commands/agenthooks/sca/prompts.go | 48 +++++++++++++++- internal/commands/agenthooks/sca/sca_test.go | 37 ++++++++++++ internal/commands/ignore_vulnerability.go | 2 +- .../commands/ignore_vulnerability_test.go | 21 +++++++ .../realtimeengine/ignore/ignorefile.go | 28 +++++++++- .../realtimeengine/ignore/ignorefile_test.go | 47 ++++++++++++++++ 17 files changed, 424 insertions(+), 36 deletions(-) create mode 100644 internal/commands/agenthooks/guardrails/kics/scanner_test.go diff --git a/go.mod b/go.mod index 8e20c14a..8bae057a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkmarx/ast-cli go 1.26.5 require ( - github.com/Checkmarx/ast-cx-hooks v1.0.5 + github.com/Checkmarx/ast-cx-hooks v1.0.6 github.com/Checkmarx/containers-resolver v1.0.34 github.com/Checkmarx/containers-types v1.0.9 github.com/Checkmarx/gen-ai-prompts v0.0.0-20240807143411-708ceec12b63 diff --git a/go.sum b/go.sum index 63662e93..1d165ab1 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Checkmarx/ast-cx-hooks v1.0.5 h1:4Og5JeBBg3SynAErAP76oGKrjoWrlduWRgg1V9IXjWo= -github.com/Checkmarx/ast-cx-hooks v1.0.5/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= +github.com/Checkmarx/ast-cx-hooks v1.0.6 h1:8/Kcl9V0XKeY1vgTKJR6eIfXXoa4c9DgUOBuY1Ms268= +github.com/Checkmarx/ast-cx-hooks v1.0.6/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= github.com/Checkmarx/containers-images-extractor v1.0.22 h1:kJZgwk28LwJZ7Xky+kzwL+JSZOlpwrGsZQhhz4L2t6s= github.com/Checkmarx/containers-images-extractor v1.0.22/go.mod h1:HyzVb8TtTDf56hGlSakalPXtzjJ6VhTYe9fmAcOS+V8= github.com/Checkmarx/containers-resolver v1.0.34 h1:KULN8s8xb1tQtdH4yzHVdwN8GyLqtPCAkFWra10k7V0= diff --git a/internal/commands/agenthooks/cx/install.go b/internal/commands/agenthooks/cx/install.go index 33f6caea..a47e829a 100644 --- a/internal/commands/agenthooks/cx/install.go +++ b/internal/commands/agenthooks/cx/install.go @@ -49,8 +49,9 @@ var Agents = []Agent{ {"cursor-stop", "Cursor agent finished"}, {"cursor-before-shell", "Gate Cursor shell execution"}, {"cursor-before-mcp", "Gate Cursor MCP execution"}, + {"cursor-before-file-write", "Gate Cursor file write (preToolUse)"}, {"cursor-before-file-read", "Gate Cursor file read"}, - {"cursor-after-file-edit", "React to Cursor file edit"}, + {"cursor-after-file-edit", "React to Cursor file edit (postToolUse)"}, {"cursor-before-submit-prompt", "Gate Cursor prompt"}, }, }, diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 3c452cf6..06494cbb 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -392,6 +392,49 @@ func TestAdditionalContext_EmptyFindings_StillContainsRemediationInstruction(t * } } +func TestCursorEscapeJSON_MatchesTheShellCursorActuallyRunsOn(t *testing.T) { + got := cursorEscapeJSON(`{"FileName":"Demo.java"}`) + if runtime.GOOS == "windows" { + // PowerShell double-quoted strings escape an embedded `"` by doubling it; a + // backslash is not a quote-escape there, so `\"` would corrupt the command. + want := `{""FileName"":""Demo.java""}` + if got != want { + t.Errorf("expected doubled-quote escaping on windows (PowerShell), got %q", got) + } + } else { + want := `{\"FileName\":\"Demo.java\"}` + if got != want { + t.Errorf("expected backslash-escaped quotes on unix (bash), got %q", got) + } + } +} + +func TestAdditionalContext_CursorSuppressCommandNeverUsesBackslashEscapingOnWindows(t *testing.T) { + findings := []grpcs.ScanDetail{{FileName: "Demo.java", Line: 5, RuleID: 1027}} + ctx := additionalContext("Demo.java", "cx", findings, "", "Cursor", "sess-1") + if runtime.GOOS == "windows" { + if strings.Contains(ctx, `\"`) { + t.Errorf("cursor suppress command on windows must not use backslash-escaped quotes "+ + "(PowerShell terminates the string early on them), got %q", ctx) + } + if !strings.Contains(ctx, `""FileName""`) { + t.Errorf("expected doubled-quote escaping for PowerShell, got %q", ctx) + } + } +} + +func TestFormatFindings_RoutesCursorQuoting(t *testing.T) { + findings := []grpcs.ScanDetail{{FileName: "a.py", Line: 1, RuleID: 1}} + _, ctx := formatFindings("a.py", findings, "", "Cursor", "sess-1") + if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data "`) { + t.Fatalf("cursor agent should get double-quoted suppress command, got %q", ctx) + } + _, ctx = formatFindings("a.py", findings, "", "Claude", "sess-1") + if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data '`) { + t.Fatalf("claude agent should get single-quoted suppress command, got %q", ctx) + } +} + func TestAdditionalContext_PinsIgnoredFilePathToWorkDir(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, diff --git a/internal/commands/agenthooks/guardrails/asca/delta.go b/internal/commands/agenthooks/guardrails/asca/delta.go index afcf92ab..36577738 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -4,12 +4,22 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "runtime" "strings" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/wrappers/grpcs" ) +// agentCursor identifies Cursor for the shell-quoting branch below. Cursor's CLI +// reformats single-quoted commands into double-quoted ones (notably on Windows +// PowerShell), so its suppression commands need double-quoted JSON with the +// embedded quotes escaped for the shell actually in play (see cursorEscapeJSON) — +// otherwise the reformatted command corrupts the JSON payload or drops +// --ignored-file-path, silently sending the suppression to the wrong file. +const agentCursor = "Cursor" + // findingKey is the deduplication tuple used for delta detection. // Mirrors the cx-devassist plugin's matching logic. type findingKey struct { @@ -88,6 +98,34 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// cursorIgnoredFilePathFlag is the Cursor-specific variant of ignoredFilePathFlag. It uses +// double quotes and converts backslashes to forward slashes so the flag survives Windows +// PowerShell and cmd.exe without the agent needing to re-quote it. (Cursor agents on Windows +// tend to reformat single-quoted shell commands into double-quoted form and drop flags that +// have complex quoting, causing the ignore entry to land in the wrong directory.) +func cursorIgnoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + p := filepath.ToSlash(ignore.PathFor(workDir)) + return fmt.Sprintf(` --ignored-file-path "%s"`, p) +} + +// cursorEscapeJSON escapes the embedded `"` in a JSON payload so it survives being placed +// inside a double-quoted argument on the shell that actually runs the Cursor agent's command: +// PowerShell on Windows, bash/zsh elsewhere. This runs on the developer's own machine (inside +// the cx process), so runtime.GOOS reflects that shell choice directly. The two shells disagree +// on how to escape an embedded double quote — bash accepts a backslash-escaped `\"`, but +// PowerShell's double-quoted strings do NOT treat `\` as an escape character at all: `\"` ends +// the string early (backslash is literal, then the quote closes it), corrupting everything +// after the first embedded quote. PowerShell requires the quote to be doubled (`""`) instead. +func cursorEscapeJSON(data string) string { + if runtime.GOOS == "windows" { + return strings.ReplaceAll(data, `"`, `""`) + } + return strings.ReplaceAll(data, `"`, `\"`) +} + // optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the // child `cx ignore-vulnerability` process via --optional-flags, which reads them through // utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. @@ -115,7 +153,6 @@ 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. func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string { - ignoreFlag := ignoredFilePathFlag(workDir) provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, f := range findings { @@ -124,7 +161,14 @@ 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) + if agent == agentCursor { + ignoreFlag := cursorIgnoredFilePathFlag(workDir) + escapedData := cursorEscapeJSON(string(data)) + fmt.Fprintf(&suppressCmds, ` %s ignore-vulnerability --scan-type asca --data "%s"%s%s`+"\n", cxBinary, escapedData, ignoreFlag, provenance) + } else { + ignoreFlag := ignoredFilePathFlag(workDir) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + } } return fmt.Sprintf( "ASCA detected vulnerabilities in %s. "+ diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 9503b949..6248345d 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + agenthooks "github.com/Checkmarx/ast-cx-hooks" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" ) @@ -59,9 +60,17 @@ func findingsSummary(filePath string, findings []iacrealtime.IacRealtimeResult) } // formatFindings builds the two verdict fields delivered to the agent. -func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult) (reason, context string) { +// Cursor receives cursorAdditionalContext (folded into agent_message); other agents +// receive the original additionalContext (e.g. Claude additionalContext). +func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) (reason, context string) { summary := findingsSummary(filePath, findings) - return permissionDecisionReason(filePath, summary), additionalContext(filePath, findings) + reason = permissionDecisionReason(filePath, summary) + if agent == agenthooks.AgentCursor { + context = cursorAdditionalContext(filePath, findings) + } else { + context = additionalContext(filePath, findings) + } + return reason, context } // permissionDecisionReason is the human-readable deny message shown to the user. @@ -76,6 +85,7 @@ func permissionDecisionReason(filePath, summary string) string { // 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 { var findingList strings.Builder for _, f := range findings { @@ -110,3 +120,26 @@ func additionalContext(filePath string, findings []iacrealtime.IacRealtimeResult filePath, findingList.String(), ) } + +// cursorAdditionalContext is remediation guidance for Cursor only. Cursor has no +// additionalContext field on preToolUse — ast-cx-hooks folds this into agent_message. +func cursorAdditionalContext(filePath string, findings []iacrealtime.IacRealtimeResult) string { + var findingList strings.Builder + for _, f := range findings { + line := 0 + if len(f.Locations) > 0 { + line = f.Locations[0].Line + } + fmt.Fprintf(&findingList, " - line %d [%s] %s: %s\n", + line, f.Severity, f.Title, f.Description) + } + return fmt.Sprintf( + "KICS IaC findings in %s — apply the cx-devassist-kics.mdc rule exactly. "+ + "Do not retry the blocked Write/StrReplace, paste code in chat, or bypass the scan with shell workarounds.\n\n"+ + "Fix every finding below (deterministic IaC rule matches — not false positives). "+ + "For each, call mcp__Checkmarx__imageRemediation with type \"iac\" and metadata from the finding "+ + "(title, description, remediationAdvice), apply remediation_steps, then retry the write:\n"+ + "%s", + filePath, findingList.String(), + ) +} diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index 09f6c476..919cf645 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + agenthooks "github.com/Checkmarx/ast-cx-hooks" "github.com/checkmarx/ast-cli/internal/services/realtimeengine" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" ) @@ -83,7 +84,7 @@ func TestNewFindings_DeltaDedup_SameKeyNotDoubled(t *testing.T) { func TestFormatFindings_ReasonContainsKICS(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "KICS") { t.Errorf("reason should contain KICS, got: %q", reason) } @@ -91,7 +92,7 @@ func TestFormatFindings_ReasonContainsKICS(t *testing.T) { func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "/project/Dockerfile") { t.Errorf("reason should contain file path, got: %q", reason) } @@ -99,7 +100,7 @@ func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "HIGH") { t.Errorf("reason should contain severity, got: %q", reason) } @@ -110,7 +111,7 @@ func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings) + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "fix") && !strings.Contains(ctx, "Fix") { t.Errorf("context should contain fix instruction, got: %q", ctx) } @@ -118,8 +119,43 @@ func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { func TestFormatFindings_ContextContainsDoNotBypass(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings) + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "bypass") { t.Errorf("context should warn against bypass, got: %q", ctx) } } + +func TestCursorAdditionalContext_UsesImageRemediation(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + ctx := cursorAdditionalContext("/project/Dockerfile", findings) + if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { + t.Errorf("cursor KICS context should use imageRemediation, got: %q", ctx) + } + if strings.Contains(ctx, "codeRemediation") { + t.Errorf("cursor KICS context should not use codeRemediation, got: %q", ctx) + } + if !strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Errorf("cursor KICS context should reference cx-devassist-kics.mdc rule, got: %q", ctx) + } +} + +func TestFormatFindings_RoutesCursorContext(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentCursor) + if !strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Fatalf("cursor agent should get context with rule reference, got %q", ctx) + } + if strings.Contains(ctx, "MANDATORY NEXT STEPS") { + t.Fatalf("cursor context should not have verbose MANDATORY NEXT STEPS block, got %q", ctx) + } + if !strings.Contains(ctx, "imageRemediation") { + t.Fatalf("cursor KICS context should reference imageRemediation, got %q", ctx) + } + _, ctx = formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + if strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Fatalf("claude agent should not get cursor-specific rule reference, got %q", ctx) + } + if !strings.Contains(ctx, "codeRemediation") { + t.Fatalf("claude KICS context should reference codeRemediation, got %q", ctx) + } +} diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index 10048f77..a470e8e5 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -67,7 +67,8 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas } defer cleanupNew() - newResults, err := svc.scan(stagedNew) + ignoreFilePath := existingIgnoreFilePath(ev.WorkDir) + newResults, err := svc.scan(stagedNew, ignoreFilePath) if err != nil { // Fail open: Docker unavailable, image pull failure, feature flag disabled, etc. return false, "", "" @@ -78,7 +79,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas // For new files (no original content), every finding is new if originalContent == "" { - r, c := formatFindings(ev.FilePath, newResults) + r, c := formatFindings(ev.FilePath, newResults, ev.Agent) return true, r, c } @@ -89,7 +90,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas } defer cleanupOrig() - origResults, err := svc.scan(stagedOrig) + origResults, err := svc.scan(stagedOrig, ignoreFilePath) if err != nil { // Fail open on original scan error return false, "", "" @@ -100,15 +101,16 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas return false, "", "" } - r, c := formatFindings(ev.FilePath, newFindings) + r, c := formatFindings(ev.FilePath, newFindings, ev.Agent) return true, r, c } -// existingIgnoreFilePath returns the default realtime ignore-file path only when it -// exists on disk. The IaC realtime service logs a warning and skips ignore filtering -// when a missing path is passed, but we keep the pattern consistent with ASCA. -func existingIgnoreFilePath() string { - p := ignore.DefaultPath() +// existingIgnoreFilePath returns the realtime ignore-file path anchored at workDir only +// when it exists on disk. Mirrors the ASCA pattern: anchor to workDir so the hook reads +// from the same absolute path that `cx ignore-vulnerability` writes to when run from the +// project root. Returns "" (no filtering) until the user creates the file. +func existingIgnoreFilePath(workDir string) string { + p := ignore.PathFor(workDir) if _, err := os.Stat(p); err == nil { return p } diff --git a/internal/commands/agenthooks/guardrails/kics/kics_test.go b/internal/commands/agenthooks/guardrails/kics/kics_test.go index 7e97777f..4fd2ddd2 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics_test.go +++ b/internal/commands/agenthooks/guardrails/kics/kics_test.go @@ -96,7 +96,7 @@ func makeResult(title, similarityID, severity, description string, line int) iac func TestScanFileEdit_NewFileWithFinding_Blocked(t *testing.T) { finding := makeResult("Privileged Container", "sim123", "HIGH", "Container runs as privileged", 5) - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return []iacrealtime.IacRealtimeResult{finding}, nil }) @@ -123,7 +123,7 @@ func TestScanFileEdit_NewFileWithFinding_Blocked(t *testing.T) { func TestScanFileEdit_EditWithNoNewFindings_NotBlocked(t *testing.T) { existingFinding := makeResult("Privileged Container", "sim123", "HIGH", "Container runs as privileged", 5) - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { // Both original and new have the same finding — delta is empty return []iacrealtime.IacRealtimeResult{existingFinding}, nil }) @@ -147,7 +147,7 @@ func TestScanFileEdit_EditWithNoNewFindings_NotBlocked(t *testing.T) { } func TestScanFileEdit_ScanError_FailOpen(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return nil, fmt.Errorf("docker daemon not running") }) @@ -164,7 +164,7 @@ func TestScanFileEdit_ScanError_FailOpen(t *testing.T) { } func TestScanFileEdit_UnsupportedFile_NotBlocked(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { t.Error("scan should not be called for unsupported file types") return nil, nil }) @@ -182,7 +182,7 @@ func TestScanFileEdit_UnsupportedFile_NotBlocked(t *testing.T) { } func TestScanFileEdit_EmptyNewContent_NotBlocked(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return nil, nil }) diff --git a/internal/commands/agenthooks/guardrails/kics/scanner.go b/internal/commands/agenthooks/guardrails/kics/scanner.go index e1e99a12..8c451dd7 100644 --- a/internal/commands/agenthooks/guardrails/kics/scanner.go +++ b/internal/commands/agenthooks/guardrails/kics/scanner.go @@ -11,7 +11,7 @@ import ( type Scanner struct { jwt wrappers.JWTWrapper ff wrappers.FeatureFlagsWrapper - scan func(path string) ([]iacrealtime.IacRealtimeResult, error) + scan func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error) } // NewScanner returns a Scanner backed by the given wrappers. @@ -23,11 +23,11 @@ func NewScanner(jwt wrappers.JWTWrapper, ff wrappers.FeatureFlagsWrapper) *Scann // NewScannerWithFunc returns a Scanner whose scan call is replaced with f. // For unit tests only. -func NewScannerWithFunc(f func(path string) ([]iacrealtime.IacRealtimeResult, error)) *Scanner { +func NewScannerWithFunc(f func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error)) *Scanner { return &Scanner{scan: f} } -func (s *Scanner) runRealScan(path string) ([]iacrealtime.IacRealtimeResult, error) { +func (s *Scanner) runRealScan(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error) { svc := iacrealtime.NewIacRealtimeService(s.jwt, s.ff, iacrealtime.NewContainerManager()) - return svc.RunIacRealtimeScan(path, "", existingIgnoreFilePath()) + return svc.RunIacRealtimeScan(path, "", ignoreFilePath) } diff --git a/internal/commands/agenthooks/guardrails/kics/scanner_test.go b/internal/commands/agenthooks/guardrails/kics/scanner_test.go new file mode 100644 index 00000000..51328ded --- /dev/null +++ b/internal/commands/agenthooks/guardrails/kics/scanner_test.go @@ -0,0 +1,56 @@ +//go:build !integration + +package kics + +import ( + "os" + "path/filepath" + "testing" + + "github.com/checkmarx/ast-cli/internal/params" +) + +const enginePodman = "podman" + +// ── resolveContainerEngine ─────────────────────────────────────────────────── + +func TestResolveContainerEngine_EnvOverrideWins(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, enginePodman) + if got := resolveContainerEngine(); got != enginePodman { + t.Errorf("expected env override %q, got %q", enginePodman, got) + } +} + +func TestResolveContainerEngine_EnvOverrideArbitraryValue(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, "nerdctl") + if got := resolveContainerEngine(); got != "nerdctl" { + t.Errorf("expected env override %q, got %q", "nerdctl", got) + } +} + +func TestResolveContainerEngine_FallsBackToDefaultWhenNothingResolves(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, "") + // Point PATH somewhere with no docker/podman binaries so auto-detection + // finds nothing and falls back to the default. + emptyDir := t.TempDir() + t.Setenv("PATH", emptyDir) + + if got := resolveContainerEngine(); got != defaultContainerEngine { + t.Errorf("expected fallback default %q, got %q", defaultContainerEngine, got) + } +} + +func TestResolveContainerEngine_AutoDetectsFromPath(t *testing.T) { + t.Setenv(params.HooksContainerEngineEnv, "") + + dir := t.TempDir() + podmanPath := filepath.Join(dir, enginePodman) + if err := os.WriteFile(podmanPath, []byte("#!/bin/sh\n"), 0o700); err != nil { + t.Fatalf("failed to create fake podman binary: %v", err) + } + t.Setenv("PATH", dir) + + if got := resolveContainerEngine(); got != enginePodman { + t.Errorf("expected auto-detected %q, got %q", enginePodman, got) + } +} diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index 4be68391..1b9e9006 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "runtime" "strings" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/agentprofile" @@ -11,6 +13,14 @@ import ( "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime" ) +// agentCursor identifies Cursor for the shell-quoting branch below. Cursor's CLI +// reformats single-quoted commands into double-quoted ones (notably on Windows +// PowerShell), so its suppression commands need double-quoted JSON with the +// embedded quotes escaped for the shell actually in play (see cursorEscapeJSON) — +// otherwise the reformatted command corrupts the JSON payload or drops +// --ignored-file-path, silently sending the suppression to the wrong file. +const agentCursor = "Cursor" + // DenyMalicious returns the finding and remediation strings for one or more // packages classified as Malicious. func DenyMalicious(pkgs []ossrealtime.OssPackage, agent string) (finding, remediation string) { @@ -59,7 +69,6 @@ func remediationNote(subject, goal, agent string) string { // and informs the user. func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID string) string { cxBinary := cxExecutable() - ignoreFlag := ignoredFilePathFlag(workDir) provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, p := range pkgs { @@ -68,7 +77,14 @@ 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) + if agent == agentCursor { + ignoreFlag := cursorIgnoredFilePathFlag(workDir) + escapedData := cursorEscapeJSON(string(data)) + fmt.Fprintf(&suppressCmds, ` %s ignore-vulnerability --scan-type sca --data "%s"%s%s`+"\n", cxBinary, escapedData, ignoreFlag, provenance) + } else { + ignoreFlag := ignoredFilePathFlag(workDir) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + } } return fmt.Sprintf( "Action required:\n"+ @@ -99,6 +115,34 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// cursorIgnoredFilePathFlag is the Cursor-specific variant of ignoredFilePathFlag. It uses +// double quotes and converts backslashes to forward slashes so the flag survives Windows +// PowerShell and cmd.exe without the agent needing to re-quote it. (Cursor agents on Windows +// tend to reformat single-quoted shell commands into double-quoted form and drop flags that +// have complex quoting, causing the ignore entry to land in the wrong directory.) +func cursorIgnoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + p := filepath.ToSlash(ignore.PathFor(workDir)) + return fmt.Sprintf(` --ignored-file-path "%s"`, p) +} + +// cursorEscapeJSON escapes the embedded `"` in a JSON payload so it survives being placed +// inside a double-quoted argument on the shell that actually runs the Cursor agent's command: +// PowerShell on Windows, bash/zsh elsewhere. This runs on the developer's own machine (inside +// the cx process), so runtime.GOOS reflects that shell choice directly. The two shells disagree +// on how to escape an embedded double quote — bash accepts a backslash-escaped `\"`, but +// PowerShell's double-quoted strings do NOT treat `\` as an escape character at all: `\"` ends +// the string early (backslash is literal, then the quote closes it), corrupting everything +// after the first embedded quote. PowerShell requires the quote to be doubled (`""`) instead. +func cursorEscapeJSON(data string) string { + if runtime.GOOS == "windows" { + return strings.ReplaceAll(data, `"`, `""`) + } + return strings.ReplaceAll(data, `"`, `\"`) +} + // optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the // child `cx ignore-vulnerability` process via --optional-flags, which reads them through // utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. diff --git a/internal/commands/agenthooks/sca/sca_test.go b/internal/commands/agenthooks/sca/sca_test.go index 20c80cc7..7d89b86f 100644 --- a/internal/commands/agenthooks/sca/sca_test.go +++ b/internal/commands/agenthooks/sca/sca_test.go @@ -5,6 +5,7 @@ package sca import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -236,6 +237,42 @@ func TestDenyVulnerable_EmitsProvenanceOptionalFlags(t *testing.T) { } } +func TestCursorEscapeJSON_MatchesTheShellCursorActuallyRunsOn(t *testing.T) { + got := cursorEscapeJSON(`{"PackageName":"axios"}`) + if runtime.GOOS == "windows" { + // PowerShell double-quoted strings escape an embedded `"` by doubling it; a + // backslash is not a quote-escape there, so `\"` would corrupt the command. + want := `{""PackageName"":""axios""}` + if got != want { + t.Errorf("expected doubled-quote escaping on windows (PowerShell), got %q", got) + } + } else { + want := `{\"PackageName\":\"axios\"}` + if got != want { + t.Errorf("expected backslash-escaped quotes on unix (bash), got %q", got) + } + } +} + +func TestDenyVulnerable_CursorSuppressCommandNeverUsesBackslashEscapingOnWindows(t *testing.T) { + pkgs := []ossrealtime.OssPackage{ + {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, + } + _, remediation := DenyVulnerable(pkgs, "", "Cursor", "sess-9") + if !strings.Contains(remediation, `ignore-vulnerability --scan-type sca --data "`) { + t.Errorf("cursor remediation should use double-quoted suppress command, got %q", remediation) + } + if runtime.GOOS == "windows" { + if strings.Contains(remediation, `\"`) { + t.Errorf("cursor suppress command on windows must not use backslash-escaped quotes "+ + "(PowerShell terminates the string early on them), got %q", remediation) + } + if !strings.Contains(remediation, `""PackageName""`) { + t.Errorf("expected doubled-quote escaping for PowerShell, got %q", remediation) + } + } +} + func TestDenyVulnerable_MultiplePackages_EachGetsIgnoreCommand(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageManager: "npm", PackageName: "lodash", PackageVersion: "4.17.0"}, diff --git a/internal/commands/ignore_vulnerability.go b/internal/commands/ignore_vulnerability.go index 092f7c3c..1f3f22d1 100644 --- a/internal/commands/ignore_vulnerability.go +++ b/internal/commands/ignore_vulnerability.go @@ -122,7 +122,7 @@ func readIgnoreData(cmd *cobra.Command, dataArg string) ([]byte, error) { } return data, nil case strings.HasPrefix(dataArg, "@"): - path := strings.TrimPrefix(dataArg, "@") + path := ignore.NormalizePath(strings.TrimPrefix(dataArg, "@")) data, err := os.ReadFile(path) if err != nil { return nil, errors.Wrapf(err, "failed to read --data file %s", path) diff --git a/internal/commands/ignore_vulnerability_test.go b/internal/commands/ignore_vulnerability_test.go index fb03eee2..f40848e9 100644 --- a/internal/commands/ignore_vulnerability_test.go +++ b/internal/commands/ignore_vulnerability_test.go @@ -63,6 +63,27 @@ func TestIgnoreVulnerability_DataFromFile(t *testing.T) { assert.Len(t, list, 1) } +// TestIgnoreVulnerability_DataFromFile_CursorPosixStyleWindowsRoot reproduces the reported +// failure: Cursor supplies --data as "@/c:/…/finding.json" (a leading slash before the drive +// letter). Without normalization, os.ReadFile rejects it with "The filename, directory name, +// or volume label syntax is incorrect." +func TestIgnoreVulnerability_DataFromFile_CursorPosixStyleWindowsRoot(t *testing.T) { + dir := t.TempDir() + drive := filepath.VolumeName(dir) + if drive == "" { + t.Skip("no drive letter on this platform") + } + findingFile := filepath.Join(dir, "finding.json") + require.NoError(t, os.WriteFile(findingFile, []byte(`{"Title":"github-pat","SecretValue":"ghp_x"}`), 0o600)) + posixStyleFindingFile := "/" + filepath.ToSlash(findingFile) + ignoreFile := filepath.Join(dir, "ignore.json") + + _, err := runIgnoreVulnCmd("", "--scan-type", "secrets", "--data", "@"+posixStyleFindingFile, "--ignored-file-path", ignoreFile) + require.NoError(t, err) + list, _ := ignore.Load(ignoreFile) + assert.Len(t, list, 1) +} + func TestIgnoreVulnerability_DataFromStdin(t *testing.T) { file := filepath.Join(t.TempDir(), "ignore.json") _, err := runIgnoreVulnCmd(`{"ImageName":"ubuntu","ImageTag":"14.04"}`, diff --git a/internal/services/realtimeengine/ignore/ignorefile.go b/internal/services/realtimeengine/ignore/ignorefile.go index 4cb2b610..6a7c6564 100644 --- a/internal/services/realtimeengine/ignore/ignorefile.go +++ b/internal/services/realtimeengine/ignore/ignorefile.go @@ -8,6 +8,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" ) const ( @@ -27,6 +28,28 @@ func DefaultPath() string { return filepath.Join(defaultDir, defaultFileName) } +// NormalizePath canonicalizes a filesystem path that may carry Cursor's Windows +// workspace-root spelling ("/c:/foo/bar" — a leading slash before the drive +// letter) instead of a native one ("c:/foo/bar" / "c:\foo\bar"). ast-cx-hooks' +// Cursor adapter normalizes workDir at ingestion (see its normalizeWorkDir), but +// this is a defensive second layer: it also protects a hand-typed or +// agent-typed path (e.g. an --ignored-file-path or --data @ argument +// copied from an older suggested command, or typed directly by an agent) from +// the same "invalid volume label syntax" failure when it reaches os.ReadFile / +// os.WriteFile / filepath.Join. A path with no leading-slash-drive-letter +// pattern passes through unchanged. +func NormalizePath(path string) string { + r := strings.ReplaceAll(path, "\\", "/") + if len(r) >= 3 && r[0] == '/' && isASCIIDriveLetter(r[1]) && r[2] == ':' { + r = r[1:] + } + return r +} + +func isASCIIDriveLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + // PathFor returns the ignore-file path anchored at workDir — the workspace root the hook // event reports via its "cwd" field — i.e. /.checkmarx/checkmarxIgnoredTempList.json. // When workDir is empty it falls back to the CWD-relative DefaultPath. @@ -40,13 +63,13 @@ func PathFor(workDir string) string { if workDir == "" { return DefaultPath() } - return filepath.Join(workDir, defaultDir, defaultFileName) + return filepath.Join(NormalizePath(workDir), defaultDir, defaultFileName) } // Load reads the ignore file as a list of raw JSON entries. A missing or empty file yields an // empty list (not an error) so the first ignore creates the file cleanly. func Load(path string) ([]json.RawMessage, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(NormalizePath(path)) if err != nil { if os.IsNotExist(err) { return []json.RawMessage{}, nil @@ -107,6 +130,7 @@ func Remove(list []json.RawMessage, entry any) ([]json.RawMessage, bool, error) // Save writes the list as pretty-printed JSON, creating the parent directory if needed. func Save(path string, list []json.RawMessage) error { + path = NormalizePath(path) if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, dirPerm); err != nil { return err diff --git a/internal/services/realtimeengine/ignore/ignorefile_test.go b/internal/services/realtimeengine/ignore/ignorefile_test.go index 6024ed2d..b5a2b1d9 100644 --- a/internal/services/realtimeengine/ignore/ignorefile_test.go +++ b/internal/services/realtimeengine/ignore/ignorefile_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -100,3 +101,49 @@ func TestPathFor_AnchorsAtWorkDir(t *testing.T) { func TestPathFor_EmptyWorkDirFallsBackToDefault(t *testing.T) { assert.Equal(t, DefaultPath(), PathFor("")) } + +// TestPathFor_NormalizesCursorPosixStyleWindowsRoot guards against a real production +// failure: Cursor reports a Windows workspace root as "/c:/foo/bar" (leading slash before +// the drive letter). Without normalization, filepath.Join produces a path Go's os.ReadFile +// rejects with "The filename, directory name, or volume label syntax is incorrect." +func TestPathFor_NormalizesCursorPosixStyleWindowsRoot(t *testing.T) { + got := PathFor("/c:/Cx-Flow/Test/JavaVulnerabilityLabE") + want := filepath.Join("c:/Cx-Flow/Test/JavaVulnerabilityLabE", ".checkmarx", "checkmarxIgnoredTempList.json") + assert.Equal(t, want, got) +} + +func TestNormalizePath(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"posix-style windows root", "/c:/Cx-Flow/Test/JavaVulnerabilityLabE", "c:/Cx-Flow/Test/JavaVulnerabilityLabE"}, + {"posix-style windows root, uppercase drive", "/C:/Users/dev/project", "C:/Users/dev/project"}, + {"native windows backslash path", `c:\Users\dev\project`, "c:/Users/dev/project"}, + {"native windows forward-slash path", "c:/Users/dev/project", "c:/Users/dev/project"}, + {"posix path, no drive letter", "/home/dev/project", "/home/dev/project"}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, NormalizePath(tc.in)) + }) + } +} + +// TestLoad_NormalizesCursorPosixStyleWindowsRoot reproduces the exact reported failure: +// --ignored-file-path passed as "/c:/…/checkmarxIgnoredTempList.json" must not error with +// "invalid volume label syntax" — Load should normalize it and read the (missing) file cleanly. +func TestLoad_NormalizesCursorPosixStyleWindowsRoot(t *testing.T) { + dir := t.TempDir() + drive := filepath.VolumeName(dir) + if drive == "" { + t.Skip("no drive letter on this platform") + } + posixStyle := "/" + strings.TrimSuffix(filepath.ToSlash(dir), "") + "/checkmarxIgnoredTempList.json" + + list, err := Load(posixStyle) + require.NoError(t, err) + assert.Empty(t, list) +} From 3cac686edd766b2586291dd76170e9b5971206d3 Mon Sep 17 00:00:00 2001 From: atishj99 Date: Thu, 6 Aug 2026 11:40:23 +0530 Subject: [PATCH 2/3] cursor changes --- go.mod | 2 +- go.sum | 4 +- internal/commands/agenthooks/cx/install.go | 3 +- .../agenthooks/guardrails/asca/asca_test.go | 43 +++++++++++++++++ .../agenthooks/guardrails/asca/delta.go | 48 ++++++++++++++++++- .../agenthooks/guardrails/kics/delta.go | 37 +++++++++++++- .../agenthooks/guardrails/kics/delta_test.go | 45 +++++++++++++++-- .../agenthooks/guardrails/kics/kics.go | 20 ++++---- .../agenthooks/guardrails/kics/kics_test.go | 10 ++-- .../agenthooks/guardrails/kics/scanner.go | 4 +- internal/commands/agenthooks/sca/prompts.go | 48 ++++++++++++++++++- internal/commands/agenthooks/sca/sca_test.go | 37 ++++++++++++++ internal/commands/ignore_vulnerability.go | 2 +- .../commands/ignore_vulnerability_test.go | 21 ++++++++ .../realtimeengine/ignore/ignorefile.go | 28 ++++++++++- .../realtimeengine/ignore/ignorefile_test.go | 47 ++++++++++++++++++ 16 files changed, 365 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 8e20c14a..8bae057a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/checkmarx/ast-cli go 1.26.5 require ( - github.com/Checkmarx/ast-cx-hooks v1.0.5 + github.com/Checkmarx/ast-cx-hooks v1.0.6 github.com/Checkmarx/containers-resolver v1.0.34 github.com/Checkmarx/containers-types v1.0.9 github.com/Checkmarx/gen-ai-prompts v0.0.0-20240807143411-708ceec12b63 diff --git a/go.sum b/go.sum index 63662e93..1d165ab1 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbi github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/Checkmarx/ast-cx-hooks v1.0.5 h1:4Og5JeBBg3SynAErAP76oGKrjoWrlduWRgg1V9IXjWo= -github.com/Checkmarx/ast-cx-hooks v1.0.5/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= +github.com/Checkmarx/ast-cx-hooks v1.0.6 h1:8/Kcl9V0XKeY1vgTKJR6eIfXXoa4c9DgUOBuY1Ms268= +github.com/Checkmarx/ast-cx-hooks v1.0.6/go.mod h1:GPHk8IJHQlCW7l8ye9/Bij57zYQGRG+pxJPiGgsR8cY= github.com/Checkmarx/containers-images-extractor v1.0.22 h1:kJZgwk28LwJZ7Xky+kzwL+JSZOlpwrGsZQhhz4L2t6s= github.com/Checkmarx/containers-images-extractor v1.0.22/go.mod h1:HyzVb8TtTDf56hGlSakalPXtzjJ6VhTYe9fmAcOS+V8= github.com/Checkmarx/containers-resolver v1.0.34 h1:KULN8s8xb1tQtdH4yzHVdwN8GyLqtPCAkFWra10k7V0= diff --git a/internal/commands/agenthooks/cx/install.go b/internal/commands/agenthooks/cx/install.go index 33f6caea..a47e829a 100644 --- a/internal/commands/agenthooks/cx/install.go +++ b/internal/commands/agenthooks/cx/install.go @@ -49,8 +49,9 @@ var Agents = []Agent{ {"cursor-stop", "Cursor agent finished"}, {"cursor-before-shell", "Gate Cursor shell execution"}, {"cursor-before-mcp", "Gate Cursor MCP execution"}, + {"cursor-before-file-write", "Gate Cursor file write (preToolUse)"}, {"cursor-before-file-read", "Gate Cursor file read"}, - {"cursor-after-file-edit", "React to Cursor file edit"}, + {"cursor-after-file-edit", "React to Cursor file edit (postToolUse)"}, {"cursor-before-submit-prompt", "Gate Cursor prompt"}, }, }, diff --git a/internal/commands/agenthooks/guardrails/asca/asca_test.go b/internal/commands/agenthooks/guardrails/asca/asca_test.go index 3c452cf6..06494cbb 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca_test.go +++ b/internal/commands/agenthooks/guardrails/asca/asca_test.go @@ -392,6 +392,49 @@ func TestAdditionalContext_EmptyFindings_StillContainsRemediationInstruction(t * } } +func TestCursorEscapeJSON_MatchesTheShellCursorActuallyRunsOn(t *testing.T) { + got := cursorEscapeJSON(`{"FileName":"Demo.java"}`) + if runtime.GOOS == "windows" { + // PowerShell double-quoted strings escape an embedded `"` by doubling it; a + // backslash is not a quote-escape there, so `\"` would corrupt the command. + want := `{""FileName"":""Demo.java""}` + if got != want { + t.Errorf("expected doubled-quote escaping on windows (PowerShell), got %q", got) + } + } else { + want := `{\"FileName\":\"Demo.java\"}` + if got != want { + t.Errorf("expected backslash-escaped quotes on unix (bash), got %q", got) + } + } +} + +func TestAdditionalContext_CursorSuppressCommandNeverUsesBackslashEscapingOnWindows(t *testing.T) { + findings := []grpcs.ScanDetail{{FileName: "Demo.java", Line: 5, RuleID: 1027}} + ctx := additionalContext("Demo.java", "cx", findings, "", "Cursor", "sess-1") + if runtime.GOOS == "windows" { + if strings.Contains(ctx, `\"`) { + t.Errorf("cursor suppress command on windows must not use backslash-escaped quotes "+ + "(PowerShell terminates the string early on them), got %q", ctx) + } + if !strings.Contains(ctx, `""FileName""`) { + t.Errorf("expected doubled-quote escaping for PowerShell, got %q", ctx) + } + } +} + +func TestFormatFindings_RoutesCursorQuoting(t *testing.T) { + findings := []grpcs.ScanDetail{{FileName: "a.py", Line: 1, RuleID: 1}} + _, ctx := formatFindings("a.py", findings, "", "Cursor", "sess-1") + if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data "`) { + t.Fatalf("cursor agent should get double-quoted suppress command, got %q", ctx) + } + _, ctx = formatFindings("a.py", findings, "", "Claude", "sess-1") + if !strings.Contains(ctx, `ignore-vulnerability --scan-type asca --data '`) { + t.Fatalf("claude agent should get single-quoted suppress command, got %q", ctx) + } +} + func TestAdditionalContext_PinsIgnoredFilePathToWorkDir(t *testing.T) { findings := []grpcs.ScanDetail{ {FileName: "billing.py", Line: 5, RuleID: 4059}, diff --git a/internal/commands/agenthooks/guardrails/asca/delta.go b/internal/commands/agenthooks/guardrails/asca/delta.go index afcf92ab..36577738 100644 --- a/internal/commands/agenthooks/guardrails/asca/delta.go +++ b/internal/commands/agenthooks/guardrails/asca/delta.go @@ -4,12 +4,22 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "runtime" "strings" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/wrappers/grpcs" ) +// agentCursor identifies Cursor for the shell-quoting branch below. Cursor's CLI +// reformats single-quoted commands into double-quoted ones (notably on Windows +// PowerShell), so its suppression commands need double-quoted JSON with the +// embedded quotes escaped for the shell actually in play (see cursorEscapeJSON) — +// otherwise the reformatted command corrupts the JSON payload or drops +// --ignored-file-path, silently sending the suppression to the wrong file. +const agentCursor = "Cursor" + // findingKey is the deduplication tuple used for delta detection. // Mirrors the cx-devassist plugin's matching logic. type findingKey struct { @@ -88,6 +98,34 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// cursorIgnoredFilePathFlag is the Cursor-specific variant of ignoredFilePathFlag. It uses +// double quotes and converts backslashes to forward slashes so the flag survives Windows +// PowerShell and cmd.exe without the agent needing to re-quote it. (Cursor agents on Windows +// tend to reformat single-quoted shell commands into double-quoted form and drop flags that +// have complex quoting, causing the ignore entry to land in the wrong directory.) +func cursorIgnoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + p := filepath.ToSlash(ignore.PathFor(workDir)) + return fmt.Sprintf(` --ignored-file-path "%s"`, p) +} + +// cursorEscapeJSON escapes the embedded `"` in a JSON payload so it survives being placed +// inside a double-quoted argument on the shell that actually runs the Cursor agent's command: +// PowerShell on Windows, bash/zsh elsewhere. This runs on the developer's own machine (inside +// the cx process), so runtime.GOOS reflects that shell choice directly. The two shells disagree +// on how to escape an embedded double quote — bash accepts a backslash-escaped `\"`, but +// PowerShell's double-quoted strings do NOT treat `\` as an escape character at all: `\"` ends +// the string early (backslash is literal, then the quote closes it), corrupting everything +// after the first embedded quote. PowerShell requires the quote to be doubled (`""`) instead. +func cursorEscapeJSON(data string) string { + if runtime.GOOS == "windows" { + return strings.ReplaceAll(data, `"`, `""`) + } + return strings.ReplaceAll(data, `"`, `\"`) +} + // optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the // child `cx ignore-vulnerability` process via --optional-flags, which reads them through // utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. @@ -115,7 +153,6 @@ 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. func additionalContext(filePath, cxBinary string, findings []grpcs.ScanDetail, workDir, agent, sessionID string) string { - ignoreFlag := ignoredFilePathFlag(workDir) provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, f := range findings { @@ -124,7 +161,14 @@ 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) + if agent == agentCursor { + ignoreFlag := cursorIgnoredFilePathFlag(workDir) + escapedData := cursorEscapeJSON(string(data)) + fmt.Fprintf(&suppressCmds, ` %s ignore-vulnerability --scan-type asca --data "%s"%s%s`+"\n", cxBinary, escapedData, ignoreFlag, provenance) + } else { + ignoreFlag := ignoredFilePathFlag(workDir) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type asca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + } } return fmt.Sprintf( "ASCA detected vulnerabilities in %s. "+ diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 05626847..5e7be07b 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -5,6 +5,7 @@ import ( "path/filepath" "strings" + agenthooks "github.com/Checkmarx/ast-cx-hooks" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" ) @@ -60,9 +61,17 @@ func findingsSummary(filePath string, findings []iacrealtime.IacRealtimeResult) } // formatFindings builds the two verdict fields delivered to the agent. -func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult) (reason, context string) { +// Cursor receives cursorAdditionalContext (folded into agent_message); other agents +// receive the original additionalContext (e.g. Claude additionalContext). +func formatFindings(filePath string, findings []iacrealtime.IacRealtimeResult, agent agenthooks.AgentID) (reason, context string) { summary := findingsSummary(filePath, findings) - return permissionDecisionReason(filePath, summary), additionalContext(filePath, findings) + reason = permissionDecisionReason(filePath, summary) + if agent == agenthooks.AgentCursor { + context = cursorAdditionalContext(filePath, findings) + } else { + context = additionalContext(filePath, findings) + } + return reason, context } // permissionDecisionReason is the human-readable deny message shown to the user. @@ -114,6 +123,7 @@ 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 { var findingList strings.Builder for _, f := range findings { @@ -167,3 +177,26 @@ func remediationInstructions(filePath string, findings []iacrealtime.IacRealtime "a centrally-managed policy), add them as part of your change rather than skipping " + "the finding." } + +// cursorAdditionalContext is remediation guidance for Cursor only. Cursor has no +// additionalContext field on preToolUse — ast-cx-hooks folds this into agent_message. +func cursorAdditionalContext(filePath string, findings []iacrealtime.IacRealtimeResult) string { + var findingList strings.Builder + for _, f := range findings { + line := 0 + if len(f.Locations) > 0 { + line = f.Locations[0].Line + } + fmt.Fprintf(&findingList, " - line %d [%s] %s: %s\n", + line, f.Severity, f.Title, f.Description) + } + return fmt.Sprintf( + "KICS IaC findings in %s — apply the cx-devassist-kics.mdc rule exactly. "+ + "Do not retry the blocked Write/StrReplace, paste code in chat, or bypass the scan with shell workarounds.\n\n"+ + "Fix every finding below (deterministic IaC rule matches — not false positives). "+ + "For each, call mcp__Checkmarx__imageRemediation with type \"iac\" and metadata from the finding "+ + "(title, description, remediationAdvice), apply remediation_steps, then retry the write:\n"+ + "%s", + filePath, findingList.String(), + ) +} diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index 66df897b..4ba13ca7 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + agenthooks "github.com/Checkmarx/ast-cx-hooks" "github.com/checkmarx/ast-cli/internal/services/realtimeengine" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" ) @@ -89,7 +90,7 @@ func TestNewFindings_DeltaDedup_SameKeyNotDoubled(t *testing.T) { func TestFormatFindings_ReasonContainsKICS(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "KICS") { t.Errorf("reason should contain KICS, got: %q", reason) } @@ -97,7 +98,7 @@ func TestFormatFindings_ReasonContainsKICS(t *testing.T) { func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "/project/Dockerfile") { t.Errorf("reason should contain file path, got: %q", reason) } @@ -105,7 +106,7 @@ func TestFormatFindings_ReasonContainsFilePath(t *testing.T) { func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - reason, _ := formatFindings("/project/Dockerfile", findings) + reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(reason, "HIGH") { t.Errorf("reason should contain severity, got: %q", reason) } @@ -116,7 +117,7 @@ func TestFormatFindings_ReasonContainsSeverityAndTitle(t *testing.T) { func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings) + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "fix") && !strings.Contains(ctx, "Fix") { t.Errorf("context should contain fix instruction, got: %q", ctx) } @@ -124,7 +125,7 @@ func TestFormatFindings_ContextContainsFixInstruction(t *testing.T) { func TestFormatFindings_ContextContainsDoNotBypass(t *testing.T) { findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} - _, ctx := formatFindings("/project/Dockerfile", findings) + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) if !strings.Contains(ctx, "bypass") { t.Errorf("context should warn against bypass, got: %q", ctx) } @@ -211,4 +212,38 @@ func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) { if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { t.Errorf("Terraform context should not call imageRemediation, got: %q", ctx) } +} +func TestCursorAdditionalContext_UsesImageRemediation(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + ctx := cursorAdditionalContext("/project/Dockerfile", findings) + if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") { + t.Errorf("cursor KICS context should use imageRemediation, got: %q", ctx) + } + if strings.Contains(ctx, "codeRemediation") { + t.Errorf("cursor KICS context should not use codeRemediation, got: %q", ctx) + } + if !strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Errorf("cursor KICS context should reference cx-devassist-kics.mdc rule, got: %q", ctx) + } +} + +func TestFormatFindings_RoutesCursorContext(t *testing.T) { + findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)} + _, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentCursor) + if !strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Fatalf("cursor agent should get context with rule reference, got %q", ctx) + } + if strings.Contains(ctx, "MANDATORY NEXT STEPS") { + t.Fatalf("cursor context should not have verbose MANDATORY NEXT STEPS block, got %q", ctx) + } + if !strings.Contains(ctx, "imageRemediation") { + t.Fatalf("cursor KICS context should reference imageRemediation, got %q", ctx) + } + _, ctx = formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + if strings.Contains(ctx, "cx-devassist-kics.mdc") { + t.Fatalf("claude agent should not get cursor-specific rule reference, got %q", ctx) + } + if !strings.Contains(ctx, "codeRemediation") { + t.Fatalf("claude KICS context should reference codeRemediation, got %q", ctx) + } } diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index c73740c1..d57a9d4b 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -69,7 +69,8 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas } defer cleanupNew() - newResults, err := svc.scan(stagedNew) + ignoreFilePath := existingIgnoreFilePath(ev.WorkDir) + newResults, err := svc.scan(stagedNew, ignoreFilePath) if err != nil { // Fail open: Docker unavailable, image pull failure, feature flag disabled, etc. logger.PrintfIfVerbose("kics guardrail: scan of proposed content failed, failing open: %v", err) @@ -81,7 +82,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas // For new files (no original content), every finding is new if originalContent == "" { - r, c := formatFindings(ev.FilePath, newResults) + r, c := formatFindings(ev.FilePath, newResults, ev.Agent) return true, r, c } @@ -92,7 +93,7 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas } defer cleanupOrig() - origResults, err := svc.scan(stagedOrig) + origResults, err := svc.scan(stagedOrig, ignoreFilePath) if err != nil { // Fail open on original scan error logger.PrintfIfVerbose("kics guardrail: scan of original content failed, failing open: %v", err) @@ -104,15 +105,16 @@ func ScanFileEdit(ev agenthooks.FileEditEvent, svc *Scanner) (blocked bool, reas return false, "", "" } - r, c := formatFindings(ev.FilePath, newFindings) + r, c := formatFindings(ev.FilePath, newFindings, ev.Agent) return true, r, c } -// existingIgnoreFilePath returns the default realtime ignore-file path only when it -// exists on disk. The IaC realtime service logs a warning and skips ignore filtering -// when a missing path is passed, but we keep the pattern consistent with ASCA. -func existingIgnoreFilePath() string { - p := ignore.DefaultPath() +// existingIgnoreFilePath returns the realtime ignore-file path anchored at workDir only +// when it exists on disk. Mirrors the ASCA pattern: anchor to workDir so the hook reads +// from the same absolute path that `cx ignore-vulnerability` writes to when run from the +// project root. Returns "" (no filtering) until the user creates the file. +func existingIgnoreFilePath(workDir string) string { + p := ignore.PathFor(workDir) if _, err := os.Stat(p); err == nil { return p } diff --git a/internal/commands/agenthooks/guardrails/kics/kics_test.go b/internal/commands/agenthooks/guardrails/kics/kics_test.go index 7e97777f..4fd2ddd2 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics_test.go +++ b/internal/commands/agenthooks/guardrails/kics/kics_test.go @@ -96,7 +96,7 @@ func makeResult(title, similarityID, severity, description string, line int) iac func TestScanFileEdit_NewFileWithFinding_Blocked(t *testing.T) { finding := makeResult("Privileged Container", "sim123", "HIGH", "Container runs as privileged", 5) - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return []iacrealtime.IacRealtimeResult{finding}, nil }) @@ -123,7 +123,7 @@ func TestScanFileEdit_NewFileWithFinding_Blocked(t *testing.T) { func TestScanFileEdit_EditWithNoNewFindings_NotBlocked(t *testing.T) { existingFinding := makeResult("Privileged Container", "sim123", "HIGH", "Container runs as privileged", 5) - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { // Both original and new have the same finding — delta is empty return []iacrealtime.IacRealtimeResult{existingFinding}, nil }) @@ -147,7 +147,7 @@ func TestScanFileEdit_EditWithNoNewFindings_NotBlocked(t *testing.T) { } func TestScanFileEdit_ScanError_FailOpen(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return nil, fmt.Errorf("docker daemon not running") }) @@ -164,7 +164,7 @@ func TestScanFileEdit_ScanError_FailOpen(t *testing.T) { } func TestScanFileEdit_UnsupportedFile_NotBlocked(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { t.Error("scan should not be called for unsupported file types") return nil, nil }) @@ -182,7 +182,7 @@ func TestScanFileEdit_UnsupportedFile_NotBlocked(t *testing.T) { } func TestScanFileEdit_EmptyNewContent_NotBlocked(t *testing.T) { - svc := NewScannerWithFunc(func(_ string) ([]iacrealtime.IacRealtimeResult, error) { + svc := NewScannerWithFunc(func(_, _ string) ([]iacrealtime.IacRealtimeResult, error) { return nil, nil }) diff --git a/internal/commands/agenthooks/guardrails/kics/scanner.go b/internal/commands/agenthooks/guardrails/kics/scanner.go index c539775c..1c413730 100644 --- a/internal/commands/agenthooks/guardrails/kics/scanner.go +++ b/internal/commands/agenthooks/guardrails/kics/scanner.go @@ -15,7 +15,7 @@ import ( type Scanner struct { jwt wrappers.JWTWrapper ff wrappers.FeatureFlagsWrapper - scan func(path string) ([]iacrealtime.IacRealtimeResult, error) + scan func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error) } // NewScanner returns a Scanner backed by the given wrappers. @@ -27,7 +27,7 @@ func NewScanner(jwt wrappers.JWTWrapper, ff wrappers.FeatureFlagsWrapper) *Scann // NewScannerWithFunc returns a Scanner whose scan call is replaced with f. // For unit tests only. -func NewScannerWithFunc(f func(path string) ([]iacrealtime.IacRealtimeResult, error)) *Scanner { +func NewScannerWithFunc(f func(path, ignoreFilePath string) ([]iacrealtime.IacRealtimeResult, error)) *Scanner { return &Scanner{scan: f} } diff --git a/internal/commands/agenthooks/sca/prompts.go b/internal/commands/agenthooks/sca/prompts.go index 4be68391..1b9e9006 100644 --- a/internal/commands/agenthooks/sca/prompts.go +++ b/internal/commands/agenthooks/sca/prompts.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" + "runtime" "strings" "github.com/checkmarx/ast-cli/internal/commands/agenthooks/agentprofile" @@ -11,6 +13,14 @@ import ( "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime" ) +// agentCursor identifies Cursor for the shell-quoting branch below. Cursor's CLI +// reformats single-quoted commands into double-quoted ones (notably on Windows +// PowerShell), so its suppression commands need double-quoted JSON with the +// embedded quotes escaped for the shell actually in play (see cursorEscapeJSON) — +// otherwise the reformatted command corrupts the JSON payload or drops +// --ignored-file-path, silently sending the suppression to the wrong file. +const agentCursor = "Cursor" + // DenyMalicious returns the finding and remediation strings for one or more // packages classified as Malicious. func DenyMalicious(pkgs []ossrealtime.OssPackage, agent string) (finding, remediation string) { @@ -59,7 +69,6 @@ func remediationNote(subject, goal, agent string) string { // and informs the user. func vulnerableRemediationNote(pkgs []ossrealtime.OssPackage, workDir, agent, sessionID string) string { cxBinary := cxExecutable() - ignoreFlag := ignoredFilePathFlag(workDir) provenance := optionalFlagsFragment(agent, sessionID) var suppressCmds strings.Builder for _, p := range pkgs { @@ -68,7 +77,14 @@ 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) + if agent == agentCursor { + ignoreFlag := cursorIgnoredFilePathFlag(workDir) + escapedData := cursorEscapeJSON(string(data)) + fmt.Fprintf(&suppressCmds, ` %s ignore-vulnerability --scan-type sca --data "%s"%s%s`+"\n", cxBinary, escapedData, ignoreFlag, provenance) + } else { + ignoreFlag := ignoredFilePathFlag(workDir) + fmt.Fprintf(&suppressCmds, " %s ignore-vulnerability --scan-type sca --data '%s'%s%s\n", cxBinary, string(data), ignoreFlag, provenance) + } } return fmt.Sprintf( "Action required:\n"+ @@ -99,6 +115,34 @@ func ignoredFilePathFlag(workDir string) string { return fmt.Sprintf(" --ignored-file-path '%s'", ignore.PathFor(workDir)) } +// cursorIgnoredFilePathFlag is the Cursor-specific variant of ignoredFilePathFlag. It uses +// double quotes and converts backslashes to forward slashes so the flag survives Windows +// PowerShell and cmd.exe without the agent needing to re-quote it. (Cursor agents on Windows +// tend to reformat single-quoted shell commands into double-quoted form and drop flags that +// have complex quoting, causing the ignore entry to land in the wrong directory.) +func cursorIgnoredFilePathFlag(workDir string) string { + if workDir == "" { + return "" + } + p := filepath.ToSlash(ignore.PathFor(workDir)) + return fmt.Sprintf(` --ignored-file-path "%s"`, p) +} + +// cursorEscapeJSON escapes the embedded `"` in a JSON payload so it survives being placed +// inside a double-quoted argument on the shell that actually runs the Cursor agent's command: +// PowerShell on Windows, bash/zsh elsewhere. This runs on the developer's own machine (inside +// the cx process), so runtime.GOOS reflects that shell choice directly. The two shells disagree +// on how to escape an embedded double quote — bash accepts a backslash-escaped `\"`, but +// PowerShell's double-quoted strings do NOT treat `\` as an escape character at all: `\"` ends +// the string early (backslash is literal, then the quote closes it), corrupting everything +// after the first embedded quote. PowerShell requires the quote to be doubled (`""`) instead. +func cursorEscapeJSON(data string) string { + if runtime.GOOS == "windows" { + return strings.ReplaceAll(data, `"`, `""`) + } + return strings.ReplaceAll(data, `"`, `\"`) +} + // optionalFlagsFragment carries the suppression's provenance (AI provider, agent, session id) to the // child `cx ignore-vulnerability` process via --optional-flags, which reads them through // utils.GetOptionalParam and logs them — matching logRemediationTelemetry's aiProvider/agent/session. diff --git a/internal/commands/agenthooks/sca/sca_test.go b/internal/commands/agenthooks/sca/sca_test.go index 20c80cc7..7d89b86f 100644 --- a/internal/commands/agenthooks/sca/sca_test.go +++ b/internal/commands/agenthooks/sca/sca_test.go @@ -5,6 +5,7 @@ package sca import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -236,6 +237,42 @@ func TestDenyVulnerable_EmitsProvenanceOptionalFlags(t *testing.T) { } } +func TestCursorEscapeJSON_MatchesTheShellCursorActuallyRunsOn(t *testing.T) { + got := cursorEscapeJSON(`{"PackageName":"axios"}`) + if runtime.GOOS == "windows" { + // PowerShell double-quoted strings escape an embedded `"` by doubling it; a + // backslash is not a quote-escape there, so `\"` would corrupt the command. + want := `{""PackageName"":""axios""}` + if got != want { + t.Errorf("expected doubled-quote escaping on windows (PowerShell), got %q", got) + } + } else { + want := `{\"PackageName\":\"axios\"}` + if got != want { + t.Errorf("expected backslash-escaped quotes on unix (bash), got %q", got) + } + } +} + +func TestDenyVulnerable_CursorSuppressCommandNeverUsesBackslashEscapingOnWindows(t *testing.T) { + pkgs := []ossrealtime.OssPackage{ + {PackageManager: "npm", PackageName: "axios", PackageVersion: "0.21.0"}, + } + _, remediation := DenyVulnerable(pkgs, "", "Cursor", "sess-9") + if !strings.Contains(remediation, `ignore-vulnerability --scan-type sca --data "`) { + t.Errorf("cursor remediation should use double-quoted suppress command, got %q", remediation) + } + if runtime.GOOS == "windows" { + if strings.Contains(remediation, `\"`) { + t.Errorf("cursor suppress command on windows must not use backslash-escaped quotes "+ + "(PowerShell terminates the string early on them), got %q", remediation) + } + if !strings.Contains(remediation, `""PackageName""`) { + t.Errorf("expected doubled-quote escaping for PowerShell, got %q", remediation) + } + } +} + func TestDenyVulnerable_MultiplePackages_EachGetsIgnoreCommand(t *testing.T) { pkgs := []ossrealtime.OssPackage{ {PackageManager: "npm", PackageName: "lodash", PackageVersion: "4.17.0"}, diff --git a/internal/commands/ignore_vulnerability.go b/internal/commands/ignore_vulnerability.go index 092f7c3c..1f3f22d1 100644 --- a/internal/commands/ignore_vulnerability.go +++ b/internal/commands/ignore_vulnerability.go @@ -122,7 +122,7 @@ func readIgnoreData(cmd *cobra.Command, dataArg string) ([]byte, error) { } return data, nil case strings.HasPrefix(dataArg, "@"): - path := strings.TrimPrefix(dataArg, "@") + path := ignore.NormalizePath(strings.TrimPrefix(dataArg, "@")) data, err := os.ReadFile(path) if err != nil { return nil, errors.Wrapf(err, "failed to read --data file %s", path) diff --git a/internal/commands/ignore_vulnerability_test.go b/internal/commands/ignore_vulnerability_test.go index fb03eee2..f40848e9 100644 --- a/internal/commands/ignore_vulnerability_test.go +++ b/internal/commands/ignore_vulnerability_test.go @@ -63,6 +63,27 @@ func TestIgnoreVulnerability_DataFromFile(t *testing.T) { assert.Len(t, list, 1) } +// TestIgnoreVulnerability_DataFromFile_CursorPosixStyleWindowsRoot reproduces the reported +// failure: Cursor supplies --data as "@/c:/…/finding.json" (a leading slash before the drive +// letter). Without normalization, os.ReadFile rejects it with "The filename, directory name, +// or volume label syntax is incorrect." +func TestIgnoreVulnerability_DataFromFile_CursorPosixStyleWindowsRoot(t *testing.T) { + dir := t.TempDir() + drive := filepath.VolumeName(dir) + if drive == "" { + t.Skip("no drive letter on this platform") + } + findingFile := filepath.Join(dir, "finding.json") + require.NoError(t, os.WriteFile(findingFile, []byte(`{"Title":"github-pat","SecretValue":"ghp_x"}`), 0o600)) + posixStyleFindingFile := "/" + filepath.ToSlash(findingFile) + ignoreFile := filepath.Join(dir, "ignore.json") + + _, err := runIgnoreVulnCmd("", "--scan-type", "secrets", "--data", "@"+posixStyleFindingFile, "--ignored-file-path", ignoreFile) + require.NoError(t, err) + list, _ := ignore.Load(ignoreFile) + assert.Len(t, list, 1) +} + func TestIgnoreVulnerability_DataFromStdin(t *testing.T) { file := filepath.Join(t.TempDir(), "ignore.json") _, err := runIgnoreVulnCmd(`{"ImageName":"ubuntu","ImageTag":"14.04"}`, diff --git a/internal/services/realtimeengine/ignore/ignorefile.go b/internal/services/realtimeengine/ignore/ignorefile.go index 4cb2b610..6a7c6564 100644 --- a/internal/services/realtimeengine/ignore/ignorefile.go +++ b/internal/services/realtimeengine/ignore/ignorefile.go @@ -8,6 +8,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" ) const ( @@ -27,6 +28,28 @@ func DefaultPath() string { return filepath.Join(defaultDir, defaultFileName) } +// NormalizePath canonicalizes a filesystem path that may carry Cursor's Windows +// workspace-root spelling ("/c:/foo/bar" — a leading slash before the drive +// letter) instead of a native one ("c:/foo/bar" / "c:\foo\bar"). ast-cx-hooks' +// Cursor adapter normalizes workDir at ingestion (see its normalizeWorkDir), but +// this is a defensive second layer: it also protects a hand-typed or +// agent-typed path (e.g. an --ignored-file-path or --data @ argument +// copied from an older suggested command, or typed directly by an agent) from +// the same "invalid volume label syntax" failure when it reaches os.ReadFile / +// os.WriteFile / filepath.Join. A path with no leading-slash-drive-letter +// pattern passes through unchanged. +func NormalizePath(path string) string { + r := strings.ReplaceAll(path, "\\", "/") + if len(r) >= 3 && r[0] == '/' && isASCIIDriveLetter(r[1]) && r[2] == ':' { + r = r[1:] + } + return r +} + +func isASCIIDriveLetter(b byte) bool { + return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') +} + // PathFor returns the ignore-file path anchored at workDir — the workspace root the hook // event reports via its "cwd" field — i.e. /.checkmarx/checkmarxIgnoredTempList.json. // When workDir is empty it falls back to the CWD-relative DefaultPath. @@ -40,13 +63,13 @@ func PathFor(workDir string) string { if workDir == "" { return DefaultPath() } - return filepath.Join(workDir, defaultDir, defaultFileName) + return filepath.Join(NormalizePath(workDir), defaultDir, defaultFileName) } // Load reads the ignore file as a list of raw JSON entries. A missing or empty file yields an // empty list (not an error) so the first ignore creates the file cleanly. func Load(path string) ([]json.RawMessage, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(NormalizePath(path)) if err != nil { if os.IsNotExist(err) { return []json.RawMessage{}, nil @@ -107,6 +130,7 @@ func Remove(list []json.RawMessage, entry any) ([]json.RawMessage, bool, error) // Save writes the list as pretty-printed JSON, creating the parent directory if needed. func Save(path string, list []json.RawMessage) error { + path = NormalizePath(path) if dir := filepath.Dir(path); dir != "" && dir != "." { if err := os.MkdirAll(dir, dirPerm); err != nil { return err diff --git a/internal/services/realtimeengine/ignore/ignorefile_test.go b/internal/services/realtimeengine/ignore/ignorefile_test.go index 6024ed2d..b5a2b1d9 100644 --- a/internal/services/realtimeengine/ignore/ignorefile_test.go +++ b/internal/services/realtimeengine/ignore/ignorefile_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -100,3 +101,49 @@ func TestPathFor_AnchorsAtWorkDir(t *testing.T) { func TestPathFor_EmptyWorkDirFallsBackToDefault(t *testing.T) { assert.Equal(t, DefaultPath(), PathFor("")) } + +// TestPathFor_NormalizesCursorPosixStyleWindowsRoot guards against a real production +// failure: Cursor reports a Windows workspace root as "/c:/foo/bar" (leading slash before +// the drive letter). Without normalization, filepath.Join produces a path Go's os.ReadFile +// rejects with "The filename, directory name, or volume label syntax is incorrect." +func TestPathFor_NormalizesCursorPosixStyleWindowsRoot(t *testing.T) { + got := PathFor("/c:/Cx-Flow/Test/JavaVulnerabilityLabE") + want := filepath.Join("c:/Cx-Flow/Test/JavaVulnerabilityLabE", ".checkmarx", "checkmarxIgnoredTempList.json") + assert.Equal(t, want, got) +} + +func TestNormalizePath(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + {"posix-style windows root", "/c:/Cx-Flow/Test/JavaVulnerabilityLabE", "c:/Cx-Flow/Test/JavaVulnerabilityLabE"}, + {"posix-style windows root, uppercase drive", "/C:/Users/dev/project", "C:/Users/dev/project"}, + {"native windows backslash path", `c:\Users\dev\project`, "c:/Users/dev/project"}, + {"native windows forward-slash path", "c:/Users/dev/project", "c:/Users/dev/project"}, + {"posix path, no drive letter", "/home/dev/project", "/home/dev/project"}, + {"empty", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, NormalizePath(tc.in)) + }) + } +} + +// TestLoad_NormalizesCursorPosixStyleWindowsRoot reproduces the exact reported failure: +// --ignored-file-path passed as "/c:/…/checkmarxIgnoredTempList.json" must not error with +// "invalid volume label syntax" — Load should normalize it and read the (missing) file cleanly. +func TestLoad_NormalizesCursorPosixStyleWindowsRoot(t *testing.T) { + dir := t.TempDir() + drive := filepath.VolumeName(dir) + if drive == "" { + t.Skip("no drive letter on this platform") + } + posixStyle := "/" + strings.TrimSuffix(filepath.ToSlash(dir), "") + "/checkmarxIgnoredTempList.json" + + list, err := Load(posixStyle) + require.NoError(t, err) + assert.Empty(t, list) +} From fc300d1fc422760f55f4c7d18c552b335c107da3 Mon Sep 17 00:00:00 2001 From: atishj99 Date: Thu, 6 Aug 2026 12:18:09 +0530 Subject: [PATCH 3/3] resolving conflicts --- .../agenthooks/guardrails/kics/delta.go | 23 ------------------- .../agenthooks/guardrails/kics/delta_test.go | 5 +++- 2 files changed, 4 insertions(+), 24 deletions(-) diff --git a/internal/commands/agenthooks/guardrails/kics/delta.go b/internal/commands/agenthooks/guardrails/kics/delta.go index 12019532..5e7be07b 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta.go +++ b/internal/commands/agenthooks/guardrails/kics/delta.go @@ -200,26 +200,3 @@ func cursorAdditionalContext(filePath string, findings []iacrealtime.IacRealtime filePath, findingList.String(), ) } - -// cursorAdditionalContext is remediation guidance for Cursor only. Cursor has no -// additionalContext field on preToolUse — ast-cx-hooks folds this into agent_message. -func cursorAdditionalContext(filePath string, findings []iacrealtime.IacRealtimeResult) string { - var findingList strings.Builder - for _, f := range findings { - line := 0 - if len(f.Locations) > 0 { - line = f.Locations[0].Line - } - fmt.Fprintf(&findingList, " - line %d [%s] %s: %s\n", - line, f.Severity, f.Title, f.Description) - } - return fmt.Sprintf( - "KICS IaC findings in %s — apply the cx-devassist-kics.mdc rule exactly. "+ - "Do not retry the blocked Write/StrReplace, paste code in chat, or bypass the scan with shell workarounds.\n\n"+ - "Fix every finding below (deterministic IaC rule matches — not false positives). "+ - "For each, call mcp__Checkmarx__imageRemediation with type \"iac\" and metadata from the finding "+ - "(title, description, remediationAdvice), apply remediation_steps, then retry the write:\n"+ - "%s", - filePath, findingList.String(), - ) -} diff --git a/internal/commands/agenthooks/guardrails/kics/delta_test.go b/internal/commands/agenthooks/guardrails/kics/delta_test.go index 1c152c50..6ddd8e6f 100644 --- a/internal/commands/agenthooks/guardrails/kics/delta_test.go +++ b/internal/commands/agenthooks/guardrails/kics/delta_test.go @@ -240,7 +240,10 @@ func TestFormatFindings_RoutesCursorContext(t *testing.T) { if !strings.Contains(ctx, "imageRemediation") { t.Fatalf("cursor KICS context should reference imageRemediation, got %q", ctx) } - _, ctx = formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude) + // Use a non-Docker path for the Claude assertion below: Dockerfile findings + // always route through imageRemediation (see isDockerImageFinding), so + // asserting codeRemediation here requires a generic IaC file instead. + _, ctx = formatFindings("/project/main.tf", findings, agenthooks.AgentClaude) if strings.Contains(ctx, "cx-devassist-kics.mdc") { t.Fatalf("claude agent should not get cursor-specific rule reference, got %q", ctx) }