Agents that never see a secret
Published
Our agents write code and run commands. Some of that work needs keys: a database password, a token for a cloud account. We want the agent to use a key without reading it. A key that enters an agent’s context stays in its transcript, and a transcript is kept. By 14 October 2026 three layers stood between our agents and our secrets. None of them is enough alone, so we built all three.
Layer one: the tool knows when an agent runs it
Our command line, wapps, decides at start whether a person or an agent runs it. It is in agent mode when the standard input is not a terminal, or when the environment carries one of a list of agent and build markers.
A person can turn agent mode off with WAPPS_AGENT_MODE=0, but only from a real terminal. An agent has no terminal, so it cannot switch itself to the human mode.
Each command has a policy. Some are allowed in agent mode. Some need a human terminal. Some are refused because they print a secret value or cannot be undone. Reading one secret in plain text is refused. Deleting a secret is refused. Printing the whole environment is refused, while writing it to a file is allowed. A policy name that the check does not know is refused.
One command had a trap. Its real refusal was a check inside it, next to a label that looked like the refusal but was read by nothing. A reader could delete the check as a duplicate. On 26 August we deleted the dead labels and gave the check a test that names the refusal.
Layer two: the output is scrubbed
An agent still needs to run a program with a secret, for example a migration with a database password. The exec command does this. It reads the secrets, puts them into the child program’s environment and runs it. wapps itself never prints a value. Only the child prints, and its standard output and standard error go through a scrubber. Every full occurrence of an injected value becomes ***.
The hard part is the chunk boundary. A program writes in pieces, and a secret can be split across two reads. The scrubber keeps back the end of a read when that end could be the start of a value, at most one byte fewer than the longest value. Everything before it goes out at once, so an interactive prompt still reaches the terminal. Values shorter than four bytes are not scrubbed, because replacing a two-letter string would break unrelated output. When a real-looking value is skipped, the tool writes one warning with the count, never the value.
We first wrote the scrubber in Go. On 26 August we ported it to Rust. Two hand-written versions of boundary logic tend to differ only when a real secret falls on a boundary. So both versions were tested against 27 cases from one shared corpus: splits into two chunks, three chunks and single bytes, a character split in the middle, and the floor.
The scrubber file says what it is: a layered mitigation, not cryptographic enforcement. The child holds the plain text and can still send it elsewhere. What the scrubber protects is the transcript.
Layer three: a sandbox for each job
A local daemon starts each agent’s job with a provider’s command-line tool, in a checkout of its own. Before 14 October, the tool’s own permission prompts held the job. On that day we moved the job into a macOS sandbox, made from a profile that the daemon writes for each job. The kernel holds the job now. A tool call that the provider does not report, a shell command or a child process meets the same profile.
Three of that day’s profile changes matter here. The first change named what a job may not read: our credentials, the daemon’s state, the key folders. That left every credential it did not name readable. So the second change refused every read under the home folder by default. A job reads there only its checkout, its temporary folder and what its provider was measured to need to run. The same change refused hard links, Unix sockets outside the job and signals to processes outside the sandbox. When a job ends, every process still in its sandbox is killed, also one that left its process group. A job whose sandbox still holds processes after ten seconds fails, and its checkout is kept.
The third change closed paths that act for the job outside the sandbox. A job cannot use the preferences daemon, reach the pasteboard, send an Apple Event or open a terminal device.
After the three changes, a job writes files only in its checkout and its temporary folder, and never in the checkout’s git, agent or tool configuration folders. One exception came later that day: a job for one provider may update that provider’s login file.
If no sandbox can be applied on a machine, the daemon refuses a real job before it reserves anything.
What is not covered
The sandbox file lists what it does not cover. Jobs run as our own user. The kernel shows such a process the arguments and environment of our other processes, so a token in another process’s environment is readable. Only a separate user would close this, and we accepted it for now. A job for one provider reads the keychain file and reaches the keychain service, because its login needs them. Files outside the home folder that the profile does not name stay readable. The network is open, because the provider needs it.
The claim in the title is narrow. Our command line never prints a secret to an agent, the output of a program that holds one is scrubbed, and the job that runs an agent cannot read the credentials of our command line or our key folders. The list of what is not covered is part of the design.