SSD Nodes Learn 8GB RAM — $66/yr
Guides Matt ConnorBy Matt Connor

Keep secrets out of your AI agents

An agent holding your API keys can leak them in one tool call. Give it scoped short lived tokens behind a credential gateway, never the real keys.

What keeping secrets out of AI agents means

An AI agent is a normal Linux process that runs commands. Every environment variable that process holds is readable by the code it runs, so an API key in the agent's environment is a key the agent can send to any host it can reach. Keeping secrets out of the agent means giving it a handle instead of the key: a short lived scoped token, or a placeholder that something else swaps for the real value at the network boundary.

This is not a story about a model turning hostile. The mechanism is duller. An agent reads a web page, a README, or an issue comment that contains instructions, and it follows them, because to a language model there is no difference between text you wrote and text it fetched. That is prompt injection. Once it happens, the damage is bounded by exactly one thing: what the process can read. If you have not set a boundary yet, running a coding agent safely on a server covers the isolation rungs this guide sits on top of.

The threat model in plain terms

Run this as the user your agent runs as.

tr '\0' '\n' < /proc/self/environ | grep -iE 'key|token|secret|password'

Every line it prints is one HTTP request away from a stranger's server. Now look at what is on disk near the agent.

grep -rIl --exclude-dir=.git -e 'API_KEY' -e 'SECRET' ~/projects
find ~ -maxdepth 3 -name '.env' -o -name 'credentials' -o -name '*.pem'

An agent with a shell does not need a clever exploit to move that data out. Four ordinary paths do the job, and all four look like normal work in the log:

  • An outbound curl or fetch to any host, with the value in a query string.
  • A git commit and git push to a repository the agent can write.
  • A package install script, which runs arbitrary code as the agent's user.
  • A DNS lookup of a hostname that contains the value, which leaves even when HTTP egress is blocked.

You cannot review your way out of this. The fix is to make sure there is nothing valuable in reach.

A secret in the working tree is a secret in the context window

An agent reads files. A .env file in the repository it is working in will be read, and once read it is in the context window, which means it is in the transcript, in any log you keep, and in whatever the agent writes next.

Before, with the key sitting in the tree the agent works in:

cd ~/projects/billing
cat .env
# STRIPE_SECRET_KEY=sk_live_...
# DATABASE_URL=postgres://app:hunter2@db.internal:5432/billing

After, with the file moved out of reach:

sudo install -d -m 750 -o root -g agent-review /etc/agent-review
sudo install -m 640 -o root -g agent-review ~/projects/billing/.env /etc/agent-review/billing.env
rm ~/projects/billing/.env

The agent's user can no longer open the file, because the working tree no longer contains it. Deny rules in the agent's own config are a second layer, not the first one. Claude Code reads permission rules from .claude/settings.json in the project:

{
  "permissions": {
    "deny": ["Read(./.env)", "Read(./secrets/**)", "Read(./**/*.pem)"]
  }
}

That stops the honest mistake of the agent opening a file while exploring. It does not stop an injected instruction from running base64 .env, because that is a shell command and not a file read. Treat the config as a guardrail and the filesystem permission as the wall. The same split applies inside containers: env files and secrets in Docker Compose covers the version of this problem one layer down.

Give every agent its own unprivileged user

If the agent runs as you, it inherits your SSH keys, your cloud credentials, and your shell history. A separate user costs one command and removes all of that.

sudo adduser --disabled-password --gecos "" agent-review
sudo chmod 700 /home/agent-review
sudo -u agent-review cat ~/.ssh/id_ed25519

The last line must fail with cat: /home/you/.ssh/id_ed25519: Permission denied. If it prints a key instead, your home directory is group or world readable, and chmod 700 ~ fixes it. Do not add the agent user to sudo, and do not give it a NOPASSWD rule broader than the one command it truly needs. Least privilege users on a VPS goes through the group and sudoers detail.

One more boundary is worth adding on a cloud VPS. The instance metadata service answers on a fixed link local address, and it often hands out role credentials to anything that asks.

sudo iptables -A OUTPUT -m owner --uid-owner agent-review -d 169.254.169.254 -j REJECT

Check it from the agent's side. sudo -u agent-review curl -s --max-time 3 http://169.254.169.254/ should print nothing and exit non zero, because the packet is rejected before it leaves the box.

Inject the credential at the boundary

The pattern that actually solves this is credential injection. The agent never holds a real key. It sends its request through a local gateway, and the gateway swaps a placeholder for the real secret on the way out. The secret lives in the gateway's storage, in a different process, owned by a different user.

OneCLI is one open source implementation of this, Apache-2.0 licensed, and it runs as a container next to the agent. As of July 2026 the project documents this setup:

git clone https://github.com/onecli/onecli.git
cd onecli
docker compose -f docker/docker-compose.yml up -d --wait

The dashboard listens on port 10254 and the gateway on 10255. You store the real credential once, then give each agent a placeholder value in place of the key plus its own scoped access token, which it sends in a Proxy-Authorization header. The gateway matches the outbound request by host and path, decrypts the matching credential, and substitutes it. The agent's environment holds nothing worth stealing.

The value here is not the encryption. It is that the question "what did this agent use, and when" becomes a log query. You read one audit trail instead of guessing which of six environments held a copy of the key.

Hand the secret to the process, not to the environment

If you run the agent under systemd, you do not need environment variables at all. LoadCredential= places the secret in a private directory that only that service can read, exposed as %d in the unit file and as $CREDENTIALS_DIRECTORY inside the process. The value never appears in /proc/<pid>/environ, so ps eww cannot show it, and the directory disappears when the service stops.

Encrypt the credential to the machine first. These commands come from the systemd documentation and work on systemd 250 or newer, which covers Ubuntu 24.04 and Debian 13:

echo -n 'sk-example-value' > /tmp/plain.txt
sudo systemd-creds encrypt --name=api_key /tmp/plain.txt /etc/credstore/api_key.cred
shred -u /tmp/plain.txt
sudo systemd-run -P --wait -p LoadCredentialEncrypted=api_key:/etc/credstore/api_key.cred \
  systemd-creds cat api_key

The last command prints sk-example-value. That proves the encrypted file decrypts on this host. Then reference it from the unit:

[Service]
User=agent-review
LoadCredentialEncrypted=api_key:/etc/credstore/api_key.cred
Environment=AGENT_KEY_FILE=%d/api_key
ExecStart=/usr/local/bin/agent-worker

Your agent code opens the file at $AGENT_KEY_FILE when it needs the value. A file read is a moment. An environment variable lasts for the life of the process, in every child it spawns.

Prefer short lived tokens to long lived keys

A key that never expires is still valid whenever it surfaces, months later, in a log or a transcript. Where the service offers a session token, take the session token and set the shortest lifetime the work allows.

aws sts assume-role \
  --role-arn arn:aws:iam::123456789012:role/agent-readonly \
  --role-session-name agent-review \
  --duration-seconds 900

Fifteen minutes is the minimum AWS STS (security token service) accepts, and it is usually enough for one agent task. For GitHub, give the agent user its own gh login with a fine grained token scoped to the single repository it works on, so gh auth token inside that session returns something that cannot touch anything else. Scope by resource first, then by time.

Verify, then keep verifying

Three checks are worth running after any change to an agent's setup. Run them as the agent's user, not as yourself.

sudo -u agent-review env | grep -iE 'key|token|secret'
sudo -u agent-review ls -la /home/you/ 2>&1 | head -3
sudo -u agent-review curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 https://api.github.com/user

The first should print nothing at all. The second should print ls: cannot open directory '/home/you/': Permission denied. The third tells you what identity the agent's network path presents, which is the question the gateway pattern exists to answer: a 401 means the agent is carrying no GitHub credential of its own, and a 200 means it is carrying one, so you should know which token it is. If you run agents unattended, controlling AI agent costs on a VPS covers the budget limits that pair with these access limits.

FAQ

Can I just trust the model not to leak my keys?

No, because the model is not the attacker in this threat model. The agent reads text from web pages, repositories, and issue trackers, and that text can contain instructions. The model has no reliable way to tell your instructions from text it fetched. Any control that depends on the model choosing correctly fails the first time an injected instruction is convincing, so the control has to sit in the operating system or the network instead.

Are environment variables really that bad for agent secrets?

They are bad in one specific way: they are inherited. Every child process the agent spawns gets a copy, including a build script, a test runner, and any package install hook. The variables are also readable through /proc/<pid>/environ by the same user, so anything the agent runs can read them without the agent passing them along. A file read at the moment of use, with LoadCredential= or a gateway, limits the exposure to that moment.

Does putting secrets in a vault solve this on its own?

Only partly. A vault fixes storage. It does not fix the last step, where something pulls the secret out of the vault and hands it to the agent as an environment variable, which puts you back where you started. What matters is who performs the substitution. If the agent fetches the secret, the agent has the secret. If a gateway or the init system performs the substitution outside the agent's process, the agent never holds it.

How do I know whether an agent has already leaked something?

Usually you cannot tell after the fact, which is the argument for the gateway. Without one, your evidence is scattered across shell history, the agent's transcript, and outbound connection logs you are probably not keeping. With a credential gateway, every use of a credential is one line with an agent identity and a timestamp. If you suspect a leak, rotate the key first and investigate second. Rotation is cheap and certainty is not.

What is the minimum I should do today?

Move every .env file out of the directories your agents work in, and create one unprivileged user per agent. Those two changes take about ten minutes and close the most common path, which is an agent reading a credential file that had no reason to sit next to the code. The gateway and the short lived tokens are the next step, not the first one. The same starting point applies to any agent runtime, including running an autonomous agent safely on a VPS.