Skip to main content

A secrets CLI that never prints values

Hayden Westbrook
security cli node ai-agents

When an AI coding agent deploys something, it eventually needs to put a secret somewhere. Copy an API key into a server’s .env, rotate a signing secret, check that two environments agree. The obvious commands for all of that are cat .env, ssh host cat file and echo $KEY. Every one of them prints the value, and once it is printed it lives in the agent’s context, the transcript and whatever logs sit behind them. Shell history has the same problem for humans: a key pasted into a command line stays in the history file.

I wrote secrets, part of my agentic-kit repository, to give the agent a sanctioned path instead. It is plain Node ESM, needs node 18 or newer, and has no dependencies.

The shape of it

secrets mv KEY... --from <loc> --to <loc> [--as NEWKEY]
secrets gen KEY... --to <loc> [--hex N | --b64 N | --alnum N]
secrets check KEY... --in <loc> [--in <loc>]... [--pairs]
secrets keys --in <loc>
secrets policy

Each command prints exactly one JSON object and exits 0 on success, 1 on refusal or failure, 2 on bad arguments. There is no --print or --show flag, and no code path hands a value back to the caller. That was the main design rule: if a flag could print the value, an agent would eventually find it.

A location is a prefix plus a target: a local file, a file over ssh, a Docker container’s environment, the macOS clipboard, a hidden prompt on the terminal, or a macOS Keychain item. mv reads from one and writes to another. gen creates a random value (hex by default, 32 bytes) and writes it straight to the destination. When the destination is an ssh location, gen runs on the remote host, so the value never reaches my machine at all. keys lists key names, never values.

Comparing without seeing

The question agents ask most is “do these two places agree?” check answers that with metadata only: whether the key is present, its length, its shape (hex, base64, alphanumeric or other), and a six-character fingerprint, which is the first six hex characters of the value’s SHA-256. If the fingerprints across the given locations match, match is true.

For keys that should always be equal, such as the same token under two names, you can declare pairs in the policy file and run check --pairs. That path compares full hashes internally and prints no fingerprints at all, only in-sync, out-of-sync or not-present.

The policy file

The trust boundary is a single JSON file, ~/.config/secrets/policy.json, that only the human writes. It lists the hosts that may be used over ssh (allowHosts), the path globs that are allowed or always denied, extra Keychain services, and the pair rules.

Some defaults are deliberately strict:

  • With no policy file, only local files under the current directory or ~/.env* work. Remote hosts and --pairs are refused, with a reason that points at the policy file.
  • A malformed policy file fails closed. Everything is refused.
  • A built-in deny list cannot be removed by any policy: .env.production, production.env, *.pem, id_rsa and id_ed25519, and anything under .ssh/.
  • Any path, container name or Keychain service with a prod or production segment is also refused, unless a human has exported SECRETS_ALLOW_PROD=1 in their own shell for one command.

The last rule is the one I care about most, and it is written into my global agent instructions: if secrets refuses, the agent stops and tells me. It does not set the override, edit the policy, or find another tool that gets around the refusal.

Paths are resolved with realpath before the policy check, so a symlink cannot lead out of an allowed directory. Remote symlinks are refused outright.

Keeping values out of the corners

Leaks usually happen in the corners, not the main path, so a good part of the code is about those:

  • Values travel to remote hosts on ssh stdin, never in argv, so they do not show up in ps.
  • An output scrubber replaces any value the process has touched with *** in every error and log string.
  • The audit log records the operation, key, locations, fingerprint and outcome, never the value. It is written with mode 0600.
  • Env-file edits replace only the matching line, keep its quoting and trailing comment, write atomically through a temp file and rename, and make one timestamped backup per run before the first change. Duplicate active keys and values with newlines are refused rather than guessed at.
  • When the clipboard is a source, it is cleared right after the read. When it is a destination, a detached helper clears it after 45 seconds, but only if its hash still matches the value, and the hash goes in on stdin.

Threat model and limits

The README says this plainly and I will repeat it: this is a sanctioned path, not a sandbox. It makes the safe route the easy one. It does not stop an agent that wants the value and has other tools. That agent can still run ssh host cat file or cat an env file. So the tool is only half of the setup. The other half is agent permission rules that deny those direct routes.

The policy file is the real boundary, so the agent must not be able to write it. A six-character fingerprint leaks a few bits, which is nothing for a random 32-byte secret but not something to lean on for a short password or a PIN. And nothing here helps if the machine, the remote host, the clipboard manager or the terminal is compromised, or if a secret reaches the agent some other way, such as through a file it reads or test output.

The Keychain locator has one honest weakness of its own: the first read triggers a macOS prompt, and that prompt is useful isolation because the human decides. Clicking “Always Allow” for the security binary would remove the only gate.

Tests

npm test runs node --test over four files and currently reports 83 passing tests. All I/O is injected (process spawning, the filesystem, the tty, the clock, the random source, the clipboard clear), so the tests need no network, real clipboard or real Keychain. A fake ssh runs the actual generated remote script through a local sh -s, which means the remote code path is exercised for real. The most useful file is the leak test: it feeds in a sentinel value and asserts that it never appears in stdout, stderr, the audit log or any argv.

That test is the one I would copy into any similar tool. It turns “never print the value” from an intention into something that fails the build.