Skip to content
Merged
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
75 changes: 63 additions & 12 deletions internal/commands/agenthooks/cx/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"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"
)
Expand All @@ -35,6 +36,17 @@ 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
Expand All @@ -59,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 @@ -69,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 Down Expand Up @@ -100,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 @@ -118,32 +132,39 @@ 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 {
if blocked, reason, context, severity := secrets.ScanFileEdit(&ev, secretsScanner, telemetryWrapper, agent); blocked {
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)
Expand Down Expand Up @@ -284,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
6 changes: 3 additions & 3 deletions internal/commands/agenthooks/cx/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,7 @@ func TestCxBeforeFileEdit_SecretsFinding_RejectsWithContext(t *testing.T) {
})

v := cxBeforeFileEdit(agenthooks.FileEditEvent{
Agent: agenthooks.AgentGemini,
Agent: agenthooks.AgentCopilotCLI,
SessionID: "sec-sess",
FilePath: "config.env",
WorkDir: t.TempDir(),
Expand All @@ -450,8 +450,8 @@ func TestCxBeforeFileEdit_SecretsFinding_RejectsWithContext(t *testing.T) {
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-secrets") {
t.Errorf("expected Gemini skill in context, got %q", v.Context)
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))
Expand Down
Loading
Loading