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
11 changes: 10 additions & 1 deletion internal/commands/agenthooks/cx/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,15 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict {
logRemediationTelemetry(agent, "Asca", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(reason, context)
}
var kicsNote string
if kicsScanner != nil {
if blocked, reason, context := kics.ScanFileEdit(ev, kicsScanner); blocked {
blocked, reason, context, note, severity := kics.ScanFileEdit(&ev, kicsScanner, telemetryWrapper, agent)
if blocked {
sessiontally.Add(ev.SessionID, engineKics, 1, 1)
logRemediationTelemetry(agent, "IaC", severity, ev.SessionID)
return agenthooks.RejectEditWithContext(reason, context)
}
kicsNote = note
}
if scaScanner != nil {
for _, diff := range ev.Changes {
Expand All @@ -131,6 +135,11 @@ func cxBeforeFileEdit(ev agenthooks.FileEditEvent) agenthooks.FileEditVerdict {
}
}
}
// 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 != "" {
return agenthooks.AllowWithNote(kicsNote)
}
return agenthooks.AcceptEdit()
}

Expand Down
19 changes: 18 additions & 1 deletion internal/commands/agenthooks/cx/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ const (
"eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ." +
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
osWindows = "windows"

telemetryEngineIaC = "IaC"
telemetryTypeHooksRemediate = "hooks-remediate"
)

type recordingTelemetry struct {
Expand Down Expand Up @@ -332,6 +335,8 @@ func TestCxBeforeFileEdit_TotalFileSize_Rejects(t *testing.T) {

func TestCxBeforeFileEdit_KICSFinding_RejectsWithContext(t *testing.T) {
resetHookGlobals(t)
tel := &recordingTelemetry{}
telemetryWrapper = tel
kicsScanner = kics.NewScannerWithFunc(func(string, string) ([]iacrealtime.IacRealtimeResult, error) {
return []iacrealtime.IacRealtimeResult{{
Title: "Privileged Container",
Expand All @@ -357,6 +362,18 @@ func TestCxBeforeFileEdit_KICSFinding_RejectsWithContext(t *testing.T) {
if !strings.Contains(v.Message, "KICS") {
t.Errorf("expected KICS in reason, got %q", v.Message)
}
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 != telemetryEngineIaC {
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 != telemetryEngineIaC {
t.Errorf("remediate telemetry = Type %q Engine %q", tel.calls[1].Type, tel.calls[1].Engine)
}
if tel.calls[1].ProblemSeverity != "HIGH" {
t.Errorf("ProblemSeverity = %q, want HIGH", tel.calls[1].ProblemSeverity)
}
}

func TestCxBeforeFileEdit_SCAManifest_RejectsWithContext(t *testing.T) {
Expand Down Expand Up @@ -573,7 +590,7 @@ func TestLogRemediationTelemetry(t *testing.T) {
if got.Engine != "Asca" || got.ScanType != "asca" {
t.Errorf("Engine/ScanType = %q/%q", got.Engine, got.ScanType)
}
if got.Type != "hooks-remediate" || got.SubType != "fixWithAIAssist" {
if got.Type != telemetryTypeHooksRemediate || got.SubType != "fixWithAIAssist" {
t.Errorf("Type/SubType = %q/%q", got.Type, got.SubType)
}
if got.ProblemSeverity != "Critical" || got.AiAgentSessionId != "sess-9" {
Expand Down
276 changes: 149 additions & 127 deletions internal/commands/agenthooks/guardrails/kics/delta.go

Large diffs are not rendered by default.

161 changes: 72 additions & 89 deletions internal/commands/agenthooks/guardrails/kics/delta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,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, agenthooks.AgentClaude)
reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude, "/project", "sess1")
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, agenthooks.AgentClaude)
reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude, "/project", "sess1")
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, agenthooks.AgentClaude)
reason, _ := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude, "/project", "sess1")
if !strings.Contains(reason, "HIGH") {
t.Errorf("reason should contain severity, got: %q", reason)
}
Expand All @@ -117,95 +117,53 @@ 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, agenthooks.AgentClaude)
if !strings.Contains(ctx, "fix") && !strings.Contains(ctx, "Fix") {
t.Errorf("context should contain fix instruction, got: %q", ctx)
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude, "/project", "sess1")
if !strings.Contains(ctx, "fix") && !strings.Contains(ctx, "Fix") && !strings.Contains(ctx, "remediation") {
t.Errorf("context should contain fix/remediation instruction, got: %q", ctx)
}
}

func TestFormatFindings_ContextContainsDoNotBypass(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)}
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude)
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude, "/project", "sess1")
if !strings.Contains(ctx, "bypass") {
t.Errorf("context should warn against bypass, got: %q", ctx)
}
}

// ── isDockerImageFinding / remediation tool routing ────────────────────────────
// ── remediation tool routing ─────────────────────────────────────────────────

func TestIsDockerImageFinding_ByPlatform(t *testing.T) {
cases := []struct {
platform string
want bool
}{
{"Dockerfile", true},
{"DockerCompose", true},
{"Docker Compose", true},
{"dockerfile", true},
{"Terraform", false},
{"Kubernetes", false},
{"CloudFormation", false},
{"Ansible", false},
}
for _, c := range cases {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("SomeFinding", c.platform),
}
// Filename deliberately contradicts platform to prove platform wins.
if got := isDockerImageFinding("/project/values.yaml", findings); got != c.want {
t.Errorf("isDockerImageFinding with platform %q = %v, want %v", c.platform, got, c.want)
}
}
}

func TestIsDockerImageFinding_FallsBackToFilenameWhenPlatformEmpty(t *testing.T) {
cases := map[string]bool{
"/project/Dockerfile": true,
"/project/api.dockerfile": true,
"/project/docker-compose.yml": true,
"/project/docker-compose.yaml": true,
"/project/docker-compose.prod.yml": true,
"/project/compose.yaml": true,
"/project/main.tf": false,
"/project/deployment.yaml": false,
"/project/values.yaml": false,
}
for path, want := range cases {
findings := []iacrealtime.IacRealtimeResult{iacResult("SomeFinding", "sim1", "HIGH", 1)}
if got := isDockerImageFinding(path, findings); got != want {
t.Errorf("isDockerImageFinding(%q) with no platform = %v, want %v", path, got, want)
}
}
}

func TestFormatFindings_DockerfilePlatformUsesImageRemediation(t *testing.T) {
func TestFormatFindings_DockerfilePlatformUsesCodeRemediation(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"),
}
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude)
if !strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
t.Errorf("Dockerfile context should call imageRemediation, got: %q", ctx)
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude, "/project", "sess1")
if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
t.Errorf("Dockerfile KICS context should call codeRemediation, got: %q", ctx)
}
if strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
t.Errorf("Dockerfile context should not call codeRemediation, got: %q", ctx)
if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
t.Errorf("Dockerfile KICS context should not call imageRemediation, got: %q", ctx)
}
}

func TestFormatFindings_DockerComposePlatformUsesImageRemediation(t *testing.T) {
func TestFormatFindings_DockerComposePlatformUsesCodeRemediation(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("VulnerableBaseImage", "DockerCompose"),
}
_, 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)
_, ctx := formatFindings("/project/stack.yml", findings, agenthooks.AgentClaude, "/project", "sess1")
if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
t.Errorf("docker-compose KICS context should call codeRemediation, got: %q", ctx)
}
if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
t.Errorf("docker-compose KICS context should not call imageRemediation, got: %q", ctx)
}
}

func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("OpenSecurityGroup", "Terraform"),
}
_, ctx := formatFindings("/project/main.tf", findings, agenthooks.AgentClaude)
_, ctx := formatFindings("/project/main.tf", findings, agenthooks.AgentClaude, "/project", "sess1")
if !strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
t.Errorf("Terraform context should call codeRemediation, got: %q", ctx)
}
Expand All @@ -214,36 +172,36 @@ func TestFormatFindings_TerraformUsesCodeRemediation(t *testing.T) {
}
}

func TestCursorAdditionalContext_UsesImageRemediation(t *testing.T) {
func TestCursorAdditionalContext_UsesCodeRemediation(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)}
ctx := cursorAdditionalContext("/project/Dockerfile", findings)
if !strings.Contains(ctx, "mcp__plugin-cx-devassist-Checkmarx__imageRemediation") {
t.Errorf("cursor KICS context should use imageRemediation, got: %q", ctx)
ctx := cursorAdditionalContext("/project/Dockerfile", "cx", findings, "/project", "sess1")
if !strings.Contains(ctx, "mcp__plugin-cx-devassist-Checkmarx__codeRemediation") {
t.Errorf("cursor KICS context should use codeRemediation, got: %q", ctx)
}
if strings.Contains(ctx, "codeRemediation") {
t.Errorf("cursor KICS context should not use codeRemediation, got: %q", ctx)
if strings.Contains(ctx, "imageRemediation") {
t.Errorf("cursor KICS context should not use imageRemediation, 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)
}
if !strings.Contains(ctx, "cx-devassist:cx-devassist-kics") {
t.Errorf("cursor KICS context should reference cx-devassist:cx-devassist-kics skill, got: %q", ctx)
}
}

func TestFormatFindings_RoutesCursorContext(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)}
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentCursor)
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentCursor, "/project", "sess1")
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)
if !strings.Contains(ctx, "codeRemediation") {
t.Fatalf("cursor KICS context should reference codeRemediation, 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)
_, ctx = formatFindings("/project/main.tf", findings, agenthooks.AgentClaude, "/project", "sess1")
if strings.Contains(ctx, "cx-devassist-kics.mdc") {
t.Fatalf("claude agent should not get cursor-specific rule reference, got %q", ctx)
}
Expand All @@ -256,27 +214,52 @@ func TestAdditionalContext_GeminiUsesUnderscoreMCPNames(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{
iacResultWithPlatform("VulnerableBaseImage", "Dockerfile"),
}
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentGemini)
if !strings.Contains(ctx, "mcp_Checkmarx_imageRemediation") {
t.Errorf("Gemini context should use underscore MCP name, got: %q", ctx)
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentGemini, "/project", "sess1")
if !strings.Contains(ctx, "mcp_Checkmarx_codeRemediation") {
t.Errorf("Gemini context should use underscore codeRemediation MCP name, got: %q", ctx)
}
if strings.Contains(ctx, "mcp__Checkmarx__imageRemediation") {
if strings.Contains(ctx, "mcp__Checkmarx__codeRemediation") {
t.Errorf("Gemini context should not use double-underscore MCP name, got: %q", ctx)
}
if strings.Contains(ctx, "imageRemediation") {
t.Errorf("Gemini KICS context should not use imageRemediation, got: %q", ctx)
}
}

func TestAdditionalContext_ClaudeDoesNotOfferSuppress(t *testing.T) {
func TestAdditionalContext_ClaudeOffersSuppress(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)}
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude)
if strings.Contains(ctx, "ignore-vulnerability") {
t.Errorf("Claude context should not include suppress commands, got %q", ctx)
_, ctx := formatFindings("/project/Dockerfile", findings, agenthooks.AgentClaude, "/project", "sess1")
if !strings.Contains(ctx, "ignore-vulnerability") {
t.Errorf("Claude context should include suppress commands, got %q", ctx)
}
if !strings.Contains(ctx, `--scan-type iac`) {
t.Errorf("Claude context should include iac scan type, got %q", ctx)
}
}

func TestCursorAdditionalContext_DoesNotOfferSuppress(t *testing.T) {
func TestCursorAdditionalContext_OffersSuppress(t *testing.T) {
findings := []iacrealtime.IacRealtimeResult{iacResult("PrivilegedContainer", "sim1", "HIGH", 5)}
ctx := cursorAdditionalContext("/project/Dockerfile", findings)
if strings.Contains(ctx, "ignore-vulnerability") {
t.Errorf("cursor context should not include suppress commands, got %q", ctx)
ctx := cursorAdditionalContext("/project/Dockerfile", "cx", findings, "/project", "sess1")
if !strings.Contains(ctx, "ignore-vulnerability") {
t.Errorf("cursor context should include suppress commands, got %q", ctx)
}
}

func TestCursorAdditionalContext_MatchesAscaAskUserWording(t *testing.T) {
ctx := cursorAdditionalContext("/project/main.tf", "cx", nil, "/project", "sess1")
for _, want := range []string{
"ANALYZE each finding",
"for every real finding",
"mark as a confirmed false positive and unblock the write",
"intentionally-inserted misconfiguration",
"never because the request seems intentional",
"If the user chooses to suppress a finding",
} {
if !strings.Contains(ctx, want) {
t.Errorf("cursor KICS context should contain %q, got: %q", want, ctx)
}
}
if strings.Contains(ctx, "accept the risk") {
t.Errorf("cursor KICS context should not use old suppress wording, got: %q", ctx)
}
}
Loading
Loading