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
80 changes: 47 additions & 33 deletions internal/cli/agent_detection.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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
}
Expand Down
93 changes: 49 additions & 44 deletions internal/cli/agent_detection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading