skip to content
wihan.dev

Using AI coding agents without leaking your secrets

Claude Code, Cursor, and Codex all work by running shell commands, and your shell can read every secret on your machine. Here is how to give an agent the credentials it needs, and nothing else.

8 minai · agents · security · 1password · devenv

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:

bash
# 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/null

The 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:

bash
brew install 1password-cli   # macOS; Linux packages: developer.1password.com/docs/cli
op --version
op vault list

Create 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:

bash
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:

bash
# .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/token

Then run anything that needs the values through op run:

bash
op run --env-file=.env.tpl -- bun run dev
op run --env-file=.env.tpl -- terraform plan

op 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:

bash
rm .env
grep -qx '.env' .gitignore || echo '.env' >> .gitignore

💡 Try it on the agent. Ask it to print DOKPLOY_API_KEY. Outside op run the variable is empty. Inside op 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:

bash
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-token

Now 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:

zsh
# ~/.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_accounts

Agents 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 ~/.zshenv and not direnv or ~/.zshrc? Agents run non-interactive shells. ~/.zshrc and the direnv hook only run for an interactive prompt, so neither fires for an agent’s commands. zsh reads ~/.zshenv for every shell it starts. On bash, put the same loop in a file and point BASH_ENV at 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:

nix
# 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:

zsh
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:

bash
brew install gitleaks   # or: nix, apt, go install
mkdir -p ~/.config/git/hooks
sh
# ~/.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
}
bash
chmod +x ~/.config/git/hooks/pre-commit
git config --global core.hooksPath ~/.config/git/hooks

Because 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):

markdown
## 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:

bash
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 output

And from a shell outside it:

bash
op vault list              # ❌ falls back to the desktop app: no ambient token

If 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:

  1. ✅ Secrets in a vault, one vault per project
  2. ✅ .env.tpl committed, .env gone, op run around every command
  3. ✅ A service-account token that only shells inside the project can see
  4. ✅ One GitHub identity per directory, set per process
  5. ✅ gitleaks on every commit, from every repo
  6. ✅ 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