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
103 changes: 85 additions & 18 deletions internal/commands/agenthooks/cx/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@ import (
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/asca"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/kics"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/secrets"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/sca"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/scannerconfig"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/sessiontally"
"github.com/checkmarx/ast-cli/internal/wrappers"
)

// canonical per-engine labels for the session summary (normalizes the legacy SCA/Oss split used by
// the per-event remediation telemetry; ASCA already uses "Asca").
const (
engineAsca = "Asca"
engineSca = "Sca"
engineKics = "Kics"
engineAsca = "Asca"
engineSca = "Sca"
engineKics = "Kics"
engineSecrets = "Secrets"
)

// scaScanner is the package-level SCA scanner used by the guardrail handlers.
Expand All @@ -33,6 +36,21 @@ var scaScanner *sca.Scanner
// It is set by RegisterGuardrails and cleared by RegisterPassThrough.
var kicsScanner *kics.Scanner

// bindThreshold points each guardrail's finding filter at the severity threshold for this event's
// agent, so findings below cx_severity_threshold are left out of a deny and the agent is not told to
// fix them. Agents the switches do not apply to keep every finding.
func bindThreshold(agent agenthooks.AgentID) {
below := func(severity string) bool { return belowThreshold(agent, severity) }
secrets.BelowThreshold = below
asca.BelowThreshold = below
kics.BelowThreshold = below
sca.BelowThreshold = below
}

// secretsScanner is the package-level secret-detection scanner used by the file-edit guardrail.
// It is set by RegisterGuardrails and cleared by RegisterPassThrough.
var secretsScanner *secrets.Scanner

var telemetryWrapper wrappers.TelemetryWrapper

func cxWhenAgentIdle(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict {
Expand All @@ -53,6 +71,7 @@ func sessionIDFromToolCall(ev *agenthooks.ToolCallEvent) string {
// cxBeforeToolCall gates shell execution against the organization's blacklist,
// tool rules, and the SCA guardrail (malicious / vulnerable package installs).
func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict {
bindThreshold(ev.Agent)
if !ev.IsShell() {
return agenthooks.Allow()
}
Expand All @@ -63,10 +82,10 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict {
}
return agenthooks.Deny(reason)
}
if scaScanner != nil {
if scaScanner != nil && scannerOn(ev.Agent, scannerconfig.SCA) {
agent := agentToString(ev.Agent)
sid := sessionIDFromToolCall(&ev)
if finding, remediation, severity := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir, agent, sid); finding != "" {
if finding, remediation, severity := scaScanner.CheckBashInstall(ev.Command, ev.WorkDir, agent, sid); finding != "" && !belowThreshold(ev.Agent, severity) {
sessiontally.Add(sid, engineSca, 1, 1)
logRemediationTelemetry(agent, "SCA", severity, sid)
return agenthooks.DenyWithContext(finding, remediation)
Expand All @@ -81,10 +100,11 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict {
// 1. File EDITS (Claude / Windsurf / Droid / Gemini) — ev.Changes is populated.
// Enforce blast_radius_limit, files_limits.max_total_file_size_kb, the ASCA
// guardrail (AI-introduced code vulnerabilities), the KICS guardrail
// (IaC security vulnerabilities), and the SCA guardrail
// (malicious / vulnerable manifest additions) before any bytes are written
// to disk. MultiEdit and multi-file edits are handled uniformly by iterating
// ev.Changes.
// (IaC security vulnerabilities), the SCA guardrail
// (malicious / vulnerable manifest additions), and the secret-detection
// guardrail (hardcoded credentials introduced by the edit) before any bytes
// are written to disk. MultiEdit and multi-file edits are handled uniformly
// by iterating ev.Changes.
//
// 2. Cursor file READS (beforeReadFile) — ev.Changes is empty and ev.FilePath
// points to a file the agent is about to ingest into the LLM context.
Expand All @@ -93,6 +113,7 @@ func cxBeforeToolCall(ev agenthooks.ToolCallEvent) agenthooks.ToolVerdict {
// or if the file exceeds the policy size cap. Reads do NOT count toward
// the blast-radius budget (that limit is about writes).
func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict {
bindThreshold(ev.Agent)
if ev.Agent == agenthooks.AgentCursor && len(ev.Changes) == 0 {
if reason := guardrails.ScanFileForSecrets(ev.FilePath); reason != "" {
return agenthooks.RejectEdit(reason)
Expand All @@ -111,30 +132,44 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict {
return agenthooks.RejectEdit(reason)
}
agent := agentToString(ev.Agent)
if blocked, reason, context, severity := asca.ScanFileEdit(&ev, telemetryWrapper, agent); blocked {
sessiontally.Add(ev.SessionID, engineAsca, 1, 1)
logRemediationTelemetry(agent, "Asca", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(reason, context)
// Each engine below is skipped (not just its verdict ignored) when the developer switched it off in
// ~/.checkmarx/cx-onboarding.properties; an unreadable config leaves every engine on.
// cx_severity_threshold (same file): a deny whose highest severity is below the threshold is let
// through for ASCA, KICS, SCA and secrets. Malicious packages and an unknown severity
// are never relaxed. A blank or invalid threshold means low (nothing filtered).
if scannerOn(ev.Agent, scannerconfig.ASCA) {
if blocked, reason, context, severity := asca.ScanFileEdit(&ev, telemetryWrapper, agent); blocked && !belowThreshold(ev.Agent, severity) {
sessiontally.Add(ev.SessionID, engineAsca, 1, 1)
logRemediationTelemetry(agent, "Asca", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(reason, context)
}
}
var kicsNote string
if kicsScanner != nil {
if kicsScanner != nil && scannerOn(ev.Agent, scannerconfig.KICS) {
blocked, reason, context, note, severity := kics.ScanFileEdit(&ev, kicsScanner, telemetryWrapper, agent)
if blocked {
if blocked && !belowThreshold(ev.Agent, severity) {
sessiontally.Add(ev.SessionID, engineKics, 1, 1)
logRemediationTelemetry(agent, "IaC", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(reason, context)
}
kicsNote = note
}
if scaScanner != nil {
if scaScanner != nil && scannerOn(ev.Agent, scannerconfig.SCA) {
for _, diff := range ev.Changes {
if finding, remediation, severity := scaScanner.CheckManifestEdit(ev.FilePath, fullAfterContent(ev.FilePath, diff), ev.WorkDir, agent, ev.SessionID); finding != "" {
if finding, remediation, severity := scaScanner.CheckManifestEdit(ev.FilePath, fullAfterContent(ev.FilePath, diff), ev.WorkDir, agent, ev.SessionID); finding != "" && !belowThreshold(ev.Agent, severity) {
sessiontally.Add(ev.SessionID, engineSca, 1, 1)
logRemediationTelemetry(agent, "Oss", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(finding, remediation)
}
}
}
if secretsScanner != nil && secretsApply(ev.Agent) && scannerOn(ev.Agent, scannerconfig.Secrets) {
if blocked, reason, context, severity := secrets.ScanFileEdit(&ev, secretsScanner, telemetryWrapper, agent); blocked && !belowThreshold(ev.Agent, severity) {
sessiontally.Add(ev.SessionID, engineSecrets, 1, 1)
logRemediationTelemetry(agent, "Secrets", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(reason, context)
}
}
// A note on an ALLOW, deliberately: blocking every IaC edit because a
// container engine is down would be worse than an unscanned edit.
if kicsNote != "" {
Expand Down Expand Up @@ -223,10 +258,11 @@ func promptWorkspaceRoots(raw any) []string {
}

// RegisterGuardrails wires the four guardrail handlers and instantiates the
// SCA and KICS scanners used by the Bash and FileEdit handlers.
// SCA, KICS, and secret-detection scanners used by the Bash and FileEdit handlers.
func RegisterGuardrails(jwt wrappers.JWTWrapper, ff wrappers.FeatureFlagsWrapper, rt wrappers.RealtimeScannerWrapper, tel wrappers.TelemetryWrapper) {
scaScanner = sca.NewScanner(jwt, ff, rt)
kicsScanner = kics.NewScanner(jwt, ff)
secretsScanner = secrets.NewScanner(jwt, ff)
telemetryWrapper = tel
agenthooks.WhenAgentIdle(cxWhenAgentIdle)
agenthooks.BeforeToolCall(cxBeforeToolCall)
Expand All @@ -239,6 +275,7 @@ func RegisterGuardrails(jwt wrappers.JWTWrapper, ff wrappers.FeatureFlagsWrapper
func RegisterPassThrough() {
scaScanner = nil
kicsScanner = nil
secretsScanner = nil
agenthooks.WhenAgentIdle(func(_ agenthooks.AgentIdleEvent) agenthooks.IdleVerdict { return agenthooks.Resume() })
agenthooks.BeforeToolCall(func(_ agenthooks.ToolCallEvent) agenthooks.ToolVerdict { return agenthooks.Allow() })
agenthooks.BeforeFileEdit(func(_ agenthooks.FileEditEvent) agenthooks.FileEditVerdict { return agenthooks.AcceptEdit() })
Expand Down Expand Up @@ -268,6 +305,36 @@ func logRemediationTelemetry(agent, engine, severity, sessionID string) {
}
}

// switchesApply reports whether the ~/.checkmarx/cx-onboarding.properties scanner switches and severity
// threshold are honoured for this agent. Claude, Cursor, Gemini and Codex are excluded for now: they keep
// scanning with ASCA / KICS / SCA and blocking at any severity even when the shared file exists on the
// machine. Every other agent (GitHub Copilot, Droid, Windsurf, ...) honours it. Add an agent to
// the case list below to hold it back, or remove one to roll the feature out to it.
func switchesApply(agent agenthooks.AgentID) bool {
switch agent {
case agenthooks.AgentClaude, agenthooks.AgentCursor, agenthooks.AgentGemini, agenthooks.AgentCodex:
return false
}
return true
}

// secretsApply reports whether the secret-detection guardrail runs for this agent. Claude, Cursor, Gemini
// and Codex skip it entirely and keep the ASCA / KICS / SCA guardrails; every other agent runs it. It
// shares switchesApply's agent list so the two cannot drift apart.
func secretsApply(agent agenthooks.AgentID) bool {
return switchesApply(agent)
}

// scannerOn is scannerconfig.Enabled, limited to the agents the switches apply to.
func scannerOn(agent agenthooks.AgentID, s scannerconfig.Scanner) bool {
return !switchesApply(agent) || scannerconfig.Enabled(s)
}

// belowThreshold is scannerconfig.BelowThreshold, limited to the agents the switches apply to.
func belowThreshold(agent agenthooks.AgentID, severity string) bool {
return switchesApply(agent) && scannerconfig.BelowThreshold(severity)
}

// agentToString converts agenthooks.AgentID enum to string representation for telemetry.
func agentToString(agent agenthooks.AgentID) string {
switch agent {
Expand Down
75 changes: 68 additions & 7 deletions internal/commands/agenthooks/cx/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ import (

"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/kics"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/guardrails/secrets"
"github.com/checkmarx/ast-cli/internal/commands/agenthooks/sca"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/ossrealtime"
"github.com/checkmarx/ast-cli/internal/services/realtimeengine/secretsrealtime"
"github.com/checkmarx/ast-cli/internal/wrappers"

"github.com/Checkmarx/ast-cx-hooks/cursor"
Expand Down Expand Up @@ -53,16 +55,18 @@ func (r *recordingTelemetry) SendAIDataToLog(data *wrappers.DataForAITelemetry)

func resetHookGlobals(t *testing.T) {
t.Helper()
prevSCA, prevKICS, prevTel := scaScanner, kicsScanner, telemetryWrapper
prevSCA, prevKICS, prevSecrets, prevTel := scaScanner, kicsScanner, secretsScanner, telemetryWrapper
t.Cleanup(func() {
scaScanner = prevSCA
kicsScanner = prevKICS
secretsScanner = prevSecrets
telemetryWrapper = prevTel
guardrails.ResetBlastRadiusCount()
guardrails.ResetTotalFileSizeCount()
})
scaScanner = nil
kicsScanner = nil
secretsScanner = nil
telemetryWrapper = nil
guardrails.ResetBlastRadiusCount()
guardrails.ResetTotalFileSizeCount()
Expand Down Expand Up @@ -415,6 +419,51 @@ func TestCxBeforeFileEdit_SCAManifest_RejectsWithContext(t *testing.T) {
}
}

func TestCxBeforeFileEdit_SecretsFinding_RejectsWithContext(t *testing.T) {
resetHookGlobals(t)
const secret = "ghp_hook_test_secret_value"
tel := &recordingTelemetry{}
telemetryWrapper = tel
secretsScanner = secrets.NewScannerWithFunc(func(_, content, _ string) ([]secretsrealtime.SecretsRealtimeResult, error) {
if strings.Contains(content, secret) {
return []secretsrealtime.SecretsRealtimeResult{{
Title: "github-pat",
Description: "GitHub personal access token",
SecretValue: secret,
Severity: "Critical",
Locations: []realtimeengine.Location{{Line: 1}},
}}, nil
}
return nil, nil
})

v := cxBeforeFileEdit(agenthooks.FileEditEvent{
Agent: agenthooks.AgentCopilotCLI,
SessionID: "sec-sess",
FilePath: "config.env",
WorkDir: t.TempDir(),
Changes: []agenthooks.FileDiff{{Before: "", After: "TOKEN=" + secret}},
})
if v.Permit {
t.Fatal("secret finding should RejectEdit")
}
if strings.Contains(v.Message, secret) || strings.Contains(v.Context, secret) {
t.Fatal("verdict leaked the secret value")
}
if !strings.Contains(v.Context, "cx-devassist:cx-devassist-secrets") {
t.Errorf("expected Copilot skill in context, got %q", v.Context)
}
if len(tel.calls) != 2 {
t.Fatalf("expected 2 telemetry calls (detect + remediate), got %d", len(tel.calls))
}
if tel.calls[0].Type != "hooks-detect" || tel.calls[0].Engine != engineSecrets {
t.Errorf("detect telemetry = Type %q Engine %q", tel.calls[0].Type, tel.calls[0].Engine)
}
if tel.calls[1].Type != telemetryTypeHooksRemediate || tel.calls[1].Engine != "Secrets" {
t.Errorf("remediate telemetry = Type %q Engine %q", tel.calls[1].Type, tel.calls[1].Engine)
}
}

func TestCxBeforeFileEdit_CleanEdit_Accepts(t *testing.T) {
resetHookGlobals(t)
v := cxBeforeFileEdit(agenthooks.FileEditEvent{
Expand Down Expand Up @@ -555,6 +604,9 @@ func TestRegisterGuardrails_AndPassThrough(t *testing.T) {
if kicsScanner == nil {
t.Fatal("RegisterGuardrails should set kicsScanner")
}
if secretsScanner == nil {
t.Fatal("RegisterGuardrails should set secretsScanner")
}
if telemetryWrapper == nil {
t.Fatal("RegisterGuardrails should set telemetryWrapper")
}
Expand All @@ -566,6 +618,9 @@ func TestRegisterGuardrails_AndPassThrough(t *testing.T) {
if kicsScanner != nil {
t.Fatal("RegisterPassThrough should clear kicsScanner")
}
if secretsScanner != nil {
t.Fatal("RegisterPassThrough should clear secretsScanner")
}
}

func TestLogRemediationTelemetry(t *testing.T) {
Expand Down Expand Up @@ -746,10 +801,11 @@ func TestCxBeforeFileEdit_CursorRead_NoSecrets_Accepts(t *testing.T) {

func TestCxBeforeFileEdit_UnsupportedFileType_Accepts(t *testing.T) {
setEmptyHomeDir(t)
prevSca, prevKics := scaScanner, kicsScanner
defer func() { scaScanner, kicsScanner = prevSca, prevKics }()
prevSca, prevKics, prevSecrets := scaScanner, kicsScanner, secretsScanner
defer func() { scaScanner, kicsScanner, secretsScanner = prevSca, prevKics, prevSecrets }()
scaScanner = nil
kicsScanner = nil
secretsScanner = nil

path := filepath.Join(t.TempDir(), "notes.txt")
ev := agenthooks.FileEditEvent{
Expand All @@ -769,27 +825,31 @@ func TestCxBeforePrompt_Benign_Accepts(t *testing.T) {
}

func TestRegisterGuardrails_SetsScanners(t *testing.T) {
prevSca, prevKics, prevTelemetry := scaScanner, kicsScanner, telemetryWrapper
defer func() { scaScanner, kicsScanner, telemetryWrapper = prevSca, prevKics, prevTelemetry }()
prevSca, prevKics, prevSecrets, prevTelemetry := scaScanner, kicsScanner, secretsScanner, telemetryWrapper
defer func() {
scaScanner, kicsScanner, secretsScanner, telemetryWrapper = prevSca, prevKics, prevSecrets, prevTelemetry
}()

telemetry := mock.TelemetryMockWrapper{}
RegisterGuardrails(&mock.JWTMockWrapper{}, &mock.FeatureFlagsMockWrapper{}, &mock.RealtimeScannerMockWrapper{}, telemetry)

assert.NotNil(t, scaScanner)
assert.NotNil(t, kicsScanner)
assert.NotNil(t, secretsScanner)
assert.Equal(t, telemetry, telemetryWrapper)
}

func TestRegisterPassThrough_ClearsScanners(t *testing.T) {
prevSca, prevKics := scaScanner, kicsScanner
defer func() { scaScanner, kicsScanner = prevSca, prevKics }()
prevSca, prevKics, prevSecrets := scaScanner, kicsScanner, secretsScanner
defer func() { scaScanner, kicsScanner, secretsScanner = prevSca, prevKics, prevSecrets }()

RegisterGuardrails(&mock.JWTMockWrapper{}, &mock.FeatureFlagsMockWrapper{}, &mock.RealtimeScannerMockWrapper{}, mock.TelemetryMockWrapper{})
assert.NotNil(t, scaScanner)

RegisterPassThrough()
assert.Nil(t, scaScanner)
assert.Nil(t, kicsScanner)
assert.Nil(t, secretsScanner)
}

func TestLogRemediationTelemetry_NilWrapper_NoOp(t *testing.T) {
Expand Down Expand Up @@ -873,6 +933,7 @@ func TestCodexApplyPatch_KICSFinding_DeniesEndToEnd(t *testing.T) {
}}, nil
})
scaScanner = nil
secretsScanner = nil

patch := "*** Begin Patch\n" +
"*** Add File: main.tf\n" +
Expand Down
Loading
Loading