# Your AWS role can't tell a human from an agent anymore, part 2: label every agent call with a User-Agent tag

> AWS_SDK_UA_APP_ID gives you a filterable trail of agent activity in CloudTrail — and yes, you can roll it out with MDM. Part 2 of 5.

- Author: Gabriel Koo (AWS Community Builder)
- Published: 2026-10-01
- Topics: aws, security, ai, devsecops
- HTML: https://gabrielkoo.com/blog/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac/
- Original canonical URL: https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac

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.

```bash
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 `env` block 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 sets `aws:CalledViaAWSMCP` / `aws:ViaAWSMCPService` from 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_ID` is just an environment variable — an agent can `unset` it or override it. AWS warns that the caller controls `aws: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.

![Where the tag lands, where it leaks, and why it is telemetry rather than a control](/assets/img/ddbf42852172.png)

## 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:

```json
{
  "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:

```toml
[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:

```bash
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`)](https://docs.aws.amazon.com/sdkref/latest/guide/feature-appid.html)
- Amazon Bedrock — [Log API calls using AWS CloudTrail](https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html) (`Converse` / `InvokeModel` are management events)
- AWS Identity and Access Management — [`aws:UserAgent` condition key](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-useragent) (warning: caller-provided, not for authorization)
- AWS Identity and Access Management — [Global condition keys](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html) (`aws:CalledViaAWSMCP`, `aws:ViaAWSMCPService`)
- Claude Code — [Settings](https://code.claude.com/docs/en/settings) (`env` block, managed settings)
- Codex — [Advanced configuration](https://learn.chatgpt.com/docs/config-file/config-advanced) (`shell_environment_policy`)
- Codex — [Managed configuration](https://learn.chatgpt.com/docs/enterprise/managed-configuration) (MDM, `managed_config.toml`)
- Cursor — [Deployment patterns](https://cursor.com/docs/enterprise/deployment-patterns) (MDM policies, `permissions.json`)
- GitHub — [Enterprise managed settings for Copilot](https://docs.github.com/en/copilot/reference/enterprise-administrators/enterprise-managed-settings)
- Terraform AWS Provider — [Custom user-agent information](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#custom-user-agent-information)
- AWS CLI — [`filter-log-events` time range](https://docs.aws.amazon.com/cli/latest/reference/logs/filter-log-events.html)
