Least privilege for coding agents

Least privilege for coding agents

There's a good chance your coding agent can delete repos in your GitHub org right now. Checking takes about 30 seconds. Open the shell the agent actually runs in. Don't use your own terminal. Use the one Claude Code, Codex or your CI agent uses. Then type three commands: `gh auth status`, `aws sts get-caller-identity`, and `env | grep -i -E 'token|key|url'`. The output usually ruins the afternoon.

Here's an illustrative version of what most teams find. The names are made up, but the pattern is real.

text
$ gh auth status
github.com
  ✓ Logged in to github.com account minh-dev (keyring)
  - Active account: true
  - Token: gho_************************************
  - Token scopes: 'admin:org', 'delete_repo', 'repo', 'workflow'

$ aws sts get-caller-identity
{
  "UserId": "AROAXXXXXXXXXXXXXXXX:minh@acme.dev",
  "Account": "123456789012",
  "Arn": "arn:aws:sts::123456789012:assumed-role/AWSReservedSSO_PowerUserAccess_9a1b/minh@acme.dev"
}

$ env | grep -i -E 'token|key|url'
DATABASE_URL=postgres://app_rw:****@prod-primary.internal:5432/core
NPM_TOKEN=npm_****
SENTRY_AUTH_TOKEN=sntrys_****
STRIPE_SECRET_KEY=sk_live_****

Read that as a job description. This process can administer the GitHub org, delete repos and rewrite CI workflows. It holds PowerUser in the production account and can write to the primary database. It can charge cards in live mode. Its actual task today is fixing a misaligned button on the pricing page. While it works on that, it's reading issue comments written by people outside your company.

Nobody granted any of this on purpose. There was no ticket, no review and no approval. The agent inherited the access because it runs as the developer who launched it, and that developer has collected access over three years of on-call. We spent a decade getting humans off long-lived admin credentials. Then we quietly handed those credentials back to the newest and least predictable actor in the system.

The fix I'll walk through is easy to state: scope agents by what the task needs, not by who launched them. Before the run, the agent writes a permission manifest, and credentials are minted from that manifest only. After the run, you diff what was declared against what was actually used, including what ended up in commits and fixtures. That gap is the most useful trust signal an agent run gives you.

Why this is worse for agents than for humans

A developer with admin rights who opens a malicious README just closes the tab. An agent reads the README as input. Ambient authority plus untrusted input is the whole prompt injection problem in one line. Issue bodies, PR comments, a dependency's docs and a vendored README all land in the context window next to your instructions. Whatever they talk the agent into doing runs with your token. You can't filter every input. You can limit the token behind them to what the task needs.

The second problem is quieter. Short-lived credentials are the standard answer, and you need them, but they aren't enough. A 15-minute token doesn't help if the agent spent those 15 minutes querying the users table, then pasted five real rows into `fixtures/users.json` because it wanted realistic test data. The credential expired, but the data stays in git history for good. Anything an agent reads through a scoped credential can show up in what it produces, so you have to audit outputs as well as access.

The flow, end to end

Step 1: Write the manifest before the run

The manifest is a small YAML file that says what this run needs: which repo, which branches, which cloud actions, which data and which environment. Keep the schema boring. If it takes more than a minute to read, nobody will review it.

yaml
# .agent/manifests/run-8f3c.yaml
run_id: run-8f3c
ticket: WEB-2141            # "Pricing page CTA misaligned on Safari"
tier: 1
env: dev
repos:
  - name: acme/web-frontend
    access: write
    branch_pattern: "agent/WEB-2141-*"
cloud:
  aws:
    - service: s3
      actions: [GetObject]
      resources: ["arn:aws:s3:::acme-dev-storybook/*"]
data: []                     # no database for a CSS fix
network:
  egress: [registry.npmjs.org, github.com]
ttl_minutes: 60

Who writes it? One option is the agent itself, as the first step of the run, with no credentials yet beyond read access to the ticket. The other is a separate planner step that sees only the ticket and the repo map. I prefer the planner, because then the thing that proposes permissions isn't the thing that will spend them. Both work.

Next, define default tiers that are approved automatically. Ours look roughly like this:

  • Tier 0: read one repo. No cloud access, no data. Covers triage, explanations and code review comments.
  • Tier 1: write to one repo on an `agent/*` branch, open a PR, and read dev resources. This covers most bug fixes.
  • Tier 2: write across several repos, read from a masked data snapshot, or read staging. Needs human approval.
  • Tier 3: anything that touches infra, IAM, prod data or CI workflow files. Needs human approval. Honestly, a human should do most of these, with an agent assisting.

A human only sees the manifest when it asks for more than the default tier. That keeps approvals rare enough that people actually read them. There's also a side effect, and it's the real point of the exercise: a vague ticket produces a broad manifest. "Clean up the billing code" turns into a Tier 3 request with prod data access, so the vagueness becomes visible and expensive. The cheapest fix is a better ticket, and the manifest pushes people to write one.

Step 2: Mint credentials from the manifest, not the developer

Every credential the run holds should come from the manifest and be tagged with the run ID. Below are three recipes, one for each credential type you're probably leaking today.

(a) GitHub: an App installation token scoped to one repo. Create a GitHub App, install it on the org, and give it every permission your agents could ever need. At run time, request an installation token narrowed to the manifest. Installation tokens expire after one hour and can't be extended.

bash
# APP_JWT is signed with the App's private key (10 min max lifetime)
curl -s -X POST \
  -H "Authorization: Bearer ${APP_JWT}" \
  -H "Accept: application/vnd.github+json" \
  https://api.github.com/app/installations/${INSTALLATION_ID}/access_tokens \
  -d '{
    "repositories": ["web-frontend"],
    "permissions": {
      "contents": "write",
      "pull_requests": "write"
    }
  }' | jq -r .token

Leave out `workflows: write` unless the manifest asks for it. Without it, the agent can't edit `.github/workflows`. Enforce the branch pattern with a repository ruleset that blocks the App from pushing to `main`. A token can't express branch limits, but a ruleset can.

(b) AWS: AssumeRole with a session policy generated from the manifest. Create a base role called `agent-runner`. Its identity policy sets the upper limit, for example dev account only and no IAM. At run time, assume the role with an inline session policy built from the manifest. The session gets only the permissions both policies allow. It can never exceed the role, and the manifest narrows it further.

bash
aws sts assume-role \
  --role-arn arn:aws:iam::210987654321:role/agent-runner \
  --role-session-name run-8f3c \
  --source-identity run-8f3c \
  --tags Key=agent-run-id,Value=run-8f3c Key=ticket,Value=WEB-2141 \
  --duration-seconds 3600 \
  --policy '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Action": ["s3:GetObject"],
      "Resource": ["arn:aws:s3:::acme-dev-storybook/*"]
    }]
  }'

Two details matter. First, the role's trust policy has to allow `sts:TagSession` and `sts:SetSourceIdentity`, or the call fails. Second, pass `--source-identity` as well as the tag. Source identity carries through role chaining and shows up on every CloudTrail event, which is what lets you query CloudTrail by run ID in Step 5. On GCP, the rough equivalent is impersonating a per-task service account to get a short-lived token. Add Credential Access Boundaries if you need to narrow Cloud Storage access further. On Azure, use a user-assigned managed identity per task class, reached through workload identity federation.

(c) Data: a read-only replica user or a masked snapshot. Most agent tasks don't need a database at all. When one does, the default should be a masked snapshot, either dev data or a nightly anonymised copy. If the task really needs the shape of real data, create a per-run role on the primary that replicates down. Then give the agent a connection string that points only at the replica:

sql
-- on the primary; replicates to the read replica
CREATE ROLE agent_run_8f3c LOGIN
  PASSWORD :'generated_pw'
  VALID UNTIL '2026-10-28 15:00:00+00'
  IN ROLE agent_readonly;        -- agent_readonly has SELECT on an allowlisted schema only

-- injected into the run as:
-- DATABASE_URL=postgres://agent_run_8f3c:...@core-replica.internal:5432/core?sslmode=require

If you already run HashiCorp Vault, its database secrets engine creates the user, enforces the TTL and revokes access for you. That's the better choice.

Then remove the developer's credentials from the environment. This is the step people skip, and it matters most. A narrow token doesn't help if `~/.aws/credentials` is still mounted next to it. Run the agent in a container or VM that starts empty and receives only the minted credentials:

bash
docker run --rm \
  --env-file <(mint-credentials --manifest .agent/manifests/run-8f3c.yaml) \
  -v "$PWD/worktree:/work" \
  --network agent-egress-run-8f3c \
  agent-runner:latest
# Not mounted: ~/.aws ~/.config/gh ~/.ssh ~/.netrc ~/.docker/config.json ~/.npmrc
# Not inherited: the host environment (no -e passthrough)

Now run the 30-second check again inside the container. `gh auth status` should show the App token and nothing else. `aws sts get-caller-identity` should show `assumed-role/agent-runner/run-8f3c`. The env grep should be short and boring.

Step 3: Make denials loud and cheap

Once the agent is scoped, it will run into walls, and each one tells you something. Say the agent is "fixing a CSS bug" and gets denied on `iam:ListRoles`. That means one of three things: the manifest was too narrow, the agent drifted off task, or something it read sent it looking. You want to know which within minutes, not at the quarterly access review.

On AWS, add an EventBridge rule that matches CloudTrail events from `agent-runner` sessions with these error codes: `AccessDenied`, `AccessDeniedException` (services differ), or anything starting with `Client.UnauthorizedOperation` (EC2). Before you rely on it, check that your trail actually delivers the event types you care about, read-only ones included. S3 object calls like `GetObject` are data events, and CloudTrail doesn't log them unless you turn on data events for the bucket. For GitHub and the database, the agent's tool runner already sees the 403 or the permission error. Catch it there with a post-tool hook in Claude Code, or a wrapper around `gh`, `git` and `psql`, and log it with the run ID.

json
{
  "source": ["aws.s3", "aws.iam", "aws.ec2", "aws.secretsmanager"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "errorCode": ["AccessDenied", "AccessDeniedException", { "prefix": "Client.UnauthorizedOperation" }],
    "userIdentity": {
      "arn": [{ "prefix": "arn:aws:sts::210987654321:assumed-role/agent-runner/" }]
    }
  }
}

Send each denial to two places. The first is the agent's own transcript, as a plain message: "Denied: secretsmanager:GetSecretValue on prod/stripe. Not in manifest run-8f3c." Often the agent corrects itself or asks for a manifest change, which is what you want. The second is a team channel, where an engineer can see from one line whether this was a planning gap or drift. Treat it like a failing test with a stack trace. Most denials will be boring. The ones that aren't are the earliest warning you'll get.

Step 4: Audit outputs, not just API calls

CloudTrail tells you what the agent touched. It doesn't tell you what the agent took with it. So add a post-run job that runs before the PR is marked ready. It should scan everything the run produced: the diff, new or changed fixtures, snapshot files, the PR title and body, and any run logs you plan to keep.

Two cheap layers cover most of it. The first is a secret scanner over the worktree and the PR text. gitleaks and trufflehog both work, and either takes minutes to set up:

bash
git diff origin/main...HEAD > /tmp/run-8f3c.diff
gh pr view --json title,body -q '.title + "\n" + .body' > /tmp/run-8f3c-pr.txt

gitleaks detect --no-git --source /work --report-path /tmp/run-8f3c-gitleaks.json
trufflehog filesystem /tmp/run-8f3c.diff /tmp/run-8f3c-pr.txt --fail

The second layer is canary rows: fake rows planted in every data source an agent can read. Give them unique values that are easy to grep, for example a user named `Canary Fernsby` with the email `canary-7f2e91@example.invalid` and the customer ID `cus_CANARY7f2e91`. They replicate to the replica and also appear in the masked snapshot. If any of those strings shows up in a diff, fixture, PR body or log, the run copied real rows into its output. You don't need classifiers or fuzzy PII detection to catch that. One grep does it:

bash
grep -R -n -E 'canary-[0-9a-f]{6}@example\.invalid|cus_CANARY[0-9a-f]{6}' \
  /work /tmp/run-8f3c-pr.txt && echo "FAIL: replica rows leaked into output" && exit 1

A canary hit proves real data leaked. No hit doesn't prove nothing leaked, because the agent might have copied rows 4 to 9 and skipped the canary. Put canaries where a `SELECT ... LIMIT 10` would hit them, with low IDs and recent timestamps. Treat any hit as a real incident.

Step 5: Diff declared against used

Now combine three sources. The manifest tells you what was declared. Usage logs filtered by run ID tell you what was used: CloudTrail by source identity, the GitHub audit log by App actor and repo, and DB logs by role name. The denials from Step 3 tell you what was attempted. Watch for gaps. The GitHub audit log mostly records writes and admin actions, not API reads, so read usage has to come from your tool wrapper. S3 object reads only appear if you turned on data events. Each permission ends up in one of three buckets:

  • Declared and used: fine. The task did what it said it would.
  • Declared but never used: sloppy planning. It's harmless in a single run. If it keeps happening, tighten the default tier or the planner prompt.
  • Attempted but never declared: drift or injection. A human reviews these every time.

Here's an illustrative report for one run:

text
run-8f3c  ticket WEB-2141  tier 1  outcome: PR #4412 opened

| Permission                                   | Declared | Used | Attempted | Verdict          |
|----------------------------------------------|----------|------|-----------|------------------|
| github acme/web-frontend contents:write      | yes      | yes  | -         | ok               |
| github acme/web-frontend pull_requests:write | yes      | yes  | -         | ok               |
| s3:GetObject acme-dev-storybook/*            | yes      | no   | -         | unused, tighten  |
| github acme/design-tokens contents:read      | no       | no   | 403 x2    | REVIEW           |
| secretsmanager:GetSecretValue prod/*         | no       | no   | denied x1 | REVIEW (urgent)  |

Output audit: gitleaks 0 findings, trufflehog 0, canaries 0
Undeclared attempts: 3  ->  run flagged

The `design-tokens` row is probably honest drift. The button colour lives in another repo, and the planner didn't know that. Fix the repo map and move on. The Secrets Manager row is another matter, because a CSS fix doesn't explain it. Pull the transcript, find the tool call just before the denial, and check what the agent had just read. That's how you find the issue comment with the hidden instructions.

What this costs you, honestly

This isn't free. Teams that pretend it is tend to give up on the project in week three.

  • Setup: the GitHub App, the rulesets, the `agent-runner` role, a small credential-minting service and the post-run job. For a team that knows IAM, plan on a few focused days, not a quarter. Most of that time goes into the minting service, not the policies.
  • Slower runs: every run pays for manifest generation, token minting and the audit. That adds anywhere from a few seconds to a minute or two. You'll notice it on tiny tasks. On real tasks it doesn't matter.
  • Under-scoped failures: early on, agents will fail because the manifest left something out, and people will be annoyed. Each failure leads to a fix in your planner or tiers, and the failure rate drops quickly. Track it so you can show that it's dropping.
  • Approval fatigue, if your default tiers are set too low. If humans are approving more than a small share of runs, the problem is your tiers, not your engineers.

Roll it out in this order, and don't skip ahead:

  • 1. Read-only tasks: triage, code explanation, review comments. Tier 0 only. You learn the plumbing while nothing is at stake.
  • 2. Single-repo writes: bug fixes, test fixes, dependency bumps. Tier 1, App tokens, rulesets, and the output audit turned on.
  • 3. Cloud reads and masked data. Session policies, the replica user, canaries.
  • 4. Anything touching infra, IAM or CI workflows. Do this last, with mandatory approval, and only once the rate of undeclared attempts on earlier tiers is low and stable.

The side effect: your humans start to look very wide

Once agent access is written down as code (one repo, one hour, three actions), compare it with a typical engineer's standing access. The contrast is uncomfortable: org admin "from that migration in 2023", PowerUser in prod "for on-call", and a writable primary connection string in a `.env` file nobody remembers creating.

I'm not sharing a number from our side yet. I'm still checking what we've seen at PhoenixDX against real audit logs before I put it in writing. I'd ask you to do the same rather than trust anyone's anecdote, including mine. The exercise is cheap. Point the declared-vs-used method at your engineers' last 90 days of CloudTrail and GitHub audit logs, and compare the access each person holds with what they actually used. The manifest discipline you build for agents also turns out to be the clearest evidence you'll get about your humans' access.

The checklist

  • ☐ Run `gh auth status`, `aws sts get-caller-identity` and the env grep inside your agent's shell. Screenshot the output and share it with the team.
  • ☐ Step 1: Add a manifest schema (repo, branch pattern, cloud actions, data, env, TTL) and default tiers. Humans approve only runs that ask for more than the default tier.
  • ☐ Step 2: Mint credentials from the manifest. For GitHub, an App token limited to named repos and permissions. For AWS, AssumeRole with a session policy, a run-ID tag and source identity. For data, a per-run read-only replica user or a masked snapshot. Run the agent in a clean environment with no developer credentials mounted or inherited.
  • ☐ Step 3: Send AccessDenied and 403 events, tagged with the run ID, to the agent's transcript and to a team channel.
  • ☐ Step 4: Run a post-run output audit with gitleaks or trufflehog over the diff, fixtures, PR text and logs. Plant canary rows in every data source the agent can read, and grep its output for them.
  • ☐ Step 5: Produce a declared / used / attempted report for each run. Use unused permissions to tighten the tiers. Send undeclared attempts to a human.

If you track one number each week, track the share of agent runs with at least one undeclared attempt. If it's high, your planning is weak or something is steering your agents. If it's falling, your manifests are getting better. If it jumps suddenly in a quiet week, go read the transcripts.

Discussion

Ask my AI about this article. The chat stays on your device; when you're done you can publish a short summary for other readers.

No questions yet. Try: what's the trade-off here, or how would this fail in practice?