From da281ceb99b43866ff2793f78819925199421208 Mon Sep 17 00:00:00 2001 From: ctufts Date: Fri, 17 Jul 2026 16:50:32 -0400 Subject: [PATCH 1/5] Append agent/ to User-Agent when driven by an AI coding agent Detects the calling AI coding agent from a hardcoded env-var allowlist and appends " agent/" to the tilesets Session User-Agent when one is found. No server dependency: the header is already logged by CloudFront, and the agent's environment is directly visible to this CLI's subprocess. --- mapbox_tilesets/agent_detect.py | 68 +++++++++++++++++++++++++++ mapbox_tilesets/utils.py | 8 +++- tests/test_agent_detect.py | 81 +++++++++++++++++++++++++++++++++ tests/test_utils.py | 12 ++++- 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 mapbox_tilesets/agent_detect.py create mode 100644 tests/test_agent_detect.py diff --git a/mapbox_tilesets/agent_detect.py b/mapbox_tilesets/agent_detect.py new file mode 100644 index 0000000..1a8c432 --- /dev/null +++ b/mapbox_tilesets/agent_detect.py @@ -0,0 +1,68 @@ +"""Detect the AI coding agent (if any) driving this CLI invocation.""" + +import os + +# (agent_id, [(env_var, expected_value_or_None), ...]) — table order is precedence order; +# the first entry with any matching condition wins. expected_value None => presence check +# (key exists in os.environ, value not inspected); otherwise an exact-equality check. +_ALLOWLIST = [ + ("antigravity", [("ANTIGRAVITY_AGENT", None)]), + ("augment-cli", [("AUGMENT_AGENT", None)]), + ("cline", [("CLINE_ACTIVE", None)]), + ("cowork", [("CLAUDE_CODE_IS_COWORK", None)]), + ("claude-code", [("CLAUDECODE", None), ("CLAUDE_CODE", None)]), + ("codex", [("CODEX_SANDBOX", None), ("CODEX_CI", None), ("CODEX_THREAD_ID", None)]), + ("crush", [("CRUSH", None)]), + ("gemini-cli", [("GEMINI_CLI", None)]), + ( + "github-copilot", + [ + ("COPILOT_MODEL", None), + ("COPILOT_ALLOW_ALL", None), + ("COPILOT_GITHUB_TOKEN", None), + ], + ), + ("goose", [("GOOSE_TERMINAL", None)]), + ("hermes-agent", [("HERMES_SESSION_ID", None)]), + ("kilo-code", [("KILOCODE_FEATURE", None)]), + ("kiro", [("AGENT_CONTEXT_OUT", None)]), + ("openclaw", [("OPENCLAW_SHELL", None)]), + ("opencode", [("OPENCODE_CLIENT", None)]), + ("pi", [("PI_CODING_AGENT", None)]), + ("replit", [("REPL_ID", None)]), + ("trae", [("TRAE_AI_SHELL_ID", None)]), + ("vtcode", [("VTCODE", "1")]), + ("warp", [("TERM_PROGRAM", "WarpTerminal")]), + ("zed", [("ZED_TERM", None)]), + ("cursor-cli", [("CURSOR_AGENT", None)]), + ("cursor", [("CURSOR_TRACE_ID", None)]), +] + +# Checked only if nothing in _ALLOWLIST matched. First one with a non-empty value wins. +_FALLBACK_VARS = ("AI_AGENT", "AGENT") + + +def detect_agent(): + """Detect the AI coding agent driving this CLI from environment variables. + + Never reads or logs the full environment - only the matched id is used. + + Returns + ------- + str or None + The detected agent id, or None when no agent indicator is present. + """ + for agent_id, conditions in _ALLOWLIST: + for var, expected in conditions: + if expected is None: + if var in os.environ: + return agent_id + elif os.environ.get(var) == expected: + return agent_id + + for var in _FALLBACK_VARS: + value = os.environ.get(var, "").strip() + if value: + return value + + return None diff --git a/mapbox_tilesets/utils.py b/mapbox_tilesets/utils.py index 8f8e2fa..9da8a93 100644 --- a/mapbox_tilesets/utils.py +++ b/mapbox_tilesets/utils.py @@ -12,6 +12,8 @@ import geojson import json +from mapbox_tilesets.agent_detect import detect_agent + def load_module(modulename): """Dynamically imports a module and throws a readable exception if not found""" @@ -51,7 +53,11 @@ def _get_session( ): """Get a configured session""" s = Session() - s.headers.update({"user-agent": "{}/{}".format(application, version)}) + user_agent = "{}/{}".format(application, version) + agent = detect_agent() + if agent: + user_agent = "{} agent/{}".format(user_agent, agent) + s.headers.update({"user-agent": user_agent}) return s diff --git a/tests/test_agent_detect.py b/tests/test_agent_detect.py new file mode 100644 index 0000000..a527b51 --- /dev/null +++ b/tests/test_agent_detect.py @@ -0,0 +1,81 @@ +import os +from unittest import mock + +from mapbox_tilesets.agent_detect import detect_agent + + +def _detect_with_env(env): + # clear=True: these tests run inside various AI-agent shells (e.g. Claude + # Code sets CLAUDECODE), so the ambient environment must not leak in. + with mock.patch.dict(os.environ, env, clear=True): + return detect_agent() + + +def test_no_indicators_returns_none(): + assert _detect_with_env({}) is None + + +def test_harness_var_wins_over_ai_agent_fallback(): + # Harness-specific vars take precedence over AI_AGENT/AGENT even when both + # are present at once. + assert ( + _detect_with_env({"CLAUDECODE": "1", "AI_AGENT": "something-else"}) + == "claude-code" + ) + + +def test_codex_and_claude_code_are_distinct(): + assert _detect_with_env({"CODEX_THREAD_ID": "abc"}) == "codex" + assert _detect_with_env({"CLAUDECODE": "1"}) == "claude-code" + assert _detect_with_env({"CLAUDE_CODE": "1"}) == "claude-code" + + +def test_codex_matches_on_any_of_its_vars(): + assert _detect_with_env({"CODEX_SANDBOX": "1"}) == "codex" + assert _detect_with_env({"CODEX_CI": "1"}) == "codex" + + +def test_warp_requires_exact_value_match(): + assert _detect_with_env({"TERM_PROGRAM": "WarpTerminal"}) == "warp" + assert _detect_with_env({"TERM_PROGRAM": "iTerm.app"}) is None + + +def test_vtcode_requires_exact_value_match(): + assert _detect_with_env({"VTCODE": "1"}) == "vtcode" + assert _detect_with_env({"VTCODE": "0"}) is None + assert _detect_with_env({"VTCODE": "true"}) is None + + +def test_table_order_precedence_among_harness_vars(): + # antigravity is earlier in the table than cursor - it should win when + # both indicators are present. + assert ( + _detect_with_env({"CURSOR_AGENT": "1", "ANTIGRAVITY_AGENT": "1"}) + == "antigravity" + ) + + +def test_fallback_ai_agent_used_when_no_harness_match(): + assert _detect_with_env({"AI_AGENT": "custom-agent"}) == "custom-agent" + + +def test_fallback_agent_used_when_no_harness_or_ai_agent_match(): + assert _detect_with_env({"AGENT": "custom-agent"}) == "custom-agent" + + +def test_fallback_ai_agent_takes_precedence_over_agent(): + assert _detect_with_env({"AI_AGENT": "first", "AGENT": "second"}) == "first" + + +def test_fallback_empty_or_whitespace_value_returns_none(): + assert _detect_with_env({"AI_AGENT": ""}) is None + assert _detect_with_env({"AI_AGENT": " "}) is None + assert _detect_with_env({"AI_AGENT": "", "AGENT": "still-empty-check"}) == ( + "still-empty-check" + ) + + +def test_github_copilot_matches_on_any_of_its_vars(): + assert _detect_with_env({"COPILOT_MODEL": "gpt"}) == "github-copilot" + assert _detect_with_env({"COPILOT_ALLOW_ALL": "1"}) == "github-copilot" + assert _detect_with_env({"COPILOT_GITHUB_TOKEN": "abc"}) == "github-copilot" diff --git a/tests/test_utils.py b/tests/test_utils.py index f3d79c8..05a5f04 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,7 @@ import os import pytest import json +from unittest import mock from mapbox_tilesets.utils import ( _get_api, _get_session, @@ -22,11 +23,20 @@ def test_get_api(): def test_get_session(): - s = _get_session("my_application", "1.0.0") + # Cleared so an ambient agent indicator (e.g. this test running inside + # Claude Code, where CLAUDECODE is set) can't leak into the assertion. + with mock.patch.dict(os.environ, {}, clear=True): + s = _get_session("my_application", "1.0.0") assert "user-agent" in s.headers assert s.headers["user-agent"] == "my_application/1.0.0" +def test_get_session_appends_detected_agent(): + with mock.patch.dict(os.environ, {"CLAUDECODE": "1"}, clear=True): + s = _get_session("my_application", "1.0.0") + assert s.headers["user-agent"] == "my_application/1.0.0 agent/claude-code" + + def test_get_token_parameter(): token = "token-parameter" assert token == _get_token(token) From 5af02d60874bf0f8708a61b87d9e3034ea90e7c2 Mon Sep 17 00:00:00 2001 From: ctufts Date: Fri, 17 Jul 2026 17:05:25 -0400 Subject: [PATCH 2/5] Sanitize agent env-var values before self-review findings ship - Reject fallback (AI_AGENT/AGENT) values outside a safe header charset so a stray newline/colon can no longer crash every CLI command with requests.exceptions.InvalidHeader. - Treat empty/whitespace-only harness env vars as unset, matching the fallback path's existing empty-value handling. --- mapbox_tilesets/agent_detect.py | 13 ++++++++++--- tests/test_agent_detect.py | 23 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/mapbox_tilesets/agent_detect.py b/mapbox_tilesets/agent_detect.py index 1a8c432..ed98155 100644 --- a/mapbox_tilesets/agent_detect.py +++ b/mapbox_tilesets/agent_detect.py @@ -1,10 +1,17 @@ """Detect the AI coding agent (if any) driving this CLI invocation.""" import os +import re + +# A safe charset for an agent id placed into an HTTP header: env vars are not +# validated by whoever sets them, so a value like "foo\nbar: injected" must be +# rejected here rather than surfacing as an unhandled requests.InvalidHeader. +_SAFE_FALLBACK_ID = re.compile(r"^[\w.\-]{1,64}$") # (agent_id, [(env_var, expected_value_or_None), ...]) — table order is precedence order; # the first entry with any matching condition wins. expected_value None => presence check -# (key exists in os.environ, value not inspected); otherwise an exact-equality check. +# (key exists in os.environ with a non-empty, non-whitespace value); otherwise an +# exact-equality check. _ALLOWLIST = [ ("antigravity", [("ANTIGRAVITY_AGENT", None)]), ("augment-cli", [("AUGMENT_AGENT", None)]), @@ -55,14 +62,14 @@ def detect_agent(): for agent_id, conditions in _ALLOWLIST: for var, expected in conditions: if expected is None: - if var in os.environ: + if os.environ.get(var, "").strip(): return agent_id elif os.environ.get(var) == expected: return agent_id for var in _FALLBACK_VARS: value = os.environ.get(var, "").strip() - if value: + if value and _SAFE_FALLBACK_ID.match(value): return value return None diff --git a/tests/test_agent_detect.py b/tests/test_agent_detect.py index a527b51..a37d268 100644 --- a/tests/test_agent_detect.py +++ b/tests/test_agent_detect.py @@ -79,3 +79,26 @@ def test_github_copilot_matches_on_any_of_its_vars(): assert _detect_with_env({"COPILOT_MODEL": "gpt"}) == "github-copilot" assert _detect_with_env({"COPILOT_ALLOW_ALL": "1"}) == "github-copilot" assert _detect_with_env({"COPILOT_GITHUB_TOKEN": "abc"}) == "github-copilot" + + +def test_presence_check_requires_non_empty_value(): + # A harness var set to "" or whitespace is treated the same as unset, + # matching the fallback's empty-value handling. + assert _detect_with_env({"CLAUDECODE": ""}) is None + assert _detect_with_env({"CLAUDECODE": " "}) is None + + +def test_fallback_rejects_header_unsafe_characters(): + # A fallback value must never reach the User-Agent header unsanitized - + # a newline would otherwise crash every request with InvalidHeader. + assert _detect_with_env({"AI_AGENT": "foo\nbar: injected"}) is None + assert _detect_with_env({"AI_AGENT": "has spaces"}) is None + + +def test_fallback_rejects_unsafe_value_but_falls_through_to_next_var(): + assert _detect_with_env({"AI_AGENT": "foo\nbar", "AGENT": "safe-id"}) == "safe-id" + + +def test_fallback_rejects_overlong_value(): + assert _detect_with_env({"AI_AGENT": "a" * 65}) is None + assert _detect_with_env({"AI_AGENT": "a" * 64}) == "a" * 64 From 1ad06efd03a01020148d52dab0fea513f807dee7 Mon Sep 17 00:00:00 2001 From: ctufts Date: Thu, 10 Sep 2026 14:37:45 -0400 Subject: [PATCH 3/5] Stop forwarding AI_AGENT/AGENT env var values into telemetry Per review feedback (mapbox/tilesets-cli#219, line 70), the fallback detector should only check whether AI_AGENT/AGENT is present, never read and forward its value - an arbitrary, unvalidated string should not become an "agent id" in production telemetry. Fold the fallback into the allowlist itself as its lowest-precedence entry, returning a fixed `custom-agent` id on presence instead of the variable's value. This also removes the charset/length validation that existed only to sanitize that value, since every returned id is now a fixed literal. Co-Authored-By: Claude Sonnet 5 --- mapbox_tilesets/agent_detect.py | 20 ++++++-------------- tests/test_agent_detect.py | 28 ++++++++++------------------ 2 files changed, 16 insertions(+), 32 deletions(-) diff --git a/mapbox_tilesets/agent_detect.py b/mapbox_tilesets/agent_detect.py index ed98155..c490c2e 100644 --- a/mapbox_tilesets/agent_detect.py +++ b/mapbox_tilesets/agent_detect.py @@ -1,17 +1,16 @@ """Detect the AI coding agent (if any) driving this CLI invocation.""" import os -import re - -# A safe charset for an agent id placed into an HTTP header: env vars are not -# validated by whoever sets them, so a value like "foo\nbar: injected" must be -# rejected here rather than surfacing as an unhandled requests.InvalidHeader. -_SAFE_FALLBACK_ID = re.compile(r"^[\w.\-]{1,64}$") # (agent_id, [(env_var, expected_value_or_None), ...]) — table order is precedence order; # the first entry with any matching condition wins. expected_value None => presence check # (key exists in os.environ with a non-empty, non-whitespace value); otherwise an # exact-equality check. +# +# The final entry, "custom-agent", is a catch-all for AI_AGENT/AGENT: these exist so an +# agent not on this list can still flag its presence, but we only ever check for them, +# never read their value - an arbitrary, unvalidated string must never be forwarded into +# telemetry as an "agent id". _ALLOWLIST = [ ("antigravity", [("ANTIGRAVITY_AGENT", None)]), ("augment-cli", [("AUGMENT_AGENT", None)]), @@ -43,11 +42,9 @@ ("zed", [("ZED_TERM", None)]), ("cursor-cli", [("CURSOR_AGENT", None)]), ("cursor", [("CURSOR_TRACE_ID", None)]), + ("custom-agent", [("AI_AGENT", None), ("AGENT", None)]), ] -# Checked only if nothing in _ALLOWLIST matched. First one with a non-empty value wins. -_FALLBACK_VARS = ("AI_AGENT", "AGENT") - def detect_agent(): """Detect the AI coding agent driving this CLI from environment variables. @@ -67,9 +64,4 @@ def detect_agent(): elif os.environ.get(var) == expected: return agent_id - for var in _FALLBACK_VARS: - value = os.environ.get(var, "").strip() - if value and _SAFE_FALLBACK_ID.match(value): - return value - return None diff --git a/tests/test_agent_detect.py b/tests/test_agent_detect.py index a37d268..ed89105 100644 --- a/tests/test_agent_detect.py +++ b/tests/test_agent_detect.py @@ -56,22 +56,24 @@ def test_table_order_precedence_among_harness_vars(): def test_fallback_ai_agent_used_when_no_harness_match(): - assert _detect_with_env({"AI_AGENT": "custom-agent"}) == "custom-agent" + assert _detect_with_env({"AI_AGENT": "my-cool-tool"}) == "custom-agent" def test_fallback_agent_used_when_no_harness_or_ai_agent_match(): - assert _detect_with_env({"AGENT": "custom-agent"}) == "custom-agent" + assert _detect_with_env({"AGENT": "my-cool-tool"}) == "custom-agent" def test_fallback_ai_agent_takes_precedence_over_agent(): - assert _detect_with_env({"AI_AGENT": "first", "AGENT": "second"}) == "first" + # Same result either way - table order still decides precedence, but the + # value of AI_AGENT/AGENT is never read, only their presence. + assert _detect_with_env({"AI_AGENT": "first", "AGENT": "second"}) == "custom-agent" def test_fallback_empty_or_whitespace_value_returns_none(): assert _detect_with_env({"AI_AGENT": ""}) is None assert _detect_with_env({"AI_AGENT": " "}) is None assert _detect_with_env({"AI_AGENT": "", "AGENT": "still-empty-check"}) == ( - "still-empty-check" + "custom-agent" ) @@ -88,17 +90,7 @@ def test_presence_check_requires_non_empty_value(): assert _detect_with_env({"CLAUDECODE": " "}) is None -def test_fallback_rejects_header_unsafe_characters(): - # A fallback value must never reach the User-Agent header unsanitized - - # a newline would otherwise crash every request with InvalidHeader. - assert _detect_with_env({"AI_AGENT": "foo\nbar: injected"}) is None - assert _detect_with_env({"AI_AGENT": "has spaces"}) is None - - -def test_fallback_rejects_unsafe_value_but_falls_through_to_next_var(): - assert _detect_with_env({"AI_AGENT": "foo\nbar", "AGENT": "safe-id"}) == "safe-id" - - -def test_fallback_rejects_overlong_value(): - assert _detect_with_env({"AI_AGENT": "a" * 65}) is None - assert _detect_with_env({"AI_AGENT": "a" * 64}) == "a" * 64 +def test_fallback_value_is_never_forwarded_even_if_header_unsafe(): + # The fallback value must never reach the User-Agent header at all - it is + # never read, only checked for presence. + assert _detect_with_env({"AI_AGENT": "foo\nbar: injected"}) == "custom-agent" From b30ea6c1d21ce08e74c195ae0f0d3f0e94da7ec6 Mon Sep 17 00:00:00 2001 From: ctufts Date: Thu, 10 Sep 2026 14:48:11 -0400 Subject: [PATCH 4/5] Treat presence check as existence-only, ignoring blank values The allowlist's presence-check branch (expected is None) required a non-empty, non-whitespace value, so an env var explicitly set to "" or whitespace was treated as unset. Per feedback, existence is all that should matter - whether the key is present at all, not what it's set to. Check membership directly instead of stripping and testing truthiness. Co-Authored-By: Claude Sonnet 5 --- mapbox_tilesets/agent_detect.py | 6 +++--- tests/test_agent_detect.py | 21 ++++++++------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/mapbox_tilesets/agent_detect.py b/mapbox_tilesets/agent_detect.py index c490c2e..c9b9c4a 100644 --- a/mapbox_tilesets/agent_detect.py +++ b/mapbox_tilesets/agent_detect.py @@ -4,8 +4,8 @@ # (agent_id, [(env_var, expected_value_or_None), ...]) — table order is precedence order; # the first entry with any matching condition wins. expected_value None => presence check -# (key exists in os.environ with a non-empty, non-whitespace value); otherwise an -# exact-equality check. +# (key exists in os.environ at all - even set to "" or whitespace still counts, we only +# care whether it exists, not what it's set to); otherwise an exact-equality check. # # The final entry, "custom-agent", is a catch-all for AI_AGENT/AGENT: these exist so an # agent not on this list can still flag its presence, but we only ever check for them, @@ -59,7 +59,7 @@ def detect_agent(): for agent_id, conditions in _ALLOWLIST: for var, expected in conditions: if expected is None: - if os.environ.get(var, "").strip(): + if var in os.environ: return agent_id elif os.environ.get(var) == expected: return agent_id diff --git a/tests/test_agent_detect.py b/tests/test_agent_detect.py index ed89105..b686f51 100644 --- a/tests/test_agent_detect.py +++ b/tests/test_agent_detect.py @@ -69,25 +69,20 @@ def test_fallback_ai_agent_takes_precedence_over_agent(): assert _detect_with_env({"AI_AGENT": "first", "AGENT": "second"}) == "custom-agent" -def test_fallback_empty_or_whitespace_value_returns_none(): - assert _detect_with_env({"AI_AGENT": ""}) is None - assert _detect_with_env({"AI_AGENT": " "}) is None - assert _detect_with_env({"AI_AGENT": "", "AGENT": "still-empty-check"}) == ( - "custom-agent" - ) - - def test_github_copilot_matches_on_any_of_its_vars(): assert _detect_with_env({"COPILOT_MODEL": "gpt"}) == "github-copilot" assert _detect_with_env({"COPILOT_ALLOW_ALL": "1"}) == "github-copilot" assert _detect_with_env({"COPILOT_GITHUB_TOKEN": "abc"}) == "github-copilot" -def test_presence_check_requires_non_empty_value(): - # A harness var set to "" or whitespace is treated the same as unset, - # matching the fallback's empty-value handling. - assert _detect_with_env({"CLAUDECODE": ""}) is None - assert _detect_with_env({"CLAUDECODE": " "}) is None +def test_presence_check_only_requires_existence_blank_value_still_counts(): + # Existence is all that matters - a var set to "" or whitespace still + # counts as present, for both a harness var and the AI_AGENT/AGENT + # catch-all. + assert _detect_with_env({"CLAUDECODE": ""}) == "claude-code" + assert _detect_with_env({"CLAUDECODE": " "}) == "claude-code" + assert _detect_with_env({"AI_AGENT": ""}) == "custom-agent" + assert _detect_with_env({"AI_AGENT": " "}) == "custom-agent" def test_fallback_value_is_never_forwarded_even_if_header_unsafe(): From 635dae7e154c4ccd3b160535630822f2bcacdb1f Mon Sep 17 00:00:00 2001 From: ctufts Date: Thu, 10 Sep 2026 16:12:01 -0400 Subject: [PATCH 5/5] Remove all value comparisons from agent detection - existence only Every allowlist entry now tests presence only, never a value: collapse the table from (agent_id, [(env_var, expected_value_or_None), ...]) to a flat (agent_id, [env_var, ...]), and drop the equality-check branch in detect_agent entirely. The warp entry compared TERM_PROGRAM against "WarpTerminal" - dropped outright, since TERM_PROGRAM is set by most terminal emulators (iTerm2, Apple Terminal, VS Code, Hyper, ...), not just Warp, and an existence-only check on it would misidentify most terminal sessions. vtcode's VTCODE has no such collision risk and stays as a plain presence check. Co-Authored-By: Claude Sonnet 5 --- mapbox_tilesets/agent_detect.py | 82 ++++++++++++++++----------------- tests/test_agent_detect.py | 12 +++-- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/mapbox_tilesets/agent_detect.py b/mapbox_tilesets/agent_detect.py index c9b9c4a..7f1b634 100644 --- a/mapbox_tilesets/agent_detect.py +++ b/mapbox_tilesets/agent_detect.py @@ -2,47 +2,50 @@ import os -# (agent_id, [(env_var, expected_value_or_None), ...]) — table order is precedence order; -# the first entry with any matching condition wins. expected_value None => presence check -# (key exists in os.environ at all - even set to "" or whitespace still counts, we only -# care whether it exists, not what it's set to); otherwise an exact-equality check. +# (agent_id, [env_var, ...]) — table order is precedence order; the first entry with any +# of its env vars present wins. Presence is the only thing ever tested - a var's value is +# never read or compared against anything, for any entry. Even a var explicitly set to "" +# or whitespace counts as present. +# +# Ported from mapbox-sdk-js's `lib/helpers/agent-detect.js` (this repo's sibling +# implementation of the same allowlist - keep the two in sync). Canonical origin: +# HuggingFace's public `agent-harnesses.ts` registry. +# +# "vtcode" and "warp" used to require a specific value (VTCODE == "1", TERM_PROGRAM == +# "WarpTerminal") rather than mere presence. Since values are never checked, "warp" was +# dropped entirely: TERM_PROGRAM is set by most terminal emulators (iTerm2, Apple +# Terminal, VS Code, Hyper, ...), not just Warp, so an existence check on it would +# misidentify most terminal sessions as "warp". VTCODE has no such collision risk and +# stays as a plain presence check. # # The final entry, "custom-agent", is a catch-all for AI_AGENT/AGENT: these exist so an # agent not on this list can still flag its presence, but we only ever check for them, # never read their value - an arbitrary, unvalidated string must never be forwarded into # telemetry as an "agent id". _ALLOWLIST = [ - ("antigravity", [("ANTIGRAVITY_AGENT", None)]), - ("augment-cli", [("AUGMENT_AGENT", None)]), - ("cline", [("CLINE_ACTIVE", None)]), - ("cowork", [("CLAUDE_CODE_IS_COWORK", None)]), - ("claude-code", [("CLAUDECODE", None), ("CLAUDE_CODE", None)]), - ("codex", [("CODEX_SANDBOX", None), ("CODEX_CI", None), ("CODEX_THREAD_ID", None)]), - ("crush", [("CRUSH", None)]), - ("gemini-cli", [("GEMINI_CLI", None)]), - ( - "github-copilot", - [ - ("COPILOT_MODEL", None), - ("COPILOT_ALLOW_ALL", None), - ("COPILOT_GITHUB_TOKEN", None), - ], - ), - ("goose", [("GOOSE_TERMINAL", None)]), - ("hermes-agent", [("HERMES_SESSION_ID", None)]), - ("kilo-code", [("KILOCODE_FEATURE", None)]), - ("kiro", [("AGENT_CONTEXT_OUT", None)]), - ("openclaw", [("OPENCLAW_SHELL", None)]), - ("opencode", [("OPENCODE_CLIENT", None)]), - ("pi", [("PI_CODING_AGENT", None)]), - ("replit", [("REPL_ID", None)]), - ("trae", [("TRAE_AI_SHELL_ID", None)]), - ("vtcode", [("VTCODE", "1")]), - ("warp", [("TERM_PROGRAM", "WarpTerminal")]), - ("zed", [("ZED_TERM", None)]), - ("cursor-cli", [("CURSOR_AGENT", None)]), - ("cursor", [("CURSOR_TRACE_ID", None)]), - ("custom-agent", [("AI_AGENT", None), ("AGENT", None)]), + ("antigravity", ["ANTIGRAVITY_AGENT"]), + ("augment-cli", ["AUGMENT_AGENT"]), + ("cline", ["CLINE_ACTIVE"]), + ("cowork", ["CLAUDE_CODE_IS_COWORK"]), + ("claude-code", ["CLAUDECODE", "CLAUDE_CODE"]), + ("codex", ["CODEX_SANDBOX", "CODEX_CI", "CODEX_THREAD_ID"]), + ("crush", ["CRUSH"]), + ("gemini-cli", ["GEMINI_CLI"]), + ("github-copilot", ["COPILOT_MODEL", "COPILOT_ALLOW_ALL", "COPILOT_GITHUB_TOKEN"]), + ("goose", ["GOOSE_TERMINAL"]), + ("hermes-agent", ["HERMES_SESSION_ID"]), + ("kilo-code", ["KILOCODE_FEATURE"]), + ("kiro", ["AGENT_CONTEXT_OUT"]), + ("openclaw", ["OPENCLAW_SHELL"]), + ("opencode", ["OPENCODE_CLIENT"]), + ("pi", ["PI_CODING_AGENT"]), + ("replit", ["REPL_ID"]), + ("trae", ["TRAE_AI_SHELL_ID"]), + ("vtcode", ["VTCODE"]), + ("zed", ["ZED_TERM"]), + ("cursor-cli", ["CURSOR_AGENT"]), + ("cursor", ["CURSOR_TRACE_ID"]), + ("custom-agent", ["AI_AGENT", "AGENT"]), ] @@ -56,12 +59,9 @@ def detect_agent(): str or None The detected agent id, or None when no agent indicator is present. """ - for agent_id, conditions in _ALLOWLIST: - for var, expected in conditions: - if expected is None: - if var in os.environ: - return agent_id - elif os.environ.get(var) == expected: + for agent_id, env_vars in _ALLOWLIST: + for var in env_vars: + if var in os.environ: return agent_id return None diff --git a/tests/test_agent_detect.py b/tests/test_agent_detect.py index b686f51..d2c628d 100644 --- a/tests/test_agent_detect.py +++ b/tests/test_agent_detect.py @@ -35,15 +35,17 @@ def test_codex_matches_on_any_of_its_vars(): assert _detect_with_env({"CODEX_CI": "1"}) == "codex" -def test_warp_requires_exact_value_match(): - assert _detect_with_env({"TERM_PROGRAM": "WarpTerminal"}) == "warp" +def test_warp_was_dropped_term_program_is_not_a_safe_existence_only_signal(): + # TERM_PROGRAM is set by most terminal emulators, not just Warp, so it's + # not on the allowlist at all now that presence is the only check. + assert _detect_with_env({"TERM_PROGRAM": "WarpTerminal"}) is None assert _detect_with_env({"TERM_PROGRAM": "iTerm.app"}) is None -def test_vtcode_requires_exact_value_match(): +def test_vtcode_matches_on_presence_alone_regardless_of_value(): assert _detect_with_env({"VTCODE": "1"}) == "vtcode" - assert _detect_with_env({"VTCODE": "0"}) is None - assert _detect_with_env({"VTCODE": "true"}) is None + assert _detect_with_env({"VTCODE": "0"}) == "vtcode" + assert _detect_with_env({"VTCODE": "true"}) == "vtcode" def test_table_order_precedence_among_harness_vars():