---
title: "how to give claude code api keys that never show up in the chat"
description: "agent-secrets gpg-encrypts api keys and hands them to the command that needs them, not the agent. how it works with claude code and what it doesn't protect."
date: 2026-10-08
author: "joel taylor pedrós"
lang: en
url: https://joeltaylor.business/blog/api-keys-claude-code
translations:
  ca: https://joeltaylor.business/blog/claus-api-claude-code
  es: https://joeltaylor.business/blog/claves-api-claude-code
image: https://joeltaylor.business/_next/static/media/cover.en.2i0d4ak03m8wt.webp
---

# how to give claude code api keys that never show up in the chat

![two boxes. the conversation only holds the command and its result; the process holds the key, hidden behind blue dots.](https://joeltaylor.business/_next/static/media/cover.en.2i0d4ak03m8wt.webp)

i have 156 credentials stored, api keys among them, split between work and personal projects. they live encrypted in [agent-secrets](https://github.com/jtayped/agent-secrets), a tool i released on 8 september, and a coding agent like claude code can use them without the value ever reaching the conversation.

here i explain how it works inside, what it doesn't do, and a bug that lasted nine days: when i said no, the agent got a yes.

## why not a .env file

the usual way to give an agent a password is a `.env` file. the agent opens it and the value ends up written into the conversation, which travels to a server and gets stored somewhere. if all the keys are in the same file, giving one means giving them all. and the agent finds a plain-text `.env` without even looking for it, with any `grep` across the repository.

## the value goes to a process, not to the agent

the agent doesn't open any secrets file. it asks for a group by name with `secret-run acme openai`, followed by `--` and the command it wants to run. `acme` is a scope, a `.env` file encrypted with gpg, and `openai` is a group of keys inside it. the program decrypts the scope, takes out only that group and runs the command with those variables in its environment. the agent sees what the command prints, and not the key.

the end of the function that does this is nearly the whole trick:

```bash
exec "${drop[@]}" \
    env -i HOME="$target_home" USER="$target_user" LOGNAME="$target_user" \
    PATH="$child_path" ${env_args[@]+"${env_args[@]}"} "$@"
```

`env -i` starts from an empty environment and puts only the group's variables in it. `exec` replaces the process that held the whole decrypted scope with the command, which only has its own group. no command in the tool prints a value, and the only rule for the agent is not to run `env`, `printenv` or `set` through it, since they exist to print the environment.

![two secret-run calls in a terminal. the first says the key is 22 characters long and the second that the variable exists, without showing it.](https://joeltaylor.business/_next/static/media/secret-run.0x6bi1rxv1byv.webp)

_the command gets the key and can tell you its length. it never reaches the terminal, so it never reaches the conversation._

if a task needs two groups, both go in the same command. nesting one `secret-run` inside another doesn't work, and that's on purpose, because the second one's `env -i` wipes the first one's variables.

## reading the names without opening anything

to pick the right group, the agent needs to know what's there. every scope has a plain-text index with the names of the groups and keys, the descriptions and which groups are sensitive, with no values. `secret-list` walks it like `ls`, one level at a time.

![secret-list with the tree option on an example scope: groups, key names and descriptions, with one group marked as sensitive and one key with an asterisk.](https://joeltaylor.business/_next/static/media/arbre.3uvrb__vwn5jc.webp)

_an example scope. the agent sees what's in it and what it's for, without decrypting anything._

postgres roles carry `mode=ro` or `mode=rw`. a read task finds the read-only role, which usually isn't marked and doesn't ask anyone for anything.

the index has no values, but it is an inventory of which credentials i have and what they're for. the encryption is for the copies that leave the machine. each scope is a self-contained gpg file that can sync to a cloud service, and the key lives in a separate directory that never syncs. with `AGENT_SECRETS_SYNC_INDEX=0`, the index doesn't leave either.

## the key belongs to root

without the root install step, the key is a file owned by my user, and any program running as me can read it and decrypt everything with gpg. in that mode the approval dialog catches mistakes, but it doesn't stop anything.

`install-root.sh` changes that. it copies the privileged program to `/usr/local/libexec`, owned by root, moves the key to `root:root` with 600 permissions and adds a single line to sudoers that lets my user run that program without a password. from then on, reading the file myself gets me nothing. the only way in is the program.

sudoers can't restrict a command's arguments, so all the checks live inside the program. it validates the scope name, which is what blocks a `decrypt ../../root/.ssh/id_ed25519`. it reads which groups are sensitive from the encrypted content, not from the index, which my user can edit. and before calling gpg it checks that only root can modify the binary and every directory above it. on a mac that rules out homebrew's gpg, because `/opt/homebrew` belongs to the admin user.

there's no separate user. the program is root only while it decrypts and decides, and before running the command it drops back to my user with `setpriv` on linux or `sudo -u` on macos.

![diagram in two bands, my user at the top and root at the bottom. the agent calls secret-run, which goes through sudo to the privileged program; that program reads the key, may open a dialog, and drops back to my user to run the command with only the requested groups.](https://joeltaylor.business/_next/static/media/flux.en.2iqhio7yzy1sg.webp)

_the blue line is the only path the value takes. what comes back to the agent is the command's output._

## the approval dialog

a group with `#@sensitive` above it is sensitive. if a command asks for one, a dialog comes up on screen with the scope, what's being asked for and the reason the agent wrote, and the command waits until i answer.

the program doesn't trust the display variables, because an ssh session that exports `DISPLAY=:0` reaches a screen that might have nobody in front of it. on linux it asks `loginctl` whether the graphical session is local, active and unlocked. a dialog behind the lock screen would be answered by whoever unlocked it, not necessarily me. the dialog waits 60 seconds, because the agent's bash tool cuts commands off at 120.

a yes lasts 15 minutes. since october, each use restarts the countdown, up to a maximum of 12 hours from the yes. that maximum is a constant in the root program, and nothing running as me can raise it. every verdict goes to the system journal, the only log the requesting process can't rewrite.

## when saying no did nothing

`secret-approve` approves in one go the groups a task will need. until 17 september it ended like this:

```bash
if secret_helper gate "$scope" --motive "$motive" "$@"; then
    exit 0
fi
rc=$?
```

it looks right, but `$?` reads the status of the `if`, and an `if` with no `else` whose condition fails exits with 0. everything came out as a success, and the error messages below it never printed.

an agent approving groups ahead of time got a yes every time i clicked no. the value didn't leak, because the next `secret-run` went through the gate again, which either remembered the no for 30 seconds or asked again. but the agent thought it had permission. its skill told it to stop and ask me when it got a 77, the denied code, and that 77 had never reached it.

it had been like that since the first public release. a new `secret-approve` test that was checking something else exposed it. the correct version:

```bash
rc=0
secret_helper gate "$scope" --motive "$motive" "$@" || rc=$?
[[ $rc -eq 0 ]] && exit 0
```

## seven changes in one day

on 17 september i merged seven changes. three of them changed how the tool is used.

the group is now mandatory. before, a `secret-run` with no group handed over the whole scope, and the dialog had to ask about every sensitive group at once. the agent's skill already told it not to do that, but a written rule only holds while the agent remembers it mid-task, and one the tool enforces doesn't depend on that. now you have to type `--all-groups`, and the error lists the read-only roles that don't need approval.

writing no longer asks about everything. saving a new key used to ask for approval of every sensitive group in the scope, and that's how you learn to click yes without reading. a write returns no value, and now it only asks about the group the key goes into.

and `secret-ask`. i'd often ask the agent for the command to add a secret and paste the value into it, and then the value sat in the shell history, in `ps` and in the conversation. now the agent only says where it goes, and a dialog comes up with a masked field. what i type goes from the dialog to the encrypted file without passing through anywhere else.

## what it doesn't do

the readme says so in its first section. the tool changes how an agent gets to a secret, but it won't stop one that wants to take it. a process running as me can `secret-run` any unmarked group and send the value wherever it likes, with no dialog. it can rewrite the commands in `~/.local/bin`, which are mine. and after a yes, the group stays open for the whole window.

it's hygiene and visibility, not containment. the careless path becomes safe, and the deliberate one makes noise. if a credential has to stay out of an agent's reach, the agent has to run as another user, or the credential has to live on another machine.

## bash 3.2 and gpg

the root program has to work with the bash 3.2 that macos ships at `/bin/bash`, the only bash on a mac that root owns. so there are no associative arrays, no `mapfile` and no name references. where the usual bash 3.2 workaround would be an `eval`, there are global variables, because a value with a quote, a backslash or a dollar sign would come out of an `eval` altered.

gpg provides the symmetric encryption, `AES256`, and nothing else. a decrypted scope is an ordinary `.env` with metadata in `#@` comments, so with the key and a `gpg --decrypt` you can recover everything without the tool.

gpg also brought the subtlest bug. the key was 32 random bytes, and gpg reads the passphrase file as text and stops at the first newline. one key in eight contained one. with a newline at byte 10, that left 80 bits of entropy instead of 256, with no warning. ci found it when it hit the 0.4% case, a key that starts with a newline and leaves the passphrase empty. now the key is base64, and the test generates 64 of them instead of one.

on 17 september it was about 2,500 lines. on 8 october, on main, it's 4,133 lines of code not counting comments or blank lines, plus 2,023 of tests, which run on two ubuntu machines and two macs on every change. the root program's file is 3,036 lines, comments included. in the first version it was 757.
