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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 2 additions & 1 deletion internal/commands/agenthooks/cx/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
},
},
Expand Down
43 changes: 43 additions & 0 deletions internal/commands/agenthooks/guardrails/asca/asca_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
48 changes: 46 additions & 2 deletions internal/commands/agenthooks/guardrails/asca/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,22 @@
"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 {
Expand Down Expand Up @@ -88,6 +98,34 @@
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)

Check failure on line 111 in internal/commands/agenthooks/guardrails/asca/delta.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

sprintfQuotedString: use %q instead of "%s" for quoted strings (gocritic)
}

// 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" {

Check failure on line 123 in internal/commands/agenthooks/guardrails/asca/delta.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

string `windows` has 4 occurrences, make it a constant (goconst)
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.
Expand Down Expand Up @@ -115,7 +153,6 @@
// 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 {
Expand All @@ -124,7 +161,14 @@
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. "+
Expand Down
37 changes: 35 additions & 2 deletions internal/commands/agenthooks/guardrails/kics/delta.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"path/filepath"
"strings"

agenthooks "github.com/Checkmarx/ast-cx-hooks"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime"
)

Expand Down Expand Up @@ -60,9 +61,17 @@
}

// 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.
Expand Down Expand Up @@ -114,6 +123,7 @@
// 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 {
Expand Down Expand Up @@ -167,3 +177,26 @@
"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 {

Check failure on line 185 in internal/commands/agenthooks/guardrails/kics/delta.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

rangeValCopy: each iteration copies 152 bytes (consider pointers or indexing) (gocritic)
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(),
)
}
55 changes: 47 additions & 8 deletions internal/commands/agenthooks/guardrails/kics/delta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -89,23 +90,23 @@ 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)
}
}

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

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)
}
Expand All @@ -116,15 +117,15 @@ 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)
}
}

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)
}
Expand Down Expand Up @@ -181,7 +182,7 @@ func TestFormatFindings_DockerfilePlatformUsesImageRemediation(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"),
}
_, ctx := formatFindings("/project/Dockerfile", findings)
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude)
if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
t.Errorf("Dockerfile context should call imageRemediation, got: %q", ctx)
}
Expand All @@ -194,7 +195,7 @@ func TestFormatFindings_DockerComposePlatformUsesImageRemediation(t *testing.T)
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("VulnerableBaseImage", "DockerCompose"),
}
_, ctx := formatFindings("/project/stack.yml", findings)
_, ctx := formatFindings("/project/stack.yml", findings, agenthooks.AgentClaude)
if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
t.Errorf("docker-compose context should call imageRemediation, got: %q", ctx)
}
Expand All @@ -204,11 +205,49 @@ func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("OpenSecurityGroup", "Terraform"),
}
_, ctx := formatFindings("/project/main.tf", findings)
_, ctx := formatFindings("/project/main.tf", findings, agenthooks.AgentClaude)
if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
t.Errorf("Terraform context should call codeRemediation, got: %q", ctx)
}
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)
}
// 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)
}
if !strings.Contains(ctx, "codeRemediation") {
t.Fatalf("claude KICS context should reference codeRemediation, got %q", ctx)
}
}
20 changes: 11 additions & 9 deletions internal/commands/agenthooks/guardrails/kics/kics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand All @@ -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)
Expand All @@ -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
}
Expand Down
Loading
Loading