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
42 changes: 37 additions & 5 deletions internal/commands/agenthooks/cx/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions internal/commands/agenthooks/cx/vscode_self_healing_test.go
Original file line number Diff line number Diff line change
@@ -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`

Check failure on line 17 in internal/commands/agenthooks/cx/vscode_self_healing_test.go

View workflow job for this annotation

GitHub Actions / Lint (golangci-lint)

File is not properly formatted (gofmt)
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)
}
})
}
}
3 changes: 2 additions & 1 deletion internal/commands/agenthooks/guardrails/asca/asca.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion internal/commands/agenthooks/guardrails/kics/kics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
85 changes: 85 additions & 0 deletions internal/commands/agenthooks/hostenv/hostenv.go
Original file line number Diff line number Diff line change
@@ -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 "<agent>-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 <VS Code user data>/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 "<agent>-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
}
Loading
Loading