Part 1 gave the agent an identity you control. This part makes its calls visible — cheaply, and across a fleet.
Layer 2 — Label: make agent traffic visible in CloudTrail
This one is cheap, mostly overlooked, and worth exactly what it is: supporting telemetry, not enforcement.
AWS_SDK_UA_APP_ID is an officially supported setting that appends an application identifier to calls made by the AWS CLI and supported AWS SDKs. AWS documents it so customers can identify which application made a set of calls. That also makes it useful as a best-effort forensic tag for AI agents.
export AWS_SDK_UA_APP_ID="ai-agent_mydepartment"
It also works as sdk_ua_app_id in a shared ~/.aws/config profile. Either way, calls made by a supported client carrying it include ai-agent_mydepartment in CloudTrail's userAgent field — and it covers more than shell activity: if your agents run Claude Code against Bedrock, their own model calls are AWS API calls, so the inference traffic gets the tag too. (Checked: a Bedrock model call made with the app ID set shows up in CloudTrail's Converse event carrying it. Converse is logged as a management event, so it shows up even on a default trail.) Crude but effective:
-
Set it once at the machine level, not per tool. Deploy it via your MDM / device management so every Claude Code, Codex, or OpenClaw process on a managed laptop inherits it. For Claude Code specifically, an
envblock in~/.claude/settings.json— or the project's.claude/settings.json, or org-managed settings delivered by MDM — sets it for every session:{"env": {"AWS_SDK_UA_APP_ID": "ai-agent_mydepartment"}}. The agent then has to actively remove it, which is friction most tools won't bother with. -
Know where the tag leaks. Support is wide, not universal: legacy SDK majors (Go 1.x, Java 1.x, JavaScript 2.x) don't implement it, and non-SDK tooling carries its own User-Agent instead — Terraform's AWS provider, for example, uses
TF_APPEND_USER_AGENT. The app ID is also capped at 50 characters with a documented restriction on special characters. An untagged call means "this client doesn't participate", not "this was a human". Where traffic goes through AWS MCP servers instead, AWS setsaws:CalledViaAWSMCP/aws:ViaAWSMCPServicefrom the request path — a signal the caller can't simply delete, though it covers that path only. -
Treat it as telemetry, not a control.
AWS_SDK_UA_APP_IDis just an environment variable — an agent canunsetit or override it. AWS warns that the caller controlsaws:UserAgent, so it must not be used to prevent unauthorized direct requests. Use the tag to observe, never to authorize. A missing tag is a signal that something unusual happened, not proof that the request was human. I tested the control-shaped version while writing this: a permissions boundary that explicitly denies KMS reads when the bot's User-Agent appears works exactly as documented — until the caller removes one environment variable, or simply sends a different app ID. That is the whole argument, in one experiment. - CloudTrail becomes filterable. You can now answer "what is the agent doing?" with a simple query, instead of guessing.
Rolling the tag out with MDM
The obvious objection: "we're not going to ask 200 developers to export an environment variable." Fair. But if the org already manages laptops, each major coding tool has a managed-configuration channel — and their support for this specific trick varies more than you'd expect.
Claude Code has the cleanest path. Managed settings (delivered by MDM, a managed-settings.json in a system directory, or server-managed from the admin console) can't be overridden by user files, and they support an env block:
{
"env": {
"AWS_SDK_UA_APP_ID": "ai-agent_mydepartment"
}
}
Because managed settings sit at the top of the precedence stack, every session gets the variable whether the user asks for it or not. If you're on a Claude Team/Enterprise plan and standardise on Claude Code, this is a ten-minute rollout.
Codex gets there a different way. Its shell_environment_policy controls which environment variables it passes to the commands it spawns, and it can inject values outright:
[shell_environment_policy]
inherit = "core"
set = { AWS_SDK_UA_APP_ID =
"ai-agent_mydepartment" }
That config can be delivered as managed configuration — on macOS via an MDM profile in the com.openai.codex preference domain (config_toml_base64), on Linux/macOS via /etc/codex/managed_config.toml, or as cloud-managed requirements from the workspace. One honest caveat: managed defaults are defaults — a user can change them mid-session, and the client reapplies them next launch. (Enforced policy lives in requirements.toml, which governs approval/sandbox settings rather than env vars.) So it's a strong default, not a lock.
Cursor is the awkward one. It supports MDM policies (allowed team IDs, extensions, update mode, workspace trust) and you can push a managed ~/.cursor/permissions.json to control terminal and MCP allowlists — but the policy file takes static JSON only, and its docs explicitly answer "no" to environment variables in policy. There's no documented managed-env mechanism for the agent.
GitHub Copilot sits in between: enterprise managed settings exist (managed-settings.json in a .github-private repo, or native MDM delivery via registry/macOS managed preferences), but they're aimed at AI settings like model selection — not injecting environment variables into the commands the agent runs.
So, to answer the obvious question: if your company standardises on one coding plan, this is genuinely achievable — Claude Code and Codex both have documented, MDM-deliverable paths to get the tag into the agent's calls. On a mixed fleet you do it tool by tool, and on Cursor/Copilot you fall back to setting the variable at the OS/shell level and accepting it won't be enforced per tool. And even where it is enforced, remember what it buys you: better telemetry. The tag is still caller-controlled, and the experiment above is the proof.
Reading the trail
Example — find all agent-originated calls in the last hour:
START_MS=$(( ($(date +%s) - 3600) * 1000 ))
aws logs filter-log-events \
--log-group-name "CloudTrail/DefaultLogGroup" \
--filter-pattern \
'{ $.userAgent = "*ai-agent_mydepartment*" }' \
--start-time "$START_MS"
Use the JSON pattern rather than a bare quoted phrase. The quoted form matches the string anywhere in the event — requestParameters included — while the $.userAgent selector keeps it to the field that actually carries the tag. (Both forms were run against a test log group while writing this: on an event that echoed the tag in its parameters too, the quoted form returned a false positive and the JSON selector did not.) The SDK renders the app ID as app/ai-agent_mydepartment inside the User-Agent string, so a substring match is what you want. And filter-log-events paginates at 1 MB — a busy hour may need a nextToken loop before you trust the count. That command assumes your trail is configured to deliver to a CloudWatch Logs group; use your actual log group name. For fleet-wide queries, an Athena table over the CloudTrail bucket is a better fit.
Next up: the layer that holds when everything above fails — an SCP backstop that even an admin session can't override.
Sources
- AWS SDKs and Tools — Application ID (
AWS_SDK_UA_APP_ID) - Amazon Bedrock — Log API calls using AWS CloudTrail (
Converse/InvokeModelare management events) - AWS Identity and Access Management —
aws:UserAgentcondition key (warning: caller-provided, not for authorization) - AWS Identity and Access Management — Global condition keys (
aws:CalledViaAWSMCP,aws:ViaAWSMCPService) - Claude Code — Settings (
envblock, managed settings) - Codex — Advanced configuration (
shell_environment_policy) - Codex — Managed configuration (MDM,
managed_config.toml) - Cursor — Deployment patterns (MDM policies,
permissions.json) - GitHub — Enterprise managed settings for Copilot
- Terraform AWS Provider — Custom user-agent information
- AWS CLI —
filter-log-eventstime range

Top comments (0)