Chapter 10 of 12 · Agentic Engineering — A Practitioner Playbook for Production Software with AI Agents
Key, Token and Secret Security in Agentic Work
One principle: the agent should never see a secret value. This chapter dismantles the common approach of keeping keys in a .md file behind agent permissions, separates registry from value, covers runtime injection, nine defence layers, a two-way secret hook, and an egress allowlist.
One principle carries this whole chapter: the agent should never need to see a secret’s value. It only needs to know the secret’s name, where it is used, and where to obtain it. The value is injected at runtime, by a process the agent is not part of.
10.1 What most teams do — and where it fails
The common approach is a .md or .txt file with real keys in the project, plus permissions in the agent’s config restricting who may read it. This is far better than nothing, and the intent is right — but the defence is placed at the wrong layer.
| The assumption | The reality |
|---|---|
| ”Other agents cannot read it because deny is set” | Deny operates at that agent’s tool layer, not at the OS. Another tool, another script, or an agent with a different config on the same machine reads the file normally |
| ”Deny Read on the file means the agent cannot reach it” | If the agent can run a shell, there are many indirect routes: grep in other files, printenv, logs, backups, git history, a script that reads it on the agent’s behalf. A denylist is a speed bump, not a wall |
| ”The secret is only on my machine” | The moment the value enters the context window it has left the machine for the model provider, and is usually written to a local transcript as well |
| ”It is in .gitignore, so it is safe” | .gitignore stops new commits. It does not undo what was committed, nor stop the agent copying the value into a file that is not ignored — a test fixture, a comment |
| ”It is only a dev key” | True only if that key is genuinely separated from production, genuinely cannot reach real data, and expires on its own. Usually none of those hold |
The same approach, adjusted slightly, becomes much better
Keep the
.mdfile — but make it a registry of secrets, not a store of them: variable name, where it is used, who owns it, where to obtain it, when it expires, and not a single real value. A file like that can be committed, the agent can read all of it, and it is more useful to the agent than before, because it answers “which one do I need” — the thing the agent actually has to know. The value itself it never requires.
10.2 Separate the reference from the value
# Secrets Registry
This file contains no real secret values. If you see one here, treat it as a
security incident: stop, tell the system owner, and rotate that key immediately.
| Variable | Used in | Scope | Owner | Lifetime | Obtain from |
|--------------------|-----------------------|----------------------|----------|----------|----------------------|
| DATABASE_URL | src/infra/db.py | rw, dev schema only | platform | 90 days | op://dev/pg/url |
| TAVILY_API_KEY | tools/web.py | search only | you | none | op://dev/tavily/key |
| STRIPE_SECRET_KEY | src/infra/payments.py | test mode only | payments | 30 days | op://dev/stripe/test |
| GITHUB_TOKEN | scripts/release.sh | repo:read, no write | platform | 7 days | gh auth token |
## Rules for the agent
- Use only the variable names in this table. Do not invent new ones
- Always read values through os.environ[...]. Never hardcode, never write a value to a file
- If a variable you need is not in this table, stop and ask. Do not create a key
or find a value elsewhere
- If the code raises KeyError for a missing variable, report the missing name.
Do not go looking for the value
## Never on a dev machine
- Any production key
- Any credential that can read real customer data
- Any token with write access to main or to deploys
# .env.template ← in git, readable by the agent, says what must exist
DATABASE_URL=
TAVILY_API_KEY=
STRIPE_SECRET_KEY=
GITHUB_TOKEN=
# .env ← holds real values, in .gitignore, not readable by the agent and not needed by it
# Better still: do not have this file at all. Inject at runtime instead
10.3 Injecting secrets at runtime, leaving nothing on disk
If the real value is never on disk, there is no file for anyone to read by mistake. This is the largest improvement available for the least effort.
# --- 1Password CLI: the value stays in the vault, injected into the process only ---
# .env.template uses op:// syntax instead of real values
# TAVILY_API_KEY=op://dev/tavily/key
op run --env-file=.env.template -- uv run pytest
op run --env-file=.env.template -- ./scripts/verify.sh
# --- direnv: loaded on entering the directory, gone on leaving ---
# .envrc (in .gitignore)
# export TAVILY_API_KEY="$(op read op://dev/tavily/key)"
direnv allow
# --- sops + age: an encrypted file that can live in git, decrypted at runtime ---
sops -e .env.plain > .env.enc # .env.enc is committable
sops exec-env .env.enc './scripts/verify.sh'
# --- cloud: fetched at runtime, never stored locally ---
export DATABASE_URL=$(aws secretsmanager get-secret-value \
--secret-id dev/db --query SecretString --output text)
# --- CI: exchange OIDC for temporary credentials instead of long-lived keys ---
# permissions: { id-token: write }
# uses: aws-actions/configure-aws-credentials@v4
# with: { role-to-assume: arn:aws:iam::...:role/ci, aws-region: ap-southeast-1 }
Why this suits agentic work particularly well
Agents work through subprocesses that inherit the parent process’s environment. Run
op run -- claudeorop run -- ./scripts/agent-loop.shand the code the agent writes callsos.environ["TAVILY_API_KEY"]and works normally — while the agent never sees the value in conversation, has no file to read, and the value disappears when the process ends. The work proceeds exactly as before, with a far smaller surface for a leak.
10.4 The full set of defence layers
| Layer | Measure | What it does |
|---|---|---|
| L0 | Reduce what needs protecting | Do not put genuinely privileged credentials on a dev machine at all. Use test mode, sandbox accounts, synthetic data |
| L1 | Narrow scope, short life | Fine-grained, read-only tokens expiring in 7–30 days — a leak has a bounded blast radius |
| L2 | Keep values off disk | Inject at runtime with op run, direnv, sops, or a secret manager |
| L3 | Separate privileges at the OS level | Run the agent in a container or as a user that cannot read ~/.ssh, ~/.aws, ~/.config/gh |
| L4 | The agent’s denylist | A speed bump that catches honest mistakes well — never the only defence |
| L5 | Control the exit | An outbound domain allowlist, so even a leaked value has nowhere to go |
| L6 | Detect before commit | gitleaks in pre-commit and in CI, catching it while it is still easy |
| L7 | Redaction in logs | A filter that masks values in logs and error messages before they are written or sent |
| L8 | A rotation plan | Assume it leaked: what happens, who acts, how many minutes. Rehearse it |
10.5 Setting the agent’s permissions (layer L4)
{
"permissions": {
"deny": [
"Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
"Read(./**/*.pem)", "Read(./**/*.key)", "Read(./**/*.p12)",
"Read(./**/credentials*)", "Read(./**/*service-account*.json)",
"Read(~/.ssh/**)", "Read(~/.aws/**)", "Read(~/.config/gh/**)",
"Read(~/.kube/**)", "Read(~/.docker/config.json)",
"Read(~/.netrc)", "Read(~/.pgpass)", "Read(~/.npmrc)",
"Bash(env)", "Bash(printenv:*)", "Bash(set)", "Bash(export:*)",
"Bash(op:*)", "Bash(aws:*)", "Bash(gcloud:*)", "Bash(vault:*)",
"Bash(gh auth token)", "Bash(docker inspect:*)",
"Bash(kubectl get secret:*)", "Bash(journalctl:*)",
"Bash(history)", "Bash(cat ~/.*)",
"Bash(curl:*)", "Bash(wget:*)", "Bash(nc:*)", "Bash(ssh:*)",
"Bash(scp:*)", "Bash(rsync:*)"
],
"allow": [
"Read(./src/**)", "Read(./tests/**)", "Read(./docs/**)",
"Read(./.env.template)",
"Edit(./src/**)", "Edit(./tests/**)",
"Bash(uv run pytest:*)", "Bash(uv run ruff:*)", "Bash(uv run mypy:*)"
]
}
}
Understand the denylist’s limits accurately
The list above is genuinely useful, but its main value is stopping honest mistakes: an agent hunting for config and reaching for
cat .envis caught, and that happens more often than you would think. What it cannot stop is the countless indirect routes, as long as the agent can run a shell and the value is readable by the same user account. Treat L4 as supplementary, and put the real weight on L0–L3, which make sure there is nothing to read in the first place.
#!/usr/bin/env python3
"""Works alongside guard_pretooluse.py
exit 0 = allow | exit 2 = block, returning the reason for the agent to correct itself"""
import json, re, sys
data = json.load(sys.stdin)
tool = data.get("tool_name", "")
inp = data.get("tool_input", {})
def block(msg):
print(f"BLOCKED (secrets): {msg}", file=sys.stderr)
sys.exit(2)
# --- common secret shapes, used to inspect what is about to be written ---
SECRET_SHAPES = [
(r"sk-[A-Za-z0-9]{20,}", "OpenAI-style key"),
(r"sk-ant-[A-Za-z0-9\-_]{20,}", "Anthropic key"),
(r"tvly-[A-Za-z0-9]{16,}", "Tavily key"),
(r"gh[pousr]_[A-Za-z0-9]{30,}", "GitHub token"),
(r"AKIA[0-9A-Z]{16}", "AWS access key id"),
(r"AIza[0-9A-Za-z\-_]{30,}", "Google API key"),
(r"xox[baprs]-[A-Za-z0-9\-]{10,}", "Slack token"),
(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", "private key"),
(r"eyJ[A-Za-z0-9_\-]{10,}\.eyJ[A-Za-z0-9_\-]{10,}\.", "JWT"),
(r"postgres(ql)?://[^:\s]+:[^@\s]+@", "DB URL with a password"),
(r"mongodb(\+srv)?://[^:\s]+:[^@\s]+@", "MongoDB URI with a password"),
]
# --- 1. stop real values being written to files (the most common mistake) ---
if tool in ("Edit", "Write"):
body = inp.get("new_string", "") or inp.get("content", "")
for pattern, name in SECRET_SHAPES:
if re.search(pattern, body):
block(f"about to write a {name} into a file — use os.environ[...] instead, "
f"and if this is a real value, tell me so the key can be rotated now")
# --- 2. stop commands that extract secrets indirectly ---
if tool == "Bash":
cmd = inp.get("command", "")
LEAKY = [
(r"\b(printenv|env)\b(?!\s*\|?\s*grep\s+-c)", "printing the whole environment"),
(r"cat\s+.*\.env", "reading a .env file"),
(r"\bhistory\b", "reading shell history"),
(r"(curl|wget|nc)\b.*\$\{?[A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)",
"sending a secret over the network"),
(r"echo\s+.*\$\{?[A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)", "printing a secret"),
(r"git\s+log\s+.*-S", "searching git history for secrets"),
]
for pattern, why in LEAKY:
if re.search(pattern, cmd, re.I):
block(f"{why} | command: {cmd[:120]}")
for pattern, name in SECRET_SHAPES:
if re.search(pattern, cmd):
block(f"found a {name} in the command — never put a real value in a command; "
f"reference it through an environment variable")
sys.exit(0)
10.6 Catching it before git (layer L6)
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.21.2
hooks:
- id: gitleaks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: detect-private-key
- id: check-added-large-files
# ── STAGE 0 of verify.sh: security, run first ──
run_stage "secrets" gitleaks protect --staged --redact -v
run_stage "secrets-diff" bash -c \
'gitleaks detect --source . --redact --log-opts="${BASE_REF:-origin/main}..HEAD"'
run_stage "no-secret-files" bash -c \
'! git ls-files | grep -Ei "(^|/)\.env$|\.pem$|\.key$|credentials"'
# Scan the whole history once
gitleaks detect --source . -v
If the agent wrote a real key to a file and it was committed
Deleting the file in the next commit does not make the key safe. The value remains in git history, and once pushed, treat it as disclosed. The correct order is rotate the key first, deal with the history afterwards — never the other way round. Cleaning history takes tens of minutes; rotating takes a few and stops the damage immediately.
10.7 Redaction in logs and errors (layer L7)
The leak people most often overlook is the error message, because when code breaks the agent pastes a stack trace into the conversation to analyse — dragging a connection string or a token-bearing header along with it.
import logging, re
PATTERNS = [
(re.compile(r"(sk-ant-|sk-|tvly-|gh[pousr]_)[A-Za-z0-9\-_]{8,}"), r"\1***"),
(re.compile(r"(://[^:/\s]+:)[^@\s]+(@)"), r"\1***\2"),
(re.compile(r"(?i)(authorization|x-api-key|cookie)(['\"]?\s*[:=]\s*['\"]?)[^\s'\",}]+"),
r"\1\2***"),
(re.compile(r"(?i)((?:api[_-]?key|token|secret|password)['\"]?\s*[:=]\s*['\"]?)[^\s'\",}]+"),
r"\1***"),
]
class RedactFilter(logging.Filter):
def filter(self, record):
msg = record.getMessage()
for pattern, repl in PATTERNS:
msg = pattern.sub(repl, msg)
record.msg, record.args = msg, ()
return True
logging.getLogger().addFilter(RedactFilter())
# And test it — do not write it and assume it works
def test_redacts_db_url_password():
out = _apply("postgres://app:hunter2@db:5432/x")
assert "hunter2" not in out
10.8 Egress control — removing the leak’s destination (layer L5)
This is the highest-value measure in the worst case. If the agent is tricked by prompt injection from a web page or an issue into sending data out, but the network permits only a handful of destinations, the attack has nowhere to send it.
┌─ the agent's container ───────────────────────────────┐
│ No ~/.ssh, ~/.aws, ~/.config/gh mounted │
│ No .env file │
│ Environment holds only what this task genuinely needs │
│ Runs as a non-root user │
└──────────────────┬────────────────────────────────────┘
│ every request goes through the proxy
▼
┌─ egress proxy (allowlist) ────────────────────────────┐
│ Allow: api.anthropic.com, pypi.org, registry.npmjs, │
│ github.com, api.tavily.com, internal docs │
│ Deny: everything else, and log every denial │
└───────────────────────────────────────────────────────┘
The metric to watch is how often the proxy denies a request. An unusual rise means something is trying to reach outside the list, and it needs looking at immediately.
services:
agent:
image: agent-runtime:latest
user: "1000:1000" # not root
working_dir: /work
volumes:
- ./src:/work/src:rw
- ./tests:/work/tests:rw
- ./docs:/work/docs:ro
- ./scripts:/work/scripts:ro # the agent cannot edit the gate
# not mounted: .env, ~/.ssh, ~/.aws, ~/.config, .git/config
environment: # inject only what this task needs, not the whole set
- TAVILY_API_KEY=${TAVILY_API_KEY}
- HTTPS_PROXY=http://egress-proxy:3128
- NO_PROXY=localhost,127.0.0.1
networks: [agent-net]
cap_drop: [ALL]
What this chapter settles
Store names, not values. Inject at runtime so there is no file to read by mistake. Treat the denylist as a speed bump rather than a wall. Put an egress allowlist in place so a leak has no destination. And if a key does reach git, rotate it before cleaning the history — always in that order.
The next chapter covers Git and process when everyone on the team has an agent of their own.