# Keeping API keys out of the agent's reach

> Move a coding agent's API key out of its settings file into a password manager, hand it over through an environment variable or a key helper, deny the agent's reads of the secrets that stay, and scan the fixture and the transcript for what a hand forgot.

Page: https://schubergphilis.github.io/ai-training/coding-with-agents/keys-and-secrets/
License: CC BY-SA 4.0, https://creativecommons.org/licenses/by-sa/4.0/

A coding agent needs an API key, or a login that does the same job, to
reach its model. The key is billed to you, and anyone who holds it can spend
on your account, under your name. In this lesson we take a
fixture agent whose key sits in a settings file it can read and commit, and
move the key out of the files it can read in two steps. Then we run a small secret
scanner over the fixture and the session transcript, and we end with the rule
for a key you think has leaked.

The fixture is a small program in place of your coding agent. It never calls
a model. It does the one thing this lesson is about, which is to find a key
at start and say where the key came from. The setting names it uses are close to Claude
Code's, and the prose says which real setting each step maps to, checked
against the vendor's documentation on the date at the end of the lesson
(Settings files and precedence, Claude Code documentation). Other coding agents have the same three places for
a key under other names. Every key in the fixture is fake.
You don't need your real key for any step.

## Where the fixture keeps its key today

The fixture is in the course repository under
`site/examples/coding-with-agents/keys-and-secrets/fixture-repo/`. Copy that
directory somewhere, or work in a clone of the course repository, and change
into it. The course repository ignores every `.env`. So the application's
secrets file is committed as `env.sample`. Run `cp env.sample .env` once
before you start. `git checkout -- .` puts every tracked file back.

Inside are `agent.py`, the fixture agent, `vault.py`, the lesson's
replacement for the command-line tool of a password manager, `scan.py`, the
scanner, a `.env`
file with two fake application secrets, a `transcript.txt` from an earlier
session, and `.agent/settings.json`, the agent's settings file. Open that
file.

```json
{
  "env": {
    "AGENT_API_KEY": "sk-fake-lesson-key-0000-0000-0000-c0de"
  },
  "permissions": {
    "deny": []
  }
}
```

The `env` block sets an environment variable for every session, and here it
sets the key. Start the agent and it tells you so.

#### Example: Start the agent

Run this, and compare what you see with the output below.

```sh
python3 agent.py
```

Output:

```text
key sk-fake-...c0de from .agent/settings.json (env.AGENT_API_KEY)
deny rules: none
```

That file has two problems. The agent can read it, like any file in its
working directory. Whenever the agent looks at its own configuration, the
key ends up in the session's context. And the file is meant to be committed:
in Claude Code, the matching `.claude/settings.json` is the shared project
file that the whole team checks into source control, while
`.claude/settings.local.json` is the personal one, which Claude Code adds to
your git excludes the first time it writes it (Settings files and precedence, Claude Code documentation). A key
in the shared file is one `git push` away from everyone with access to the
repository.

The examples in this lesson aren't graded. Each one is a command you run in
the fixture, with the output the course itself checks on every build. The
checkpoints ask about the decisions, because deciding where a key may be is
what this lesson teaches.

Before you change anything, run the scanner from the last section once, so
you know the starting count. It reads dotfiles too.

#### Example: Scan the fixture as it ships

Run the scanner over the directory.

```sh
python3 scan.py
```

Output:

```text
.env:1  password assignment
.env:2  fake token
transcript.txt:4  fake key
.agent/settings.json:3  fake key
4 finding(s)
```

Four findings, and the last one is the key you are about to move.

## Step one: the key becomes an environment variable

Delete the `env` block from `.agent/settings.json`. The file then reads
`{"permissions": {"deny": []}}`. Now give the agent the key through your
shell instead. Where does the shell get it? From your password manager.
Most password managers have a command-line tool that asks you to unlock the
vault and prints one secret. `vault.py` is the lesson's replacement for that
tool. It prints the fake key, and the lesson runs without a password manager.
The scanner never lists `vault.py`, because the script assembles the key from
parts at run time and the whole string is in no file.

#### Example: Start the agent with the key in the environment

Set the variable for this one command from the vault's output, and start
the agent.

```sh
AGENT_API_KEY="$(python3 vault.py)" python3 agent.py
```

Output:

```text
key sk-fake-...c0de from environment variable AGENT_API_KEY
deny rules: none
```

The agent works the same, and no file in the repository holds the key. For
Claude Code, the variable is `ANTHROPIC_API_KEY`. When it is set there is no
browser login: Claude Code shows the key it found and asks you to confirm it
(Authentication, Claude Code documentation). Create that key as a personal key in the
Claude Console and store it in the password manager the moment the console
shows it. A personal key acts as you and stops working when you leave the
organization. A shared key acts as one person for everyone who holds it, and
breaks for everyone when that person leaves (Authentication, Claude Platform documentation).

Setting the variable in your shell's startup file (`.zshrc`, `.bashrc`)
puts the key in another plain-text file, and every program you start can
read it. The line above asks the vault each time instead. That is one more
prompt per session, and it is the step that keeps the key out of any file.

## Step two: a key helper, and deny rules for the files that stay

The second edit removes the prompt without bringing the file back. Instead
of you fetching the key and handing it over, the agent runs the vault
command itself at start. Edit `.agent/settings.json` to this:

```json
{
  "keyHelper": "python3 vault.py",
  "permissions": {
    "deny": ["Read(./.env)", "Edit(./.agent/settings.json)"]
  }
}
```

#### Example: Start the agent with a key helper

Start the agent with nothing set in the shell. It runs the helper and
reports the deny rules it loaded.

```sh
python3 agent.py
```

Output:

```text
key sk-fake-...c0de from keyHelper command: python3 vault.py
deny rules: Read(./.env), Edit(./.agent/settings.json)
```

In Claude Code the setting is `apiKeyHelper`: a shell command that returns
the key, run at start and again after five minutes by default
(Authentication, Claude Code documentation). The settings file then holds the name of a
command, which is safe to commit, and the password manager holds the key.

The deny rules cover the secrets that stay in the repository because the
application needs them. `.env` is the usual home of a database password or
a mail token, and a coding agent reads it as readily as any other file.
A `Read(./.env)` deny rule blocks Claude Code's file tools from opening it,
and the vendor's documentation names one limit that matters here: the rule
covers the built-in tools and file commands the tool recognizes, such as
`cat` and `head`, and doesn't cover a script that opens the file itself.
For enforcement that holds against every process, the same page points at
the sandbox, which a later lesson in this course covers
(Configure permissions, Claude Code documentation). The `Edit` rule on the settings file keeps the
agent from changing its own deny list. Claude Code already treats a write
under `.claude/` as a protected path that the normal modes prompt for, and
an `Edit(./.claude/settings.json)` deny rule is the explicit form of the
same decision (Choose a permission mode, Claude Code documentation). The fixture agent prints the
rules and doesn't enforce them, because enforcement is the real tool's job.

Claude Code reads a path in a `Read` rule that starts with `./`, or with no
prefix such as `/` or `~/`, as relative to the directory Claude Code starts
in. It reads these patterns the way git reads `.gitignore`, so a name with
no folder in it applies in every folder under the start directory.
`Read(.env)` and `Read(**/.env)` block the same files: each `.env` in the
start directory or below it, and none in a parent directory
(Configure permissions, Claude Code documentation). When Claude Code starts at the root of your
clone of the course repository, the fixture's `.env` is five folders down.
Use `Read(**/.env)` there, because it names the depth in the rule itself.

A `.gitignore` entry does a different job. It keeps `.env` out of commits,
and it does nothing about the agent reading the file, because the agent
reads the working directory and not the repository history.

#### Checkpoint: Where may the key be?

Buckets:

- A safe place for the key
- The key leaks from here

Items:

- Your password manager
- An environment variable set for one command from the password manager
- A key helper command named in the settings file, which asks the password manager at start
- The shared settings file that the team commits
- A .env file that is listed in .gitignore, in the directory the agent works in
- The session transcript, after the agent printed its settings file

#### Pitfall: The agent read .env and kept it

An engineer asks an agent to add a test for the mail client. The agent
reads `.env` to learn the configuration format, and it writes the real mail
token into the new test file as an example value. The diff is green, the
test passes, and the token is committed with the test. The rule: a secret
the agent can read is a secret it can write anywhere. Deny the read before
the session starts, and scan the diff before you commit.

## Scan the fixture and the transcript

You moved the key by hand, and a hand is what forgets a file. A secret
scanner is a program that reads every file for strings that look like a
key or a password and reports each one with a file name and a line number.
Real scanners know hundreds of vendor formats and check git history as
well. gitleaks is one, and it is free software (Gitleaks, GitHub, gitleaks/gitleaks README). `scan.py` is
the lesson's replacement, with the fixture's fake formats and the two patterns
every scanner starts from: a password assignment, and a long random-looking
string after a key-like prefix.

#### Example: Scan the fixture after the move

Run the scanner again.

```sh
python3 scan.py
```

Output:

```text
.env:1  password assignment
.env:2  fake token
transcript.txt:4  fake key
3 finding(s)
```

Read the report. The settings file no longer appears, which is what you
set out to do. `.env` appears twice, as expected, and it is why the deny
rule exists. The third line is the one to stop at: `transcript.txt` holds
the key. In an earlier session someone asked the agent how it was
configured, the agent read its settings file and quoted the key in its
answer, and the transcript kept it. A transcript is a file like any other.
It may end up in a log directory, or someone may paste it into a bug report.
From this point, treat that key as exposed.

#### Checkpoint: Is .gitignore enough?

A colleague says: "The `.env` is in `.gitignore`, so the agent can't leak
it." Is that right?

- Yes. The file is ignored, so it stays out of the repository and out of the agent.
- No. The agent reads the working directory, so add a deny rule for the file and keep .gitignore for what it does.
- No. Delete .env and put the password in the settings file, which the agent treats as configuration.
- Yes, as long as the agent runs in a fresh clone.

## A key you think has leaked

The transcript question has one answer: revoke first, investigate second. A
leaked key is used quickly, and rotation is cheap. In the Claude Console the
keys page has a **Disable** action that is reversible and a **Delete**
action that is permanent, and the vendor's own advice for a key you no
longer trust is to turn it off or remove it (Authentication, Claude Platform documentation). Create
a new key, store it in the password manager, and start the agent again
through the helper. Nothing else in your setup changes, because nothing else
held the key. Then work out how the leak happened, so it happens once.

The same page suggests an expiry date when you create a key. A key that
expires in thirty days limits how long a leaked copy keeps working, and it
still isn't a substitute for keeping it out of files (Authentication, Claude Platform documentation).

#### Checkpoint: The scanner finds the key in the transcript

The scanner reports `transcript.txt:4  fake key`. In your own setup that
line names a real key that is still active. What do you do first?

- Delete the transcript and carry on. The key is out of the file now.
- Revoke the key in the console now, create a new one into the password manager, and then read the transcript to see how it got there.
- Work out first who could have seen the transcript, and revoke only if someone outside the team had access.
- Add a deny rule for the transcript file, so the agent cannot read it again.

## Exercise

In a copy of the fixture, do the two edits yourself: remove the `env` block
from `.agent/settings.json`, put `"keyHelper": "python3 vault.py"` and the
two deny rules in its place, and start the agent with `python3 agent.py`.
Then run `python3 scan.py` and write down, for each line of the report,
what you do about it. Plan on five minutes. This is the whole procedure,
and after one pass you can repeat it on your real agent's settings file
without the page.

A good result: the agent reports the key from the helper and both deny
rules, the scanner reports three findings and none of them in the settings
file, and your notes say "deny rule, stays" for the two `.env` lines and
"revoke, then find out how" for the transcript. Then answer one question:
which file in your own projects plays the part of `transcript.txt`?

Stretch: If you have a password manager with a command-line tool, replace vault.py in the keyHelper with the real command that prints a test entry you create for this purpose, and confirm the agent starts. Then delete the test entry.

## Recap

1. A coding agent's key is billed to you. Use a personal key, never a shared one, and keep it in a password manager (Authentication, Claude Platform documentation).
2. A key never goes in a file the agent can read and commit. The shared settings file is both. Hand the key over through an environment variable set from the password manager, or let a key helper fetch it at start (Authentication, Claude Code documentation).
3. Deny the agent's reads of the secrets that stay in the repository, such as `.env`. `.gitignore` keeps a file out of commits and does nothing about the agent reading it (Configure permissions, Claude Code documentation).
4. Run a secret scanner over the working directory and the transcript, and read every line of the report.
5. A key that appears where it shouldn't is revoked first and investigated second (Authentication, Claude Platform documentation).

## References

- Settings files and precedence, Claude Code documentation, https://code.claude.com/docs/en/settings
- Authentication, Claude Code documentation, https://code.claude.com/docs/en/authentication
- Authentication, Claude Platform documentation, https://platform.claude.com/docs/en/manage-claude/authentication
- Configure permissions, Claude Code documentation, https://code.claude.com/docs/en/permissions
- Choose a permission mode, Claude Code documentation, https://code.claude.com/docs/en/permission-modes
- Gitleaks, GitHub, gitleaks/gitleaks README, https://github.com/gitleaks/gitleaks
