From bda272ad4e7dbf64d2f8aac6f573ddc46d0fe48a Mon Sep 17 00:00:00 2001 From: ramya18101 Date: Wed, 2 Sep 2026 18:56:29 +0530 Subject: [PATCH] feat: enhance agent detection with flexible environment conditions --- internal/cli/agent_detection.go | 80 ++++++++++++++---------- internal/cli/agent_detection_test.go | 93 +++++++++++++++------------- 2 files changed, 96 insertions(+), 77 deletions(-) diff --git a/internal/cli/agent_detection.go b/internal/cli/agent_detection.go index 4f6f9aa61..8c7f8ad17 100644 --- a/internal/cli/agent_detection.go +++ b/internal/cli/agent_detection.go @@ -13,42 +13,66 @@ import ( // It is distinct from "unknown", which means no invoker signal was found at all. const agentClientUnknownAgent = "unknown-agent" -// agentEnvEntry maps an env var to a canonical agent_client name. -// The requiredPrefix field restricts matching to values with that prefix (case-insensitive). -type agentEnvEntry struct { +// envCondition is one requirement on an env var: presence by default, an exact value +// via requiredValue, or a case-insensitive prefix via requiredPrefix. +type envCondition struct { envVar string + requiredValue string requiredPrefix string - agentName string +} + +// agentEnvEntry maps a set of AND-ed env conditions to a canonical agent_client name. +type agentEnvEntry struct { + conditions []envCondition + agentName string +} + +// matches reports whether every condition holds. An entry with no conditions never matches. +func (e agentEnvEntry) matches(getEnv func(string) string) bool { + for _, cond := range e.conditions { + raw := strings.TrimSpace(getEnv(cond.envVar)) + switch { + case raw == "": + return false + case cond.requiredValue != "" && raw != cond.requiredValue: + return false + case cond.requiredPrefix != "" && !strings.HasPrefix(strings.ToLower(raw), strings.ToLower(cond.requiredPrefix)): + return false + } + } + + return len(e.conditions) > 0 } // agentEnvTable is the ordered allow-list of agent env signals. First match wins. var agentEnvTable = []agentEnvEntry{ // Claude Code. - {envVar: "CLAUDECODE", agentName: "claude-code"}, - {envVar: "CLAUDE_CODE_SESSION_ID", agentName: "claude-code"}, - {envVar: "CLAUDE_CODE_ENTRYPOINT", agentName: "claude-code"}, - {envVar: "AI_AGENT", requiredPrefix: "claude-code", agentName: "claude-code"}, - // Cursor. - {envVar: "CURSOR_AGENT", agentName: "cursor"}, - {envVar: "CURSOR_TRACE_ID", agentName: "cursor"}, - {envVar: "CURSOR_CONVERSATION_ID", agentName: "cursor"}, + {conditions: []envCondition{{envVar: "CLAUDECODE"}}, agentName: "claude-code"}, + {conditions: []envCondition{{envVar: "CLAUDE_CODE_SESSION_ID"}}, agentName: "claude-code"}, + {conditions: []envCondition{{envVar: "CLAUDE_CODE_ENTRYPOINT"}}, agentName: "claude-code"}, + {conditions: []envCondition{{envVar: "AI_AGENT", requiredPrefix: "claude-code"}}, agentName: "claude-code"}, + // Cursor — the agent, not a human in its terminal. CURSOR_TRACE_ID is set for every + // Cursor terminal, so we key on the agent-execution markers instead: CURSOR_AGENT + // (headless cursor-agent CLI) and CURSOR_EXTENSION_HOST_ROLE=agent-exec (in-IDE agent). + {conditions: []envCondition{{envVar: "CURSOR_AGENT"}}, agentName: "cursor"}, + {conditions: []envCondition{{envVar: "CURSOR_EXTENSION_HOST_ROLE", requiredValue: "agent-exec"}}, agentName: "cursor"}, // Codex. - {envVar: "CODEX_THREAD_ID", agentName: "codex"}, + {conditions: []envCondition{{envVar: "CODEX_THREAD_ID"}}, agentName: "codex"}, // Gemini-cli. - {envVar: "GEMINI_CLI_VERSION", agentName: "gemini"}, + {conditions: []envCondition{{envVar: "GEMINI_CLI_VERSION"}}, agentName: "gemini"}, // AntiGravity. - {envVar: "ANTIGRAVITY_CLI_ALIAS", agentName: "antigravity"}, - {envVar: "ANTIGRAVITY_CONVERSATION_ID", agentName: "antigravity"}, + {conditions: []envCondition{{envVar: "ANTIGRAVITY_CLI_ALIAS"}}, agentName: "antigravity"}, + {conditions: []envCondition{{envVar: "ANTIGRAVITY_CONVERSATION_ID"}}, agentName: "antigravity"}, // AI_AGENT catch-all (must be last). - {envVar: "AI_AGENT", agentName: agentClientUnknownAgent}, + {conditions: []envCondition{{envVar: "AI_AGENT"}}, agentName: agentClientUnknownAgent}, } // agentProcessNames maps parent process names (partial, lower-cased) to agent names. -// Covers both AI agent binaries and CLI surfaces that spawn auth0-cli as a subprocess. +// Cursor is omitted: its app is the ancestor of both a human terminal and the agent, +// so the process name cannot tell them apart; the env markers above handle Cursor. var agentProcessNames = map[string]string{ // AI agents. "claude": "claude-code", - "cursor": "cursor", "copilot": "github-copilot", "codex": "codex", "gemini": "gemini", @@ -86,18 +110,9 @@ func detectAgentWithEnv( // Tier 2: Env allow-list. for _, entry := range agentEnvTable { - raw := strings.TrimSpace(getEnv(entry.envVar)) - if raw == "" { - continue + if entry.matches(getEnv) { + return entry.agentName } - - if entry.requiredPrefix != "" { - if !strings.HasPrefix(strings.ToLower(raw), strings.ToLower(entry.requiredPrefix)) { - continue - } - } - - return entry.agentName } // Tier 2b: Wildcard sweep for unknown future agents. Catches the shared @@ -110,9 +125,8 @@ func detectAgentWithEnv( } upperKey := strings.ToUpper(key) - // Unlisted CURSOR_* infra vars share generic agent suffixes; skip them here - // so they don't false-positive as unknown-agent. Named CURSOR_* entries are - // matched in Tier 2 above. + // Skip CURSOR_* vars: they're set for every Cursor terminal, human included, so + // they must not trip the sweep. The Cursor agent is matched in Tier 2 above. if strings.HasPrefix(upperKey, "CURSOR_") { continue } diff --git a/internal/cli/agent_detection_test.go b/internal/cli/agent_detection_test.go index e82eaff24..4d4c3ef69 100644 --- a/internal/cli/agent_detection_test.go +++ b/internal/cli/agent_detection_test.go @@ -98,56 +98,58 @@ func TestDetectAgent_ClaudeCode_AIAgent(t *testing.T) { assert.Equal(t, "claude-code", agent) } -func TestDetectAgent_Cursor(t *testing.T) { +// TestDetectAgent_CursorAgent covers the signals that mean the Cursor agent is driving: +// the in-IDE agent and the headless cursor-agent CLI. +func TestDetectAgent_CursorAgent(t *testing.T) { for _, tc := range []struct { - envVar string - value string + name string + env map[string]string }{ - {"CURSOR_AGENT", "1"}, - {"CURSOR_TRACE_ID", "abc123"}, - {"CURSOR_CONVERSATION_ID", "04bb112f-88b6-47ce-b23c-2fb28b9b98e3"}, + {name: "CURSOR_AGENT", env: map[string]string{"CURSOR_AGENT": "1"}}, + {name: "ExtensionHostRoleAgentExec", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"}}, } { - t.Run(tc.envVar, func(t *testing.T) { + t.Run(tc.name, func(t *testing.T) { agent := detectAgentFull(func(k string) string { - if k == tc.envVar { - return tc.value - } - return "" + return tc.env[k] }, noProc, false) assert.Equal(t, "cursor", agent) }) } } -func TestDetectAgent_CursorTraceIDBeatsWildcard(t *testing.T) { - agent := detectAgentWithEnv(func(k string) string { - if k == "CURSOR_TRACE_ID" { - return "abc123" - } - return "" - }, func() []string { - return []string{"CURSOR_TRACE_ID=abc123"} - }, dummyPPID, noProcInfo, false) - assert.Equal(t, "cursor", agent) -} - -func TestDetectAgent_CursorConversationIDBeatsWildcard(t *testing.T) { - agent := detectAgentWithEnv(func(k string) string { - if k == "CURSOR_CONVERSATION_ID" { - return "04bb112f-88b6-47ce-b23c-2fb28b9b98e3" - } - return "" - }, func() []string { - return []string{"CURSOR_CONVERSATION_ID=04bb112f-88b6-47ce-b23c-2fb28b9b98e3"} - }, dummyPPID, noProcInfo, false) - assert.Equal(t, "cursor", agent) -} - -func TestDetectAgent_UnlistedCursorInfraIgnoredByWildcard(t *testing.T) { - agent := detectAgentWithEnv(noEnv, func() []string { - return []string{"CURSOR_SANDBOX=seatbelt"} - }, dummyPPID, noProcInfo, false) - assert.Equal(t, "unknown", agent) +// TestDetectAgent_CursorHumanNotAgent guards the fix: a human in Cursor's terminal has +// CURSOR_* vars but no agent marker, so detection must not force agent mode. +func TestDetectAgent_CursorHumanNotAgent(t *testing.T) { + for _, tc := range []struct { + name string + env map[string]string + }{ + {name: "TraceIDAlone", env: map[string]string{"CURSOR_TRACE_ID": "abc123"}}, + {name: "ConversationIDAlone", env: map[string]string{"CURSOR_CONVERSATION_ID": "04bb112f-88b6-47ce-b23c-2fb28b9b98e3"}}, + {name: "SandboxAlone", env: map[string]string{"CURSOR_SANDBOX": "seatbelt"}}, + {name: "ExtensionHostRoleNotAgentExec", env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "workspace"}}, + // We no longer key on PAGER: even the exact value am-i-vibing keys on must not + // force a human in Cursor's terminal into agent mode. + {name: "TraceIDWithCursorPager", env: map[string]string{ + "CURSOR_TRACE_ID": "abc123", + "PAGER": "head -n 10000 | cat", + }}, + } { + t.Run(tc.name, func(t *testing.T) { + environ := make([]string, 0, len(tc.env)) + for k, v := range tc.env { + environ = append(environ, k+"="+v) + } + getEnv := func(k string) string { return tc.env[k] } + + assert.Equal(t, "unknown", + detectAgentWithEnv(getEnv, func() []string { return environ }, dummyPPID, noProcInfo, false), + "non-interactive human in Cursor must not be detected as an agent") + assert.Equal(t, "human", + detectAgentWithEnv(getEnv, func() []string { return environ }, dummyPPID, noProcInfo, true), + "interactive human in Cursor must resolve to human") + }) + } } func TestDetectAgent_Codex_ThreadID(t *testing.T) { @@ -218,14 +220,17 @@ func TestDetectAgent_ProcessWalk_MultiLevel(t *testing.T) { assert.Equal(t, "claude-code", agent) } -func TestDetectAgent_ProcessWalk_Cursor(t *testing.T) { - agent := detectAgentWithEnv(noEnv, noEnviron, dummyPPID, procInfoName(func(pid int) string { +// TestDetectAgent_ProcessWalk_CursorNotMatched pins that a "cursor" parent process is no +// longer treated as an agent, since it can't distinguish a human terminal from the agent. +func TestDetectAgent_ProcessWalk_CursorNotMatched(t *testing.T) { + procName := func(pid int) string { if pid == 9999 { return "cursor" } return "" - }), false) - assert.Equal(t, "cursor", agent) + } + assert.Equal(t, "unknown", detectAgentFull(noEnv, procName, false)) + assert.Equal(t, "human", detectAgentFull(noEnv, procName, true)) } func TestDetectAgent_ProcessWalk_GitHubCopilot(t *testing.T) {