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

i have 156 credentials stored, api keys among them, split between work and personal projects. they live encrypted in 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:
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.

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.

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.

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