diff --git a/internal/commands/agenthooks/cx/hooks.go b/internal/commands/agenthooks/cx/hooks.go index 9fb7e331..dbe0b0d6 100644 --- a/internal/commands/agenthooks/cx/hooks.go +++ b/internal/commands/agenthooks/cx/hooks.go @@ -12,9 +12,11 @@ import ( "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/hostenv" "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/logger" "github.com/checkmarx/ast-cli/internal/wrappers" ) @@ -113,6 +115,9 @@ 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 { + if selfHealingOff(ev.Agent, "pre-file-write") { + return agenthooks.AcceptEdit() + } bindThreshold(ev.Agent) if ev.Agent == agenthooks.AgentCursor && len(ev.Changes) == 0 { if reason := guardrails.ScanFileForSecrets(ev.FilePath); reason != "" { @@ -290,7 +295,7 @@ func logRemediationTelemetry(agent, engine, severity, sessionID string) { telemetryData := &wrappers.DataForAITelemetry{ AIProvider: agent, - Agent: agent + "-cli", + Agent: hostenv.TelemetryAgent(agent), Engine: engine, ScanType: strings.ToLower(engine), UniqueID: wrappers.GetUniqueID(), @@ -325,14 +330,41 @@ func secretsApply(agent agenthooks.AgentID) bool { return switchesApply(agent) } -// scannerOn is scannerconfig.Enabled, limited to the agents the switches apply to. +// scannerOn is scannerconfig.Enabled, limited to the agents the switches apply to. GitHub Copilot inside +// VS Code takes the switches from the VS Code extension's IDE file instead, while that file is valid. func scannerOn(agent agenthooks.AgentID, s scannerconfig.Scanner) bool { - return !switchesApply(agent) || scannerconfig.Enabled(s) + if !switchesApply(agent) { + return true + } + if agent == agenthooks.AgentCopilotCLI && hostenv.InVSCode() { + return scannerconfig.IDEEnabled(s) + } + return scannerconfig.Enabled(s) +} + +// selfHealingOff reports whether this GitHub Copilot hook runs inside VS Code with the VS Code extension's +// "Checkmarx Self-Healing" setting unticked (a valid IDE file saying cx_self_healing_enabled=false). The +// file-write scans then stand down without telemetry; shell commands and prompts are handled as before. +// The plugin's own hook scripts already stop before running cx in that case; this covers hooks that +// call cx directly. A missing or invalid IDE file means on, and the terminal Copilot CLI never reads it. +func selfHealingOff(agent agenthooks.AgentID, hook string) bool { + if agent != agenthooks.AgentCopilotCLI || !hostenv.InVSCode() || scannerconfig.SelfHealingEnabled() { + return false + } + logger.PrintfIfVerbose("hooks: copilot-cli %s allowed without scanning host=vscode skip_reason=self_healing_disabled", hook) + return true } -// belowThreshold is scannerconfig.BelowThreshold, limited to the agents the switches apply to. +// belowThreshold is scannerconfig.BelowThreshold, limited to the agents the switches apply to. GitHub +// Copilot inside VS Code has no threshold setting, so it uses the IDE file's threshold, low when unset. func belowThreshold(agent agenthooks.AgentID, severity string) bool { - return switchesApply(agent) && scannerconfig.BelowThreshold(severity) + if !switchesApply(agent) { + return false + } + if agent == agenthooks.AgentCopilotCLI && hostenv.InVSCode() { + return scannerconfig.IDEBelowThreshold(severity) + } + return scannerconfig.BelowThreshold(severity) } // agentToString converts agenthooks.AgentID enum to string representation for telemetry. diff --git a/internal/commands/agenthooks/cx/vscode_self_healing_test.go b/internal/commands/agenthooks/cx/vscode_self_healing_test.go new file mode 100644 index 00000000..8d69aa2b --- /dev/null +++ b/internal/commands/agenthooks/cx/vscode_self_healing_test.go @@ -0,0 +1,127 @@ +//go:build !integration + +package cx + +import ( + "os" + "path/filepath" + "testing" + + agenthooks "github.com/Checkmarx/ast-cx-hooks" + + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/scannerconfig" +) + +// PLUGIN_ROOT values as the hosts set them (AST-186850 PoC; Windows paths, any user name). +const ( + rootVSCodeFromCLIInstall = `C:\Users\u\AppData\Roaming\Code\agentPlugins\file-c-3A-Users-u-copilot-installed-plugins-cx-devassist-marketplace-cx-devassist\1a15407167c` + rootVSCodeFromMarketplace = `C:\Users\u\AppData\Roaming\Code\agentPlugins\file-c-3A-Users-u-vscode-agent-plugins-marketplace-repositories-github-com-Checkmarx-cx-agentic-ai\1a1542d091b` + rootCLI = `C:\Users\u\.copilot\installed-plugins\cx-devassist-marketplace\cx-devassist` +) + +// useHost sets the hook environment the host would inject. +func useHost(t *testing.T, pluginRoot, integrationID string) { + t.Helper() + t.Setenv("PLUGIN_ROOT", pluginRoot) + t.Setenv("GITHUB_COPILOT_INTEGRATION_ID", integrationID) +} + +// useIDEConfig points the user's home at a temp dir holding ~/.checkmarx/cx-onboarding-ide.properties +// (and optionally cx-onboarding.properties); an empty body means no file at all. +func useIDEConfig(t *testing.T, ideBody, cliBody string) { + t.Helper() + dir := t.TempDir() + t.Cleanup(setHomeDir(dir)) + cxDir := filepath.Join(dir, ".checkmarx") + if err := os.MkdirAll(cxDir, 0o755); err != nil { + t.Fatal(err) + } + for name, body := range map[string]string{"cx-onboarding-ide.properties": ideBody, "cx-onboarding.properties": cliBody} { + if body == "" { + continue + } + if err := os.WriteFile(filepath.Join(cxDir, name), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + } +} + +func TestCxBeforeFileEdit_SelfHealingOff(t *testing.T) { + const secret = "ghp_self_healing_test_secret_value" + const off = "cx_schema_version=1\ncx_self_healing_enabled=false\n" + cases := []struct { + name string + agent agenthooks.AgentID + root string + ide string + wantBlock bool + }{ + {"VS Code + OFF allows without scanning", agenthooks.AgentCopilotCLI, rootVSCodeFromMarketplace, off, false}, + {"VS Code + ON scans", agenthooks.AgentCopilotCLI, rootVSCodeFromCLIInstall, "cx_self_healing_enabled=true\n", true}, + {"VS Code + no IDE file scans (default ON)", agenthooks.AgentCopilotCLI, rootVSCodeFromCLIInstall, "", true}, + {"VS Code + unknown schema scans", agenthooks.AgentCopilotCLI, rootVSCodeFromCLIInstall, "cx_schema_version=2\ncx_self_healing_enabled=false\n", true}, + {"terminal ignores the IDE file", agenthooks.AgentCopilotCLI, rootCLI, off, true}, + {"other agents ignore it", agenthooks.AgentWindsurf, rootVSCodeFromCLIInstall, off, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + resetHookGlobals(t) + useIDEConfig(t, tc.ide, "") + useHost(t, tc.root, "") + secretsScanner = stubSecretsScanner(secret) + v := cxBeforeFileEdit(secretsEdit(secret, tc.agent)) + if v.Permit == tc.wantBlock { + t.Fatalf("Permit=%v, wantBlock=%v", v.Permit, tc.wantBlock) + } + }) + } +} + +func TestScannerOn_VSCodeUsesIDEFile(t *testing.T) { + const ideSecretsOff = "cx_schema_version=1\ncx_secret_scanner_enabled=false\n" + const cliSecretsOff = "cx_secret_scanner_enabled=false\n" + cases := []struct { + name string + root string + ide, cli string + want bool + }{ + {"VS Code follows the IDE file", rootVSCodeFromCLIInstall, ideSecretsOff, "", false}, + {"VS Code IDE file missing key is on, even if the terminal file is off", rootVSCodeFromCLIInstall, "cx_schema_version=1\n", cliSecretsOff, true}, + {"VS Code without a valid IDE file falls back to the terminal file", rootVSCodeFromCLIInstall, "", cliSecretsOff, false}, + {"terminal never reads the IDE file", rootCLI, ideSecretsOff, "", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + useIDEConfig(t, tc.ide, tc.cli) + useHost(t, tc.root, "") + if got := scannerOn(agenthooks.AgentCopilotCLI, scannerconfig.Secrets); got != tc.want { + t.Fatalf("scannerOn = %v, want %v", got, tc.want) + } + }) + } +} + +func TestBelowThreshold_VSCodeDefaultsToLow(t *testing.T) { + const cliHigh = "cx_severity_threshold=high\n" + cases := []struct { + name string + root string + ide, cli string + want bool + }{ + {"VS Code without a threshold blocks Medium (low)", rootVSCodeFromCLIInstall, "cx_schema_version=1\n", cliHigh, false}, + {"VS Code without an IDE file blocks Medium (low)", rootVSCodeFromMarketplace, "", cliHigh, false}, + {"VS Code honours a threshold in the IDE file", rootVSCodeFromCLIInstall, "cx_schema_version=1\n" + cliHigh, "", true}, + {"terminal keeps the terminal threshold", rootCLI, "cx_schema_version=1\n", cliHigh, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + useIDEConfig(t, tc.ide, tc.cli) + useHost(t, tc.root, "") + if got := belowThreshold(agenthooks.AgentCopilotCLI, "Medium"); got != tc.want { + t.Fatalf("belowThreshold = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/commands/agenthooks/guardrails/asca/asca.go b/internal/commands/agenthooks/guardrails/asca/asca.go index 09f10301..2427913e 100644 --- a/internal/commands/agenthooks/guardrails/asca/asca.go +++ b/internal/commands/agenthooks/guardrails/asca/asca.go @@ -6,6 +6,7 @@ import ( "strings" agenthooks "github.com/Checkmarx/ast-cx-hooks" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/hostenv" "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/services" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" @@ -192,7 +193,7 @@ func logASCATelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, session } telemetryData := &wrappers.DataForAITelemetry{ - Agent: agent + "-cli", + Agent: hostenv.TelemetryAgent(agent), AIProvider: agent, Engine: "Asca", TotalCount: totalCount, diff --git a/internal/commands/agenthooks/guardrails/kics/kics.go b/internal/commands/agenthooks/guardrails/kics/kics.go index 17f63032..684f4ee0 100644 --- a/internal/commands/agenthooks/guardrails/kics/kics.go +++ b/internal/commands/agenthooks/guardrails/kics/kics.go @@ -7,6 +7,7 @@ import ( "strings" agenthooks "github.com/Checkmarx/ast-cx-hooks" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/hostenv" "github.com/checkmarx/ast-cli/internal/logger" "github.com/checkmarx/ast-cli/internal/params" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/iacrealtime" @@ -170,7 +171,7 @@ func logKicsTelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, session } telemetryData := &wrappers.DataForAITelemetry{ - Agent: agent + "-cli", + Agent: hostenv.TelemetryAgent(agent), AIProvider: agent, Engine: "IaC", TotalCount: totalCount, diff --git a/internal/commands/agenthooks/guardrails/secrets/secrets.go b/internal/commands/agenthooks/guardrails/secrets/secrets.go index b5a73c85..b4c3bbd8 100644 --- a/internal/commands/agenthooks/guardrails/secrets/secrets.go +++ b/internal/commands/agenthooks/guardrails/secrets/secrets.go @@ -4,6 +4,7 @@ import ( "os" agenthooks "github.com/Checkmarx/ast-cx-hooks" + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/hostenv" "github.com/checkmarx/ast-cli/internal/logger" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/ignore" "github.com/checkmarx/ast-cli/internal/services/realtimeengine/secretsrealtime" @@ -121,7 +122,7 @@ func logSecretsTelemetry(telemetryWrapper wrappers.TelemetryWrapper, agent, sess } telemetryData := &wrappers.DataForAITelemetry{ - Agent: agent + "-cli", + Agent: hostenv.TelemetryAgent(agent), AIProvider: agent, Engine: "Secrets", TotalCount: totalCount, diff --git a/internal/commands/agenthooks/hostenv/hostenv.go b/internal/commands/agenthooks/hostenv/hostenv.go new file mode 100644 index 00000000..1ca85ea8 --- /dev/null +++ b/internal/commands/agenthooks/hostenv/hostenv.go @@ -0,0 +1,85 @@ +// Package hostenv tells where a GitHub Copilot hook runs — the terminal Copilot CLI or GitHub Copilot +// inside VS Code — and derives the telemetry agent label from it. Shared by the cx hook handlers and the +// guardrail packages so they cannot drift apart. Mirrors the cx-devassist Copilot plugin's +// _in_vscode / cx_in_vscode. +package hostenv + +import ( + "os" + "strings" + + "github.com/checkmarx/ast-cli/internal/commands/agenthooks/scannerconfig" +) + +// Where a Copilot hook runs. +const ( + HostUnknown = "" + HostCLI = "cli" + HostVSCode = "vscode" + HostOther = "other" // a VS Code fork (Cursor, Windsurf, Kiro, Devin, ...): never treated as VS Code +) + +// VSCodeIntegrationID is the GITHUB_COPILOT_INTEGRATION_ID VS Code sets on the Copilot CLI engine it bundles. +const VSCodeIntegrationID = "vscode-chat" + +// copilotAgent is the agent name the hook handlers use for GitHub Copilot (see cx.agentToString). +const copilotAgent = "Copilot" + +// Telemetry agent labels for GitHub Copilot inside VS Code, by the VS Code extension that wrote the +// IDE state file (cx_ide_extension). The terminal keeps "-cli". +const ( + agentVSCode = "Copilot-VSCode" + agentVSCodeOneAssist = "Copilot-VSCode-OneAssist" // Checkmarx One extension (ast-results) + agentVSCodeDevAssist = "Copilot-VSCode-DevAssist" // Checkmarx Developer Assist extension (cx-dev-assist) +) + +// PluginHost reports where this hook runs, from the PLUGIN_ROOT the host injected into the hook's +// environment (cx inherits it from the plugin's hook script). VS Code always runs its own copy of the +// plugin under /agentPlugins/ (Windows %APPDATA%\Code\agentPlugins, macOS +// ~/Library/Application Support/Code/agentPlugins, Linux ~/.config/Code/agentPlugins, Insiders +// likewise), whether the plugin was installed by the Copilot CLI or from VS Code's marketplace; the +// terminal Copilot CLI (also when started in VS Code's integrated terminal) runs it from +// ~/.copilot/installed-plugins/. Hashes and user names in the path are ignored. Any other PLUGIN_ROOT +// (a VS Code fork, JetBrains, Visual Studio, ...) is HostOther. Only when PLUGIN_ROOT is not set at all +// does GITHUB_COPILOT_INTEGRATION_ID=vscode-chat mark VS Code. +func PluginHost() string { + root := strings.ToLower(strings.ReplaceAll(os.Getenv("PLUGIN_ROOT"), `\`, "/")) + // The terminal marker is checked first: VS Code's folder names encode the source path with dashes + // (…-copilot-installed-plugins-…), so they never contain it, while a terminal path could contain + // "agentPlugins" by chance (a user or folder name). Only VS Code's own user-data folder ("Code", + // "Code - Insiders") counts: VS Code forks keep their agentPlugins copies under their own folder. + switch { + case strings.Contains(root, "/.copilot/installed-plugins/"): + return HostCLI + case strings.Contains(root, "/code/agentplugins/"), strings.Contains(root, "/code - insiders/agentplugins/"): + return HostVSCode + case strings.TrimSpace(root) != "": + // Any other plugin folder (a VS Code fork's agentPlugins, JetBrains, Visual Studio, ...) is not + // VS Code, and the fallback below must not override that. + return HostOther + case strings.TrimSpace(os.Getenv("GITHUB_COPILOT_INTEGRATION_ID")) == VSCodeIntegrationID: + return HostVSCode + } + return HostUnknown +} + +// InVSCode reports whether this hook runs inside VS Code. +func InVSCode() bool { + return PluginHost() == HostVSCode +} + +// TelemetryAgent is the telemetry `agent` value for an event from the given agent name: GitHub Copilot +// inside VS Code is "Copilot-VSCode-OneAssist" / "Copilot-VSCode-DevAssist" by the extension that wrote +// the IDE state file, or "Copilot-VSCode" without a valid one; everything else keeps "-cli". +func TelemetryAgent(agent string) string { + if agent != copilotAgent || !InVSCode() { + return agent + "-cli" + } + switch scannerconfig.IDEExtension() { + case "ast-results": + return agentVSCodeOneAssist + case "cx-dev-assist": + return agentVSCodeDevAssist + } + return agentVSCode +} diff --git a/internal/commands/agenthooks/hostenv/hostenv_test.go b/internal/commands/agenthooks/hostenv/hostenv_test.go new file mode 100644 index 00000000..c3cbbb60 --- /dev/null +++ b/internal/commands/agenthooks/hostenv/hostenv_test.go @@ -0,0 +1,99 @@ +//go:build !integration + +package hostenv + +import ( + "os" + "path/filepath" + "testing" +) + +// PLUGIN_ROOT values as the hosts set them (AST-186850 PoC; Windows paths, any user name). +const ( + rootVSCodeFromCLIInstall = `C:\Users\u\AppData\Roaming\Code\agentPlugins\file-c-3A-Users-u-copilot-installed-plugins-cx-devassist-marketplace-cx-devassist\1a15407167c` + rootVSCodeFromMarketplace = `C:\Users\u\AppData\Roaming\Code\agentPlugins\file-c-3A-Users-u-vscode-agent-plugins-marketplace-repositories-github-com-Checkmarx-cx-agentic-ai\1a1542d091b` + rootCLI = `C:\Users\u\.copilot\installed-plugins\cx-devassist-marketplace\cx-devassist` +) + +func useHost(t *testing.T, pluginRoot, integrationID string) { + t.Helper() + t.Setenv("PLUGIN_ROOT", pluginRoot) + t.Setenv("GITHUB_COPILOT_INTEGRATION_ID", integrationID) +} + +// useIDEFile points the user's home at a temp dir holding ~/.checkmarx/cx-onboarding-ide.properties +// with the given body (no file when empty). +func useIDEFile(t *testing.T, body string) { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + if body == "" { + return + } + cxDir := filepath.Join(dir, ".checkmarx") + if err := os.MkdirAll(cxDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cxDir, "cx-onboarding-ide.properties"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestPluginHost(t *testing.T) { + cases := []struct { + name, root, integrationID, want string + }{ + {"VS Code copy of a Copilot CLI install", rootVSCodeFromCLIInstall, "", HostVSCode}, + {"VS Code marketplace install", rootVSCodeFromMarketplace, "", HostVSCode}, + {"Linux VS Code", "/home/u/.config/Code/agentPlugins/x/1a", "", HostVSCode}, + {"macOS VS Code Insiders", "/Users/u/Library/Application Support/Code - Insiders/agentPlugins/x/1a", "", HostVSCode}, + {"terminal Copilot CLI", rootCLI, "", HostCLI}, + {"terminal path wins over the integration id", rootCLI, VSCodeIntegrationID, HostCLI}, + {"Windsurf copy is not VS Code", `C:\Users\u\AppData\Roaming\Windsurf\agentPlugins\x\1a`, VSCodeIntegrationID, HostOther}, + {"Cursor copy is not VS Code", "/Users/u/Library/Application Support/Cursor/agentPlugins/x/1a", "", HostOther}, + {"Kiro copy is not VS Code", "/home/u/.config/Kiro/agentPlugins/x/1a", "", HostOther}, + {"terminal path containing agentPlugins by chance", `C:\Users\agentPlugins\.copilot\installed-plugins\m\cx-devassist`, "", HostCLI}, + {"terminal path under an agentPlugins folder", `/home/u/agentPlugins/.copilot/installed-plugins/m/cx-devassist`, "", HostCLI}, + {"other plugin folder (JetBrains, Visual Studio, ...) is not VS Code", `D:\other\plugin`, VSCodeIntegrationID, HostOther}, + {"no root + integration id", "", VSCodeIntegrationID, HostVSCode}, + {"nothing set", "", "", HostUnknown}, + {"other integration id", "", "copilot-developer-cli", HostUnknown}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + useHost(t, tc.root, tc.integrationID) + if got := PluginHost(); got != tc.want { + t.Fatalf("PluginHost() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestTelemetryAgent(t *testing.T) { + const oneAssist = "cx_schema_version=1\ncx_ide_extension=ast-results\n" + const devAssist = "cx_schema_version=1\ncx_ide_extension=cx-dev-assist\n" + cases := []struct { + name, agent, root, ide, want string + }{ + {"terminal Copilot", "Copilot", rootCLI, oneAssist, "Copilot-cli"}, + {"VS Code + Checkmarx One", "Copilot", rootVSCodeFromCLIInstall, oneAssist, "Copilot-VSCode-OneAssist"}, + {"VS Code + Developer Assist", "Copilot", rootVSCodeFromMarketplace, devAssist, "Copilot-VSCode-DevAssist"}, + {"VS Code, no IDE file", "Copilot", rootVSCodeFromCLIInstall, "", "Copilot-VSCode"}, + {"VS Code, unknown extension id", "Copilot", rootVSCodeFromCLIInstall, "cx_ide_extension=other\n", "Copilot-VSCode"}, + {"VS Code, invalid IDE file", "Copilot", rootVSCodeFromCLIInstall, "cx_schema_version=2\ncx_ide_extension=ast-results\n", "Copilot-VSCode"}, + {"VS Code fork stays -cli", "Copilot", `C:\Users\u\AppData\Roaming\Windsurf\agentPlugins\x\1a`, oneAssist, "Copilot-cli"}, + {"no host signal stays -cli", "Copilot", "", oneAssist, "Copilot-cli"}, + {"other agents unchanged in VS Code", "Claude", rootVSCodeFromCLIInstall, oneAssist, "Claude-cli"}, + {"other agents unchanged", "Cursor", "", "", "Cursor-cli"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + useIDEFile(t, tc.ide) + useHost(t, tc.root, "") + if got := TelemetryAgent(tc.agent); got != tc.want { + t.Fatalf("TelemetryAgent(%q) = %q, want %q", tc.agent, got, tc.want) + } + }) + } +} diff --git a/internal/commands/agenthooks/scannerconfig/scannerconfig.go b/internal/commands/agenthooks/scannerconfig/scannerconfig.go index aafaf7b2..57357561 100644 --- a/internal/commands/agenthooks/scannerconfig/scannerconfig.go +++ b/internal/commands/agenthooks/scannerconfig/scannerconfig.go @@ -2,6 +2,11 @@ // ~/.checkmarx/cx-onboarding.properties (seeded by the agent plugins). Every scanner is ENABLED // unless its key is explicitly set to a false-like value, and any read problem also means enabled: // the switches can only narrow what the hooks do, never make them block. +// +// GitHub Copilot agent mode in VS Code has its own state file, +// ~/.checkmarx/cx-onboarding-ide.properties, written by the VS Code extension: the Self-Healing +// switch (cx_self_healing_enabled) and the scanner switches the VS Code settings set. It is read +// only for events from VS Code; the terminal hosts never read it. package scannerconfig import ( @@ -28,18 +33,33 @@ const ( const ( fileName = "cx-onboarding.properties" + // ideFileName is the VS Code extension's state file (see IDEPath). + ideFileName = "cx-onboarding-ide.properties" // maxBytes mirrors the plugins' reader: an oversized file is treated as unreadable (all enabled). maxBytes = 8192 utf8BOM = "\xef\xbb\xbf" + + ideSchemaKey = "cx_schema_version" + ideSchemaVersion = "1" + ideSelfHealingKey = "cx_self_healing_enabled" ) // Path returns ~/.checkmarx/cx-onboarding.properties, or "" when the home directory is unknown. func Path() string { + return homeFile(fileName) +} + +// IDEPath returns ~/.checkmarx/cx-onboarding-ide.properties, or "" when the home directory is unknown. +func IDEPath() string { + return homeFile(ideFileName) +} + +func homeFile(name string) string { home, err := os.UserHomeDir() if err != nil || home == "" { return "" } - return filepath.Join(home, ".checkmarx", fileName) + return filepath.Join(home, ".checkmarx", name) } // Enabled reports whether the scanner is switched on in the user's config file. @@ -55,31 +75,103 @@ func enabledIn(path string, s Scanner) bool { // valueIn returns the last `key=value` for key in the file, or found=false when the file is missing, // unreadable, oversized or does not set the key. Same parsing rules as the plugins' readers. func valueIn(path, key string) (value string, found bool) { - if path == "" { + props, ok := readProps(path) + if !ok { return "", false } + value, found = props[key] + return value, found +} + +// readProps parses the file's `key=value` lines (last occurrence wins, # comments, BOM stripped). +// ok is false when the file is missing, unreadable or oversized. +func readProps(path string) (props map[string]string, ok bool) { + if path == "" { + return nil, false + } f, err := os.Open(path) if err != nil { - return "", false + return nil, false } defer func() { _ = f.Close() }() raw, err := io.ReadAll(io.LimitReader(f, maxBytes+1)) if err != nil || len(raw) > maxBytes { - return "", false + return nil, false } + props = map[string]string{} sc := bufio.NewScanner(strings.NewReader(strings.TrimPrefix(string(raw), utf8BOM))) for sc.Scan() { // last occurrence wins line := strings.TrimSpace(sc.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } - k, v, ok := strings.Cut(line, "=") - if !ok || strings.TrimSpace(k) != key { + k, v, found := strings.Cut(line, "=") + if !found { continue } - value, found = v, true + props[strings.TrimSpace(k)] = v } - return value, found + return props, true +} + +// ideProps returns the VS Code state file's keys when the file is valid: it exists, is readable, is at +// most 8 KB, and its cx_schema_version is absent or 1. An invalid file returns ok=false. +func ideProps(path string) (props map[string]string, ok bool) { + props, ok = readProps(path) + if !ok { + return nil, false + } + if v, set := props[ideSchemaKey]; set && strings.TrimSpace(v) != ideSchemaVersion { + return nil, false + } + return props, true +} + +// SelfHealingEnabled reports whether Checkmarx Self-Healing is on in VS Code. It is OFF only when the +// VS Code state file is valid and cx_self_healing_enabled is a false-like value; a missing, unreadable +// or unknown-schema file, a missing key or any other value means ON. +func SelfHealingEnabled() bool { + return selfHealingIn(IDEPath()) +} + +func selfHealingIn(path string) bool { + props, ok := ideProps(path) + if !ok { + return true + } + v, set := props[ideSelfHealingKey] + return !set || !isFalse(v) +} + +// IDEEnabled is Enabled for events from VS Code: when the VS Code state file is valid its +// cx_*_scanner_enabled keys decide (a missing key means enabled); otherwise it falls back to the +// terminal's ~/.checkmarx/cx-onboarding.properties, exactly like Enabled. +func IDEEnabled(s Scanner) bool { + return ideEnabledIn(IDEPath(), Path(), s) +} + +func ideEnabledIn(idePath, cliPath string, s Scanner) bool { + props, ok := ideProps(idePath) + if !ok { + return enabledIn(cliPath, s) + } + v, set := props[string(s)] + return !set || !isFalse(v) +} + +// IDEExtension returns the id of the VS Code extension that wrote the VS Code state file +// (cx_ide_extension: "ast-results" for Checkmarx One, "cx-dev-assist" for Developer Assist), or "" when +// the file is not valid or carries no id. +func IDEExtension() string { + return ideExtensionIn(IDEPath()) +} + +func ideExtensionIn(path string) string { + props, ok := ideProps(path) + if !ok { + return "" + } + return strings.TrimSpace(props["cx_ide_extension"]) } func isFalse(v string) bool { @@ -103,19 +195,45 @@ func Threshold() int { } func thresholdIn(path string) int { - value, found := valueIn(path, thresholdKey) - if !found { - return 1 - } + value, _ := valueIn(path, thresholdKey) + return thresholdRank(value) +} + +// thresholdRank is the rank of a cx_severity_threshold value; blank or invalid means low (1). +func thresholdRank(value string) int { if rank, ok := severityRank[strings.ToLower(strings.TrimSpace(value))]; ok { return rank } return 1 } +// IDEThreshold is Threshold for events from VS Code. The VS Code extension has no threshold setting, +// so only a cx_severity_threshold in a valid VS Code state file counts; otherwise it is low (nothing +// filtered). The terminal's ~/.checkmarx/cx-onboarding.properties threshold never applies to VS Code. +func IDEThreshold() int { + return ideThresholdIn(IDEPath()) +} + +func ideThresholdIn(path string) int { + props, ok := ideProps(path) + if !ok { + return 1 + } + return thresholdRank(props[thresholdKey]) +} + // BelowThreshold reports whether a deny whose highest severity is `severity` should be let through // because it is under cx_severity_threshold. An unranked severity returns false (keep blocking). func BelowThreshold(severity string) bool { + return belowRank(severity, Threshold()) +} + +// IDEBelowThreshold is BelowThreshold for events from VS Code, against IDEThreshold. +func IDEBelowThreshold(severity string) bool { + return belowRank(severity, IDEThreshold()) +} + +func belowRank(severity string, threshold int) bool { rank, ok := severityRank[strings.ToLower(strings.TrimSpace(severity))] - return ok && rank < Threshold() + return ok && rank < threshold } diff --git a/internal/commands/agenthooks/scannerconfig/scannerconfig_test.go b/internal/commands/agenthooks/scannerconfig/scannerconfig_test.go index 3353059f..78aecd9c 100644 --- a/internal/commands/agenthooks/scannerconfig/scannerconfig_test.go +++ b/internal/commands/agenthooks/scannerconfig/scannerconfig_test.go @@ -136,3 +136,131 @@ func TestBelowThreshold(t *testing.T) { t.Error("blank threshold must filter nothing") } } + +func writeIDE(t *testing.T, body string) string { + t.Helper() + p := filepath.Join(t.TempDir(), ideFileName) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func TestSelfHealingIn(t *testing.T) { + cases := []struct { + name string + body string + want bool + }{ + {"key missing is on", "cx_schema_version=1\n", true}, + {"explicit true is on", "cx_self_healing_enabled=true\n", true}, + {"garbage is on", "cx_self_healing_enabled=flase\n", true}, + {"empty value is on", "cx_self_healing_enabled=\n", true}, + {"false is off", "cx_schema_version=1\ncx_self_healing_enabled=false\n", false}, + {"no schema key, off", "cx_self_healing_enabled=false\n", false}, + {"caps / spaces / CRLF / BOM, off", "\xef\xbb\xbfcx_schema_version= 1 \r\ncx_self_healing_enabled= Disabled \r\n", false}, + {"0 / no / off are off", "cx_self_healing_enabled=0\ncx_self_healing_enabled=no\ncx_self_healing_enabled=off\n", false}, + {"last occurrence wins", "cx_self_healing_enabled=false\ncx_self_healing_enabled=true\n", true}, + {"commented key ignored", "# cx_self_healing_enabled=false\n", true}, + {"unknown schema is on", "cx_schema_version=2\ncx_self_healing_enabled=false\n", true}, + {"blank schema is on", "cx_schema_version=\ncx_self_healing_enabled=false\n", true}, + {"oversized is on", strings.Repeat("# pad\n", 5000) + "cx_self_healing_enabled=false\n", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := selfHealingIn(writeIDE(t, tc.body)); got != tc.want { + t.Fatalf("selfHealingIn(%q) = %v, want %v", tc.body, got, tc.want) + } + }) + } + if !selfHealingIn(filepath.Join(t.TempDir(), "missing")) || !selfHealingIn("") { + t.Error("missing file / path must mean on") + } + if !selfHealingIn(t.TempDir()) { + t.Error("unreadable (directory) must mean on") + } +} + +func TestIDEEnabledIn(t *testing.T) { + cli := write(t, "cx_asca_scanner_enabled=false\n") // terminal: ASCA off, others on + cases := []struct { + name string + ide string // "" = no IDE file + s Scanner + want bool + }{ + {"no IDE file falls back to CLI (off)", "", ASCA, false}, + {"no IDE file falls back to CLI (on)", "", KICS, true}, + {"valid IDE file, key missing is on despite CLI off", "cx_schema_version=1\n", ASCA, true}, + {"valid IDE file, key off", "cx_kics_scanner_enabled=false\n", KICS, false}, + {"valid IDE file, key on", "cx_asca_scanner_enabled=true\n", ASCA, true}, + {"valid IDE file, garbage is on", "cx_sca_scanner_enabled=banana\n", SCA, true}, + {"valid IDE file, secrets off", "cx_secret_scanner_enabled=OFF\n", Secrets, false}, + {"unknown schema falls back to CLI", "cx_schema_version=9\ncx_asca_scanner_enabled=true\n", ASCA, false}, + {"oversized IDE file falls back to CLI", strings.Repeat("# pad\n", 5000), ASCA, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ide := filepath.Join(t.TempDir(), "missing") + if tc.ide != "" { + ide = writeIDE(t, tc.ide) + } + if got := ideEnabledIn(ide, cli, tc.s); got != tc.want { + t.Fatalf("ideEnabledIn(%s) = %v, want %v", tc.s, got, tc.want) + } + }) + } +} + +func TestIDEPathUsesHome(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + if got := IDEPath(); got != filepath.Join(dir, ".checkmarx", "cx-onboarding-ide.properties") { + t.Errorf("unexpected path %q", got) + } + if !SelfHealingEnabled() || !IDEEnabled(ASCA) { + t.Error("no files under home must mean on") + } + _ = os.MkdirAll(filepath.Join(dir, ".checkmarx"), 0o755) + _ = os.WriteFile(IDEPath(), []byte("cx_self_healing_enabled=false\ncx_kics_scanner_enabled=false\n"), 0o600) + if SelfHealingEnabled() || IDEEnabled(KICS) || !IDEEnabled(ASCA) { + t.Error("SelfHealingEnabled / IDEEnabled should read the IDE file under the user's home") + } + if !Enabled(KICS) { + t.Error("Enabled (terminal) must never read the IDE file") + } +} + +func TestIDEThresholdIn(t *testing.T) { + cases := map[string]int{ + "cx_schema_version=1\n": 1, // VS Code writes no threshold: low + "cx_severity_threshold=high\n": 3, + "cx_schema_version=1\ncx_severity_threshold=\n": 1, // blank + "cx_severity_threshold=hihg\n": 1, // invalid + "cx_schema_version=2\ncx_severity_threshold=high\n": 1, // unknown schema: file ignored + } + for body, want := range cases { + if got := ideThresholdIn(writeIDE(t, body)); got != want { + t.Errorf("%q: got %d, want %d", body, got, want) + } + } + if ideThresholdIn(filepath.Join(t.TempDir(), "missing")) != 1 || ideThresholdIn("") != 1 { + t.Error("missing IDE file / path must mean low") + } +} + +func TestIDEBelowThresholdIgnoresTerminalThreshold(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Setenv("USERPROFILE", dir) + _ = os.MkdirAll(filepath.Join(dir, ".checkmarx"), 0o755) + _ = os.WriteFile(Path(), []byte("cx_severity_threshold=critical\n"), 0o600) + _ = os.WriteFile(IDEPath(), []byte("cx_schema_version=1\ncx_self_healing_enabled=true\n"), 0o600) + if !BelowThreshold("High") { + t.Error("terminal: a critical threshold must relax High") + } + if IDEBelowThreshold("Low") || IDEBelowThreshold("High") { + t.Error("VS Code without a threshold must block at every severity (low)") + } +}