# 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.

- date: 2026-09-01
- tags: ai, 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](https://developer.1password.com/docs/cli/), 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](https://developer.1password.com/docs/service-accounts/):
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](https://devenv.sh), 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](https://github.com/gitleaks/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

- [1Password service accounts](https://developer.1password.com/docs/service-accounts/)
  and [`op run`](https://developer.1password.com/docs/cli/reference/commands/run/)
- [devenv.sh](https://devenv.sh), source at [cachix/devenv](https://github.com/cachix/devenv)
- [gitleaks](https://github.com/gitleaks/gitleaks)
- The rest of the agent toolchain: [Optimizing Claude Code](/tools)