Skip to content

Commit 2303620

Browse files
feat: detect AI agent invocations in telemetry
Add an AgentDetector, modeled on the existing CICDDetector, that identifies when SAM CLI is invoked by an AI coding agent by reading environment variables the agent sets, and records it via the existing userAgent telemetry field (emitted as "<agent>/1.0"). This lets us distinguish agent-driven usage from human and CI/CD usage. Detected agents and their signals: - Claude Code CLAUDECODE present - Codex any CODEX_* var present - Cursor CURSOR_AGENT present - Gemini CLI GEMINI_CLI present - Kiro TERM_PROGRAM == kiro, or AWS_EXECUTION_ENV contains amazonq/kiro - OpenCode OPENCODE present - GitHub Copilot COPILOT_AGENT_SESSION_ID present, or GITHUB_COPILOT_CLI_MODE == "true" Shared variables (AWS_EXECUTION_ENV, TERM_PROGRAM) are substring or exact-value matched, never presence-checked, to avoid misattributing Lambda/CloudShell/CodeBuild or generic VS Code environments as agents. GitHub Copilot detection deliberately avoids any GITHUB_* / GITHUB_ACTION key so it never false-matches ordinary GitHub Actions CI, which the existing CICDDetector already handles. An existing AWS_TOOLING_USER_AGENT (AWS toolkit) takes precedence over a detected agent. Amazon Q is intentionally deferred; until it is added after Kiro in resolution order, Amazon Q CLI runs resolve to kiro. Includes unit tests for each agent, the exact-name/too-generic guards (GEMINI_CLI, OPENCODE vs AGENT), the AWS_EXECUTION_ENV and GITHUB_ACTION false-positive guards, and toolkit-takes-precedence behavior.
1 parent aa228f8 commit 2303620

5 files changed

Lines changed: 347 additions & 6 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Module used for detecting whether SAMCLI is executed by an AI agent.
3+
"""
4+
5+
import os
6+
from enum import Enum, auto
7+
from typing import Callable, Dict, Mapping, Optional, Union
8+
9+
10+
class Agent(Enum):
11+
ClaudeCode = auto()
12+
Codex = auto()
13+
Cursor = auto()
14+
GeminiCLI = auto()
15+
Kiro = auto()
16+
OpenCode = auto()
17+
GitHubCopilot = auto()
18+
19+
20+
def _is_codex(environ: Mapping) -> bool:
21+
"""
22+
Whether it is running in Codex (OpenAI's Codex CLI). Codex sets a family of
23+
CODEX_* variables rather than one canonical var (e.g. CODEX_SANDBOX, CODEX_CI,
24+
CODEX_THREAD_ID), so match any name starting with "CODEX_".
25+
"""
26+
return any(key.startswith("CODEX_") for key in environ)
27+
28+
29+
def _is_kiro(environ: Mapping) -> bool:
30+
"""
31+
Whether it is running in Kiro (the Kiro IDE, via TERM_PROGRAM=kiro, or the Kiro
32+
CLI, the renamed Amazon Q Developer CLI, via AWS_EXECUTION_ENV). AWS_EXECUTION_ENV
33+
is shared with AWS Lambda/CloudShell/CodeBuild (e.g. "AWS_Lambda_python3.12"), so
34+
it is substring-matched on "amazonq"/"kiro", not checked for presence, to avoid
35+
misattributing those environments.
36+
37+
NOTE: the "amazonq" substring also matches the genuine Amazon Q Developer CLI.
38+
Amazon Q is deferred for now, so its CLI runs are attributed to kiro until a
39+
dedicated Amazon Q agent is added (which must be ordered AFTER Kiro in the enum).
40+
"""
41+
if environ.get("TERM_PROGRAM", "") == "kiro":
42+
return True
43+
aws_execution_env: str = environ.get("AWS_EXECUTION_ENV", "").lower()
44+
return "amazonq" in aws_execution_env or "kiro" in aws_execution_env
45+
46+
47+
def _is_github_copilot(environ: Mapping) -> bool:
48+
"""
49+
Whether it is running in the standalone GitHub Copilot CLI (not Copilot-in-VS-Code
50+
or the cloud agent), via COPILOT_AGENT_SESSION_ID (a per-session UUID from the
51+
@github/copilot bundle; undocumented, so re-verify per release) or
52+
GITHUB_COPILOT_CLI_MODE=="true". It must NEVER key on a CI variable: Copilot's
53+
cloud agent runs inside GitHub Actions, which cicd.py already detects via
54+
GITHUB_ACTION, so any GITHUB_* rule would false-match ordinary CI.
55+
"""
56+
return "COPILOT_AGENT_SESSION_ID" in environ or environ.get("GITHUB_COPILOT_CLI_MODE", "") == "true"
57+
58+
59+
_ENV_VAR_OR_CALLABLE_BY_AGENT: Dict[Agent, Union[str, Callable[[Mapping], bool]]] = {
60+
# https://docs.anthropic.com/en/docs/claude-code
61+
Agent.ClaudeCode: "CLAUDECODE",
62+
# https://developers.openai.com/codex/cli
63+
Agent.Codex: _is_codex,
64+
# match presence, not value: the value is not guaranteed stable
65+
Agent.Cursor: "CURSOR_AGENT",
66+
# exact name, not a prefix, so GEMINI_CLI_HOME / GEMINI_CLI_NO_RELAUNCH don't match
67+
Agent.GeminiCLI: "GEMINI_CLI",
68+
Agent.Kiro: _is_kiro,
69+
# OpenCode sets OPENCODE=1 in its root CLI middleware
70+
Agent.OpenCode: "OPENCODE",
71+
Agent.GitHubCopilot: _is_github_copilot,
72+
}
73+
74+
75+
def _is_agent(agent: Agent, environ: Mapping) -> bool:
76+
"""
77+
Check whether sam-cli is run by a particular AI agent based on certain environment variables.
78+
79+
Parameters
80+
----------
81+
agent
82+
an enum Agent object indicating which AI agent to check against.
83+
environ
84+
the mapping to look for environment variables, for example, os.environ.
85+
86+
Returns
87+
-------
88+
bool
89+
A boolean indicating whether there are environment variables matching the agent.
90+
"""
91+
env_var_or_callable = _ENV_VAR_OR_CALLABLE_BY_AGENT[agent]
92+
if isinstance(env_var_or_callable, str):
93+
return env_var_or_callable in environ
94+
95+
# it is a callable, use the return value
96+
return env_var_or_callable(environ)
97+
98+
99+
class AgentDetector:
100+
_agent: Optional[Agent]
101+
102+
def __init__(self):
103+
try:
104+
self._agent: Optional[Agent] = next(agent for agent in Agent if _is_agent(agent, os.environ))
105+
except StopIteration:
106+
self._agent = None
107+
108+
def agent(self) -> Optional[Agent]:
109+
"""
110+
Identify which AI agent SAM CLI is running in.
111+
Returns
112+
-------
113+
Agent
114+
an optional Agent enum indicating the AI agent.
115+
"""
116+
return self._agent

samcli/lib/telemetry/user_agent.py

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,33 @@
44

55
import os
66
import re
7-
from typing import Optional
7+
from typing import Dict, Optional
8+
9+
from samcli.lib.telemetry.agent_detector import Agent, AgentDetector
810

911
USER_AGENT_ENV_VAR = "AWS_TOOLING_USER_AGENT"
1012

13+
# Placeholder version emitted for every detected AI agent. Agent CLIs (e.g. Claude
14+
# Code) do not reliably expose their own version through the environment, so we use
15+
# a fixed "1.0" and rely on the agent name for grouping. Bump this only if the
16+
# emitted user-agent format itself needs to change.
17+
AGENT_USER_AGENT_VERSION = "1.0"
18+
19+
# Stable, lowercase-with-dashes names for detected AI agents. These are combined
20+
# with AGENT_USER_AGENT_VERSION to build the user-agent string (e.g.
21+
# "claude-code/1.0") that lands in the queryable "userAgent" telemetry column, so
22+
# they must remain stable across releases to keep downstream queries valid. When
23+
# adding an agent here, also add its detection entry in agent_detector.py.
24+
_USER_AGENT_NAME_BY_AGENT: Dict[Agent, str] = {
25+
Agent.ClaudeCode: "claude-code",
26+
Agent.Codex: "codex",
27+
Agent.Cursor: "cursor",
28+
Agent.GeminiCLI: "gemini-cli",
29+
Agent.Kiro: "kiro",
30+
Agent.OpenCode: "opencode",
31+
Agent.GitHubCopilot: "github-copilot",
32+
}
33+
1134
# Should accept format: ${AGENT_NAME}/${AGENT_VERSION}
1235
# AWS_Toolkit-For-VSCode/1.62.0
1336
# AWS-Toolkit-For-JetBrains/1.60-223
@@ -16,7 +39,42 @@
1639

1740

1841
def get_user_agent_string() -> Optional[str]:
19-
user_agent = os.environ.get(USER_AGENT_ENV_VAR, "").strip()
20-
if user_agent and ACCEPTED_USER_AGENT_FORMAT.match(user_agent):
21-
return user_agent
42+
"""
43+
Return the user-agent string for telemetry, or None if none is available.
44+
45+
Precedence:
46+
1. AWS_TOOLING_USER_AGENT, when set to a value matching
47+
ACCEPTED_USER_AGENT_FORMAT. This is set by AWS toolkits (e.g. the VS Code
48+
and JetBrains toolkits) and always wins when present, so a toolkit
49+
invocation is still attributed to the toolkit even if it happens to run
50+
inside an AI agent.
51+
2. A detected AI agent (e.g. Claude Code), emitted as "<agent-name>/1.0".
52+
This is used only as a fallback when the toolkit user-agent above is not
53+
set to a valid value.
54+
"""
55+
toolkit_user_agent = os.environ.get(USER_AGENT_ENV_VAR, "").strip()
56+
if toolkit_user_agent and ACCEPTED_USER_AGENT_FORMAT.match(toolkit_user_agent):
57+
return toolkit_user_agent
58+
59+
return _get_agent_user_agent_string()
60+
61+
62+
def _get_agent_user_agent_string() -> Optional[str]:
63+
"""
64+
Build a "<agent-name>/1.0" user-agent string for the detected AI agent, or None
65+
when no agent is detected (or the detected agent has no configured name).
66+
"""
67+
agent = AgentDetector().agent()
68+
if agent is None:
69+
return None
70+
71+
agent_name = _USER_AGENT_NAME_BY_AGENT.get(agent)
72+
if not agent_name:
73+
return None
74+
75+
agent_user_agent = f"{agent_name}/{AGENT_USER_AGENT_VERSION}"
76+
# Validate against the same format toolkit strings must satisfy, so a
77+
# misconfigured agent name never emits a malformed user-agent value.
78+
if ACCEPTED_USER_AGENT_FORMAT.match(agent_user_agent):
79+
return agent_user_agent
2280
return None
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
from unittest import TestCase
2+
from unittest.mock import patch
3+
4+
from parameterized import parameterized
5+
6+
from samcli.lib.telemetry.agent_detector import Agent, AgentDetector, _is_agent
7+
8+
9+
class TestAgentDetector(TestCase):
10+
@parameterized.expand(
11+
[
12+
(Agent.ClaudeCode, "CLAUDECODE", "1"),
13+
(Agent.Codex, "CODEX_SANDBOX", "seatbelt"),
14+
(Agent.Cursor, "CURSOR_AGENT", "1"),
15+
(Agent.GeminiCLI, "GEMINI_CLI", "1"),
16+
(Agent.Kiro, "TERM_PROGRAM", "kiro"),
17+
(Agent.Kiro, "AWS_EXECUTION_ENV", "AmazonQ-For-CLI Version/1.13.3"),
18+
(Agent.OpenCode, "OPENCODE", "1"),
19+
(Agent.GitHubCopilot, "COPILOT_AGENT_SESSION_ID", "0b8f6e2c-1a3d-4c9e-8f7a-2b1c0d9e8f7a"),
20+
(Agent.GitHubCopilot, "GITHUB_COPILOT_CLI_MODE", "true"),
21+
]
22+
)
23+
def test_is_agent(self, agent, env_var, env_var_value):
24+
self.assertTrue(_is_agent(agent, {env_var: env_var_value}))
25+
26+
@parameterized.expand(
27+
[
28+
(Agent.ClaudeCode, "NOT_CLAUDECODE", "1"),
29+
(Agent.Codex, "NOT_CODEX_SANDBOX", "seatbelt"),
30+
(Agent.Cursor, "NOT_CURSOR_AGENT", "1"),
31+
# exact-name rule: GEMINI_CLI_HOME must not match Agent.GeminiCLI
32+
(Agent.GeminiCLI, "GEMINI_CLI_HOME", "1"),
33+
# shared-variable rule: a Lambda AWS_EXECUTION_ENV must not match Agent.Kiro
34+
(Agent.Kiro, "AWS_EXECUTION_ENV", "AWS_Lambda_python3.12"),
35+
# too-generic var: AGENT alone (no OPENCODE) must not match Agent.OpenCode
36+
(Agent.OpenCode, "AGENT", "1"),
37+
# CI-collision guard: plain GitHub Actions CI must not match Agent.GitHubCopilot
38+
(Agent.GitHubCopilot, "GITHUB_ACTION", "run1"),
39+
]
40+
)
41+
def test_is_not_agent(self, agent, env_var, env_var_value):
42+
self.assertFalse(_is_agent(agent, {env_var: env_var_value}))
43+
44+
@patch.dict("samcli.lib.telemetry.agent_detector.os.environ", {"CLAUDECODE": "1"}, clear=True)
45+
def test_detector_identifies_claude_code(self):
46+
self.assertEqual(AgentDetector().agent(), Agent.ClaudeCode)
47+
48+
@patch.dict("samcli.lib.telemetry.agent_detector.os.environ", {"CODEX_SANDBOX": "seatbelt"}, clear=True)
49+
def test_detector_identifies_codex(self):
50+
self.assertEqual(AgentDetector().agent(), Agent.Codex)
51+
52+
@patch.dict("samcli.lib.telemetry.agent_detector.os.environ", {"CURSOR_AGENT": "1"}, clear=True)
53+
def test_detector_identifies_cursor(self):
54+
self.assertEqual(AgentDetector().agent(), Agent.Cursor)
55+
56+
@patch.dict("samcli.lib.telemetry.agent_detector.os.environ", {"GEMINI_CLI": "1"}, clear=True)
57+
def test_detector_identifies_gemini_cli(self):
58+
self.assertEqual(AgentDetector().agent(), Agent.GeminiCLI)
59+
60+
@patch.dict("samcli.lib.telemetry.agent_detector.os.environ", {"TERM_PROGRAM": "kiro"}, clear=True)
61+
def test_detector_identifies_kiro_ide(self):
62+
self.assertEqual(AgentDetector().agent(), Agent.Kiro)
63+
64+
@patch.dict(
65+
"samcli.lib.telemetry.agent_detector.os.environ",
66+
{"AWS_EXECUTION_ENV": "AmazonQ-For-CLI Version/1.13.3"},
67+
clear=True,
68+
)
69+
def test_detector_identifies_kiro_cli(self):
70+
self.assertEqual(AgentDetector().agent(), Agent.Kiro)
71+
72+
@patch.dict(
73+
"samcli.lib.telemetry.agent_detector.os.environ",
74+
{"AWS_EXECUTION_ENV": "AWS_Lambda_python3.12"},
75+
clear=True,
76+
)
77+
def test_detector_does_not_identify_lambda_as_kiro(self):
78+
self.assertIsNone(AgentDetector().agent())
79+
80+
@patch.dict("samcli.lib.telemetry.agent_detector.os.environ", {"OPENCODE": "1"}, clear=True)
81+
def test_detector_identifies_opencode(self):
82+
self.assertEqual(AgentDetector().agent(), Agent.OpenCode)
83+
84+
@patch.dict(
85+
"samcli.lib.telemetry.agent_detector.os.environ",
86+
{"COPILOT_AGENT_SESSION_ID": "0b8f6e2c-1a3d-4c9e-8f7a-2b1c0d9e8f7a"},
87+
clear=True,
88+
)
89+
def test_detector_identifies_github_copilot(self):
90+
self.assertEqual(AgentDetector().agent(), Agent.GitHubCopilot)
91+
92+
@patch.dict(
93+
"samcli.lib.telemetry.agent_detector.os.environ",
94+
{"GITHUB_COPILOT_CLI_MODE": "true"},
95+
clear=True,
96+
)
97+
def test_detector_identifies_github_copilot_via_cli_mode(self):
98+
self.assertEqual(AgentDetector().agent(), Agent.GitHubCopilot)
99+
100+
@patch.dict(
101+
"samcli.lib.telemetry.agent_detector.os.environ",
102+
{"GITHUB_ACTION": "run1"},
103+
clear=True,
104+
)
105+
def test_detector_does_not_identify_github_actions_as_copilot(self):
106+
# Plain GitHub Actions CI (no Copilot session var) must never be attributed to
107+
# GitHub Copilot; agent detection returns None so cicd.py can own GITHUB_ACTION.
108+
self.assertIsNone(AgentDetector().agent())
109+
110+
@patch.dict("samcli.lib.telemetry.agent_detector.os.environ", {}, clear=True)
111+
def test_detector_returns_none_when_no_agent(self):
112+
self.assertIsNone(AgentDetector().agent())

tests/unit/lib/telemetry/test_event.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,12 @@ def test_track_event(self, event_mock, lock_mock):
130130
lock_mock.__exit__.assert_called()
131131

132132
@patch("samcli.lib.telemetry.event.Telemetry")
133-
def test_events_get_sent(self, telemetry_mock):
133+
@patch("samcli.lib.telemetry.metric.get_user_agent_string")
134+
def test_events_get_sent(self, get_user_agent_mock, telemetry_mock):
135+
# A detected AI agent adds a "userAgent" field to every metric's common
136+
# attributes; pin it to a known value so the exact-equality assertion below
137+
# verifies the field is emitted rather than breaking on it.
138+
get_user_agent_mock.return_value = "claude-code/1.0"
134139
# Create fake emit to capture tracked events
135140
dummy_telemetry = Mock()
136141
emitted_events = []
@@ -168,6 +173,7 @@ def test_events_get_sent(self, telemetry_mock):
168173
"samcliVersion": ANY,
169174
"osPlatform": ANY,
170175
"commandName": ANY,
176+
"userAgent": "claude-code/1.0",
171177
"metricSpecificAttributes": {
172178
"containerEngine": ANY,
173179
"events": [

tests/unit/lib/telemetry/test_user_agent.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33

44
from parameterized import parameterized
55

6-
from samcli.lib.telemetry.user_agent import USER_AGENT_ENV_VAR, get_user_agent_string
6+
from samcli.lib.telemetry.user_agent import (
7+
ACCEPTED_USER_AGENT_FORMAT,
8+
USER_AGENT_ENV_VAR,
9+
get_user_agent_string,
10+
)
711

812

913
class TestUserAgent(TestCase):
@@ -43,3 +47,48 @@ def test_user_agent_without_env_var(self):
4347
@patch("samcli.lib.telemetry.user_agent.os.environ", {USER_AGENT_ENV_VAR: ""})
4448
def test_user_agent_with_empty_env_var(self):
4549
self.assertEqual(get_user_agent_string(), None)
50+
51+
52+
class TestUserAgentAgentFallback(TestCase):
53+
# NOTE: user_agent.py and agent_detector.py both read os.environ, which is a
54+
# single shared object, so one patch.dict on os.environ controls the env seen
55+
# by both the toolkit lookup and the agent detector. clear=True keeps the
56+
# ambient CLAUDECODE of the running shell from leaking into these tests.
57+
@patch.dict("samcli.lib.telemetry.user_agent.os.environ", {"CLAUDECODE": "1"}, clear=True)
58+
def test_detected_agent_is_used_when_no_toolkit_user_agent(self):
59+
# No AWS_TOOLING_USER_AGENT set, Claude Code detected -> agent string is emitted.
60+
self.assertEqual(get_user_agent_string(), "claude-code/1.0")
61+
62+
@parameterized.expand(
63+
[
64+
("AWS_Toolkit-For-VSCode/1.62.0",),
65+
("AWS-Toolkit-For-JetBrains/1.60-223",),
66+
]
67+
)
68+
def test_toolkit_user_agent_takes_precedence_over_detected_agent(self, toolkit_user_agent):
69+
# A valid AWS_TOOLING_USER_AGENT wins even when an agent is detected.
70+
with patch.dict(
71+
"samcli.lib.telemetry.user_agent.os.environ",
72+
{"CLAUDECODE": "1", USER_AGENT_ENV_VAR: toolkit_user_agent},
73+
clear=True,
74+
):
75+
self.assertEqual(get_user_agent_string(), toolkit_user_agent)
76+
77+
@patch.dict(
78+
"samcli.lib.telemetry.user_agent.os.environ",
79+
{"CLAUDECODE": "1", USER_AGENT_ENV_VAR: "invalid_value"},
80+
clear=True,
81+
)
82+
def test_detected_agent_is_used_when_toolkit_user_agent_is_invalid(self):
83+
# An invalid AWS_TOOLING_USER_AGENT falls through to the detected agent.
84+
self.assertEqual(get_user_agent_string(), "claude-code/1.0")
85+
86+
@patch.dict("samcli.lib.telemetry.user_agent.os.environ", {}, clear=True)
87+
def test_none_when_no_toolkit_user_agent_and_no_agent(self):
88+
self.assertEqual(get_user_agent_string(), None)
89+
90+
@patch.dict("samcli.lib.telemetry.user_agent.os.environ", {"CLAUDECODE": "1"}, clear=True)
91+
def test_emitted_agent_string_matches_accepted_format(self):
92+
# Guard against emitting a bare "ClaudeCode": the fallback must satisfy the
93+
# same regex the toolkit user-agent must satisfy.
94+
self.assertIsNotNone(ACCEPTED_USER_AGENT_FORMAT.match(get_user_agent_string()))

0 commit comments

Comments
 (0)