Skip to content

8 min read

An agent started from your shell inherits every credential in it

On this page

The task was "the integration test for the nightly export is flaky, find out why", and the run that picked it up did something reasonable. It read the test, noticed that the test asserted on the number of objects written to an S3 bucket, decided that the assertion was probably racing against a listing that was eventually consistent, and went to look at the bucket to check its hypothesis. The command it ran was aws s3 ls s3://acme-exports-prod --recursive | wc -l, and the command returned 1,284,551.

The engineer who found that line in the transcript the next morning had started the harness from her own terminal, which is how everybody ran it during development. Two hours before that she had been on an incident bridge, and the way she had got onto it was export AWS_PROFILE=prod-breakglass in the same shell. The harness had done nothing except call subprocess.Popen without an env argument, which is the default, and the default is to hand the child a copy of the parent's environment.

The immediate damage was not the listing command. It was that the run then read the first two hundred keys to look at the naming scheme, and the keys in that bucket are of the form exports/2026-03-10/acct-88213/ivan.petrov%40example.com.csv.gz, so a set of customer account identifiers and email addresses went into a model context, into the transcript store and into the debug log the harness writes for every run. Nothing was modified, nothing was deleted, and the incident review still took four days.

What is actually in that environment

Counting is worth doing once, because the number is larger than the mental model.

bash
$ env | wc -l
214
$ env | grep -Ei '(token|secret|key|password|passwd|credential|auth|_url)=' | \
      sed -E 's/=(.{4}).*/=\1…/'
AWS_PROFILE=prod…
AWS_SESSION_TOKEN=IQoJ…
DATABASE_URL=post…
GITHUB_TOKEN=ghp_…
NPM_TOKEN=npm_…
DATADOG_API_KEY=8f2a…
SENTRY_AUTH_TOKEN=sntr…
STRIPE_SECRET_KEY=sk_l…
VAULT_TOKEN=hvs.…
$ echo "$DATABASE_URL" | sed -E 's#//[^@]*@#//***@#'
postgres://***@replica-01.prod.acme.internal:5432/acme?sslmode=require

Nine of two hundred and fourteen. The interesting property of that list is that not one of those variables was set by anyone thinking about the agent. AWS_SESSION_TOKEN came from the incident. NPM_TOKEN and GITHUB_TOKEN are in a .zshrc because a build script wanted them in 2024. STRIPE_SECRET_KEY — a live key, note the sk_l prefix — is there because someone was reproducing a webhook against the real account last week and never unset it. DATABASE_URL points at a production read replica because that is what the debugging runbook says to do. The environment is a scratch surface that accumulates whatever anybody has needed recently, and it has no owner, no expiry and no review.

Inheritance also travels further than the immediate child. The agent spawns a shell, the shell runs pnpm test, the test harness spawns workers, and a worker imports a library that reads DATABASE_URL at module load. Nobody at any level in that stack made a decision; the variable simply arrives, and the code that consumes it cannot distinguish a variable the operator meant for it from one that leaked four processes ago.

Start from nothing and add back

The correct default is the opposite of inheritance. Construct the child environment explicitly, from an empty dictionary, and put in it exactly what the run has been authorised to hold. Everything the code below leaves out is a decision to leave it out, and that is the entire point of writing it this way.

python
import os, subprocess, boto3, pathlib

# Variables the run genuinely needs, none of which grant anything.
_PASSTHROUGH = ("LANG", "LC_ALL", "TZ", "TERM")

def build_env(run_id: str, workspace: pathlib.Path) -> dict[str, str]:
    run_home = workspace / ".home"
    (run_home / ".cache").mkdir(parents=True, exist_ok=True)

    env: dict[str, str] = {
        "PATH": "/usr/local/bin:/usr/bin:/bin",
        "HOME": str(run_home),
        "TMPDIR": str(workspace / ".tmp"),
        "XDG_CACHE_HOME": str(run_home / ".cache"),
        "GIT_TERMINAL_PROMPT": "0",
        "GIT_CONFIG_GLOBAL": "/dev/null",
        "GIT_CONFIG_SYSTEM": "/dev/null",
        "AGENT_RUN_ID": run_id,
    }
    for name in _PASSTHROUGH:
        if name in os.environ:
            env[name] = os.environ[name]

    env |= mint_dev_credentials(run_id)
    return env

def spawn(cmd: list[str], run_id: str, workspace: pathlib.Path) -> subprocess.Popen:
    return subprocess.Popen(
        cmd,
        cwd=workspace,
        env=build_env(run_id, workspace),   # never omit this argument
        start_new_session=True,
    )

Three of those entries are doing work that is easy to miss. HOME is redirected into the workspace, because an empty environment achieves very little if HOME still points at /home/priya — the AWS CLI reads ~/.aws/credentials, git reads ~/.gitconfig and ~/.git-credentials, npm reads ~/.npmrc, and every one of those is a file-based credential that no environment variable was ever protecting. GIT_CONFIG_GLOBAL=/dev/null closes the same door for git specifically, so that a global credential.helper on the host does not quietly serve tokens into the run. start_new_session=True puts the child in its own process group, which is what makes it possible to kill the whole tree later rather than orphaning a test runner that keeps the workspace busy.

The version of this fix that almost every team writes first is a denylist: keep the inherited environment and delete the dangerous entries, usually with a regular expression over the variable names much like the grep above. It fails on the next variable anybody invents. KUBECONFIG matches none of those patterns and points at a file granting cluster-admin; HTTPS_PROXY matches none of them and can route every outbound request through a host of the setter's choosing; NODE_OPTIONS=--require /tmp/x.js matches none of them and executes arbitrary code in every Node process the run starts; LD_PRELOAD does the same for everything dynamically linked. The list of variables that grant something has no boundary, because any library may decide tomorrow that a new variable configures it. An allowlist has the opposite property: the failure mode is that a build breaks because it wanted CI=true, someone adds CI to the tuple, and the review of that one-line change is the moment somebody thinks about it.

Credentials that are minted, not found

The run does need to reach some things: an internal package registry, the git server, perhaps a development database. Those arrive as short-lived credentials issued for this run, in the development account, and nowhere else.

python
def mint_dev_credentials(run_id: str) -> dict[str, str]:
    sts = boto3.client("sts")
    resp = sts.assume_role(
        RoleArn="arn:aws:iam::441502973118:role/agent-dev-runner",
        RoleSessionName=f"agent-{run_id}"[:64],
        DurationSeconds=900,          # 15 minutes; refreshed by the harness
        Tags=[{"Key": "run_id", "Value": run_id}],
    )
    c = resp["Credentials"]
    return {
        "AWS_ACCESS_KEY_ID": c["AccessKeyId"],
        "AWS_SECRET_ACCESS_KEY": c["SecretAccessKey"],
        "AWS_SESSION_TOKEN": c["SessionToken"],
        "AWS_REGION": "eu-west-1",
        "DATABASE_URL": dev_database_url(run_id),
    }

AssumeRole is a call to AWS's Security Token Service, which hands back a temporary key, secret and session token that expire on a schedule the caller asks for, so the harness is issuing credentials rather than storing them. Account 441502973118 is the development account and it has no peering, no cross-account trust and no replica of production data in it. The fifteen-minute duration is the shortest value AWS will issue for AssumeRole, and it matters less as a limit on the agent than as a limit on the wreckage: a token copied out of a transcript, a log or a stack trace stops working before anyone has finished reading the incident channel. Tagging the session with the run identifier is what makes CloudTrail — the account's own log of every API call made against it — useful afterwards, because every call the run makes carries agent-run-b71e04 in its session name and can be attributed to one task rather than to "the agent".

The refresh is the part people skip and then regret. A 40-minute task with a 15-minute credential fails two thirds of the way through with an ExpiredToken error from whatever library happened to be holding it, usually inside a retry loop that turns one clear failure into eight minutes of exponential backoff. The harness has to re-mint and re-inject before expiry, which for a long-lived child process means either a credential file inside the workspace that the harness rewrites, or a local metadata endpoint the SDKs can refresh against. Environment variables cannot be updated in a running process, and a design that only sets them at spawn time has quietly chosen a maximum task length of fifteen minutes.

json
{
  "eventTime": "2026-03-11T09:14:02Z",
  "eventName": "ListObjectsV2",
  "userIdentity": {
    "type": "AssumedRole",
    "arn": "arn:aws:sts::441502973118:assumed-role/agent-dev-runner/agent-run-b71e04"
  },
  "requestParameters": {"bucketName": "acme-exports-prod"},
  "errorCode": "AccessDenied"
}

That is the same command the run in the opening executed, after the change. It still ran, because nothing about the environment stops an agent forming an intention; it failed, it failed in the development account's trail, and it names the run.

What an empty environment does not close

Environment scoping is one boundary and it is a narrow one. The obvious gap is disk: ~/.aws/credentials, ~/.netrc, ~/.ssh/id_ed25519, ~/.docker/config.json, ~/.kube/config and ~/.config/gh/hosts.yml are all credential stores that live in files, and redirecting HOME only helps if the run cannot read the real home directory by absolute path. On a machine where the run shares a uid with the developer, as in the worktree arrangement from chapter 3, it can, and the environment work is a lock on a door in a wall that has no other bricks.

The second gap is the SSH agent, the background process that holds a developer's decrypted private keys and signs challenges on request so that nobody has to retype a passphrase. Dropping SSH_AUTH_SOCK from the environment stops the well-behaved path, and the socket itself is a file under /tmp owned by the same uid, so a run that looks for it can use every key the developer has loaded without ever holding one. The third is instance metadata: on a cloud build host, http://169.254.169.254/latest/api/token will hand out credentials for whatever role is attached to the host, and that role is frequently broader than anything the harness would have minted. IMDSv2 — version 2 of that instance metadata service, which requires a token fetched by PUT before it will answer — has a default hop limit of 1, which blocks this from inside a bridged container and does nothing at all for a process on the host, which is why the metadata endpoint belongs in the network policy rather than in the environment discussion.

Moving the run into a container changes the mechanism without changing the question. A container does start with an empty environment by default, which is genuinely better than Popen without an env argument, and the ways teams put variables back are exactly where the problem returns. docker run --env-file .env reads whatever that file has accumulated. -e AWS_PROFILE without a value copies the host's value through. A CI system is worse still: most of them export every secret configured on the project into every job's environment, so a harness running as a CI job inherits the deploy key, the registry token and the signing credential without anybody writing a line of code. The construction has to happen somewhere, and a container only moves the place where it is easy to forget.

None of this is secret scanning either, which is a different control aimed at a different moment. Scanning finds credentials that have already been written into a repository or a log; environment construction decides which credentials exist in the process at all. A team that has one and not the other has covered one of two independent paths.

The largest gap is not a gap in the mechanism. An empty environment plus minted development credentials answers the question of what the agent inherits by accident, and says nothing about what it is given on purpose. That question arrives immediately, usually as a reasonable-sounding amendment in the review of exactly this change: the agent sometimes genuinely needs to look at production data to reproduce a bug, so rather than removing DATABASE_URL altogether, replace it with a read-only credential — reads cannot break anything. The next chapter is about why that amendment is more expensive than the thing it is amending.

Sandboxed developer workspaces — read the current version at https://daviddominguez.dev/learn/en/sandboxed-developer-workspaces/an-agent-started-from-your-shell-inherits-every-credential-in-it

Back to top