The first time I watched an AI agent debug a failing deploy, it did exactly
what a careful engineer would do. It opened the .env file, printed it to
see which variables were set, spotted the typo, and fixed it. Twenty
seconds of good work. The same .env file held the API token for my DNS
provider and the key for the server that runs this site, and both went
straight into the transcript.
Nothing bad happened, and that is not the point. The point is that every
coding agent works the same way: Claude Code, Cursor, Codex, Copilot’s
agent mode, Aider. They run shell commands as you, from your project
directory. Whatever your shell can read, the agent can read. A token in
.env, the keys in ~/.aws, a password you once typed inline and left in
shell history. Each one is a single cat away, and the agent does not need
to be malicious to surface it. It only needs to be thorough.
The usual advice is “review every command before you approve it”. That works for about an hour. Then you switch on auto-accept, because a permission prompt every fifteen seconds is not a workflow. So I wanted something that does not depend on me paying attention: an agent session should hold only the secrets of the project it is in, only inside the process that needs them, and it should be unable to publish the ones it does hold.
Here is what you will have by the end:
- Secrets that live in a password manager, not in files on disk.
- A project directory that hands its own secrets to any process started inside it, and nothing to a process started anywhere else.
- One identity per project (vault, GitHub account), pinned per directory, so an agent cannot act as the wrong you.
- A commit hook that stops a secret from leaving the machine, no matter who wrote the commit.
Everything here works on macOS and Linux with any shell. The examples use
1Password, because that is
what I use. If you use another manager with a CLI, the shape is the same:
a headless token scoped to one vault, and a run command that injects
secrets into a single process.
1. See what your agent can already see
Before you fix anything, look at what is exposed. Run these from a shell in a project directory, which is exactly where an agent starts:
# Credential files an agent can read with one command
ls -la ~/.aws ~/.config/gh ~/.docker ~/.netrc 2>/dev/null
# Every .env file under your home directory
find ~ -maxdepth 5 -name '.env*' -not -path '*/node_modules/*' 2>/dev/null
# Variables every child process inherits (names only, this is safe to run)
env | grep -iE 'token|secret|key|password' | cut -d= -f1
# Secrets you typed inline and left in shell history
grep -ciE 'token|secret|password' ~/.zsh_history ~/.bash_history 2>/dev/nullThe first time I ran this, the result was longer than I liked: .env
files in projects I had forgotten about, and a shell history full of
tokens from curl commands. Every line in that output is something an
agent can read, and everything it reads can end up in a transcript, a log,
or a commit.
2. Move the secrets into a vault, one vault per project
Install the 1Password CLI and check that it can see your account:
brew install 1password-cli # macOS; Linux packages: developer.1password.com/docs/cli
op --version
op vault listCreate a vault for each project. Mine is called wihan_dev and holds
everything for this site: the deploy server’s API key, the DNS token, the
GitHub token. The vault is the unit of access. A token that can open one
vault can leak one project’s secrets, not everything you own. Your
personal logins stay in a vault that no automation can reach.
Add the secrets as items in the 1Password app, then confirm the CLI can
read them by reference. A reference is a path of vault/item/field:
op read "op://wihan_dev/dokploy_api_key/credential"That command is the last time a secret value needs to appear on your screen.
3. Replace every .env with a template
This is the step that removes secrets from disk. Instead of a .env file
with values, each project gets a committed .env.tpl with references:
# .env.tpl - committed. Documents every key, contains no secret.
DOKPLOY_API_KEY=op://wihan_dev/dokploy_api_key/credential
CLOUDFLARE_API_TOKEN=op://wihan_dev/cloudflare/api_token
GITHUB_TOKEN=op://wihan_dev/github/tokenThen run anything that needs the values through op run:
op run --env-file=.env.tpl -- bun run dev
op run --env-file=.env.tpl -- terraform planop run resolves the references at launch, injects the values into that
one process, and masks them in any output the process prints. Nothing lands
on disk, nothing sits in shell history, and the agent can run the deploy
without a secret ever appearing in a file it could read back.
Once the template works, delete the .env and make sure it stays gone:
rm .env
grep -qx '.env' .gitignore || echo '.env' >> .gitignore💡 Try it on the agent. Ask it to print
DOKPLOY_API_KEY. Outsideop runthe variable is empty. Insideop run, the output reads<concealed by 1Password>. That is the whole idea in one line.
4. Let the project directory hand out the token
op read normally asks the desktop app to unlock, with a fingerprint or a
password. An agent cannot answer that prompt, and you do not want it to.
The answer is a
service account:
a headless identity with its own token, which you scope to exactly one
vault when you create it (1Password → Developer → Service accounts).
Save the token in a file that only you can read:
install -d -m 700 ~/.config/op
${EDITOR:-nano} ~/.config/op/wihan_dev.sa-token # paste the token, once
chmod 600 ~/.config/op/wihan_dev.sa-tokenNow the trick that makes all of this invisible. The token must reach
every shell started inside the project directory, and no shell started
anywhere else. A short block in ~/.zshenv maps directories to token
files:
# ~/.zshenv - directory to vault map for 1Password service accounts
typeset -A op_service_accounts=(
"$HOME/stuff/vanillauys" wihan_dev
"$HOME/work/acme" acme_dev
)
for op_dir op_name in "${(kv)op_service_accounts[@]}"; do
if [[ "$PWD" == "$op_dir" || "$PWD" == "$op_dir"/* ]] \
&& [[ -r "$HOME/.config/op/$op_name.sa-token" ]]; then
export OP_SERVICE_ACCOUNT_TOKEN="$(<"$HOME/.config/op/$op_name.sa-token")"
break
fi
done
unset op_dir op_name op_service_accountsAgents spawn their shells in the project directory, so every session picks
up the right vault with no prompt, and op run just works. A shell in
your home directory gets no token at all, and your personal vault stays
behind the desktop app’s unlock, which an agent cannot answer.
⚠️ Why
~/.zshenvand not direnv or~/.zshrc? Agents run non-interactive shells.~/.zshrcand the direnv hook only run for an interactive prompt, so neither fires for an agent’s commands. zsh reads~/.zshenvfor every shell it starts. On bash, put the same loop in a file and pointBASH_ENVat it.
5. Pin the toolchain and the identity per directory
Secrets are one kind of ambient authority. Identity is the other. I have
two GitHub accounts, work and personal, and gh auth switch changes the
active one globally, in every terminal, including the one where a work
session is running. An agent that runs it once has just made every later
gh command act as the wrong person.
The fix is the same shape as the token: a per-process variable, set per
directory. GH_CONFIG_DIR points gh at a separate config directory
whose hosts.yml names one account. I set it with
devenv, which declares the whole toolchain of a
directory in Nix, so the agent also gets the same bun, terraform, and
go as CI:
# devenv.nix
{ pkgs, ... }:
{
env.GH_CONFIG_DIR = "/home/wihan/.config/gh-vanillauys";
packages = [ pkgs.bun pkgs.terraform pkgs.gitleaks pkgs.go ];
}If you do not use Nix, the same line in the ~/.zshenv block from step 4
does the job for the identity half:
export GH_CONFIG_DIR="$HOME/.config/gh-vanillauys"Either way, an agent in this tree cannot act as the work account. The option does not exist in its environment.
6. Scan every commit on the way out
Scoping limits what an agent can read. gitleaks limits what it can publish. A global pre-commit hook scans the staged diff of every commit, agent-made or not, before the commit exists:
brew install gitleaks # or: nix, apt, go install
mkdir -p ~/.config/git/hooks# ~/.config/git/hooks/pre-commit
#!/bin/sh
gitleaks git --pre-commit --staged --redact --no-banner || {
echo "gitleaks found a possible secret in the staged changes." >&2
exit 1
}chmod +x ~/.config/git/hooks/pre-commit
git config --global core.hooksPath ~/.config/git/hooksBecause it is global, it covers every repo on the machine, including the
ones you clone next year. The line you want to see under every agent
commit is no leaks found.
7. Tell the agent the rules
None of the above depends on the agent’s cooperation. That is deliberate.
But an agent that knows the rules makes fewer messes, so each repo gets a
short section in its CLAUDE.md (or AGENTS.md, or whatever your tool
reads):
## Secrets
- Secrets are environment variables only. Never read, print, or commit a `.env` file.
- Run commands that need credentials through `op run --env-file=.env.tpl -- <command>`.
- Never run `gh auth switch` or `op signin`. The shell already has the right identity.Now the agent reaches for op run by default, and when it cannot find a
value it asks instead of hunting for it.
Verify the boundaries
From a shell inside the project tree:
op whoami # ✅ the service account, not your user
op vault list # ✅ exactly one vault
gh api user --jq .login # ✅ the pinned account
git commit -m test # ✅ "no leaks found" in the hook outputAnd from a shell outside it:
op vault list # ❌ falls back to the desktop app: no ambient tokenIf all of those line up, an agent session can deploy the whole stack and still cannot touch your personal vault, your work identity, or a secret on disk, because none of those exist where it lives.
Conclusion
The setup took an afternoon and I have not thought about it since. The checklist:
- ✅ Secrets in a vault, one vault per project
- ✅
.env.tplcommitted,.envgone,op runaround every command - ✅ A service-account token that only shells inside the project can see
- ✅ One GitHub identity per directory, set per process
- ✅ gitleaks on every commit, from every repo
- ✅ The rules written down where the agent reads them
None of it slows the agent down. It runs the deploy, reads the logs, opens the pull request. It just does all of that with a wallet instead of the keys to the house.
Further reading
- 1Password service accounts
and
op run - devenv.sh, source at cachix/devenv
- gitleaks
- The rest of the agent toolchain: Optimizing Claude Code