AI & Agents · Systems · Developer Tools
stile
A Rust secret broker that lets coding agents rotate, verify and reconcile SOPS-managed credentials without ever receiving a value. Agents send a logical id over a Unix socket; a root daemon does the work from a root-owned registry and answers with booleans.
Active · 2026
Rust · SOPS · systemd · Unix sockets

stile lets a coding agent rotate a credential without ever seeing it. The agent runs stile rotate inbox-zero/postgres-password. A root daemon on the other side of a Unix socket A connection between two processes on the same machine that appears as a file. File permissions decide who can connect, and nothing crosses the network. generates the new password, writes it into the SOPS Secrets OPerationS, a tool that encrypts the values in a config file and leaves the keys readable, so encrypted secrets can live in Git. Anyone holding the matching age or cloud KMS key can decrypt them.-encrypted store in Git, runs ALTER ROLE in the database container, updates the app’s dotenv file A plain-text .env file of KEY=value lines that an application loads into its environment at startup. It is the usual place self-hosted apps keep their secrets., restarts the app and checks that it came back. The agent gets a JSON report of booleans and stage names. No command returns a secret, and the CLI has no code that could print one.
A stile is the step in a fence that lets people climb over and keeps the sheep in. Requests go over. Secret bytes don’t come back.
Why I built it
In September 2026 the whole .env file for my self-hosted Inbox Zero An open-source email app that keeps a copy of a mailbox and uses a language model to file, draft and archive mail in it. Users sign in with OAuth, and the app encrypts the tokens it stores in its database., plus the API key for the model server on Zeus, turned up in a Coding agent An AI model given tools to edit files and run commands, so it can carry out a programming task end to end instead of only suggesting code.’s Transcript The record of a coding agent's session: prompts, files read, tool output, everything. Transcripts are logged, synced and sent to a model provider, so a secret that passes through one has effectively left the machine.. Transcripts get logged, synced and sent to a model provider, so I treated every value in that file as burned.
The quickest way to rotate a dozen credentials is to ask the agent that’s already open. It would generate a password, write it into the .env file, pass it to ALTER ROLE, restart the containers and curl the health check with the new key. Every one of those steps puts the replacement into the same transcript that leaked the original. Cleaning up that way repeats the leak.
I wanted the agent to keep the ability to rotate and lose the ability to read. I wrote the first version on 20 September. It was running on Zeus the next day, and I rotated eight of the leaked credentials through it, the Postgres password and the model server key among them. None of the new values went through the agent.
How it works
There are two binaries. stile is the CLI, and the agent runs it as its own user. stile-brokerd runs as root under systemd Linux's first process and its service manager. It starts each service as a unit and gives every one its own cgroup, and that cgroup tree is what resource limits and oomd's kill decisions hang off. and is the only process that ever holds a secret.
The CLI sends one line of JSON naming an operation and a logical id. The broker asks the kernel who is on the other end with SO_PEERCRED A Linux socket option that tells the server which user, group and process is on the other end of a Unix socket. The kernel fills it in, so the client cannot lie about who it is. and lets in root, an allowlisted UID or a member of the stile-access group. It looks the id up in /etc/stile/registry.toml and does what the registry declares for it. Commands, users, file paths and URLs all come from the registry. Nothing the caller sends reaches a command line, so a caller can’t run anything of its own or aim a credential at a server it picked.
There are six operations: rotate, verify, reconcile, status, list and import-provider-secret. The request types in stile-protocol are a closed enum with deny_unknown_fields, so an unknown operation or an extra field fails to parse and the broker answers with an error. Adding a get would take a change to that crate, and that is the one change I review as a security change.
A rotation runs in this order:
| Stage | What the broker does |
|---|---|
| Generate | At least 16 random bytes from a cryptographic RNG, hex or URL-safe, held in broker memory |
| Store | Encrypted backup of the old file, then sops encrypt in place, then decrypt and compare. Any failure puts the old bytes back. |
| Pre-reload | Declared hooks, such as ALTER ROLE … PASSWORD sent to psql on stdin inside the declared container |
| Deploy | Writes one key in a dotenv file or a whole raw file, then sets the declared owner and mode |
| Reload | Declared commands only, run as declared users through runuser |
| Verify | HTTP probes against declared URLs. A Bearer token An API key sent in an HTTP Authorization header. The server lets in whoever presents it, so anyone who copies the token has the same access. goes in a 0600 header file that curl reads. |
If the hook, the deploy or the reload fails, the broker writes the old value back through the same stages and restarts everything again. If only verification fails, the broker keeps the new value, because the service may still be starting, and reports the operation as an error.
The secret never goes in argv A program's command-line arguments. On Linux any user on the machine can read them for any running process, through ps or /proc, so a password passed as an argument is readable by everyone. or a child’s environment. psql reads it on stdin and curl reads it from a file that is deleted straight after. The broker even strips SOPS_AGE_KEY from the environment it gives sops. Every rotate, verify, reconcile and import writes one line to an audit log with the caller’s uid, gid and pid, each stage’s result and the duration. It records a boolean for whether the value changed and never the value or a hash of it.
A registry entry looks like this:
[[secret]]
id = "example-app/db-password"
policy = "auto"
[secret.store]
file = "secrets/example-app.env" # SOPS file, relative to the repo root
type = "dotenv"
key = "DB_PASSWORD"
[secret.generation]
type = "hex"
bytes = 24
[[secret.pre_reload]]
type = "postgres-role-password" # ALTER ROLE via stdin, never argv
container = "example-app-db-1"
role = "example_app"
[[secret.consumers]]
type = "dotenv-file"
path = "/etc/example-app/env"
key = "DB_PASSWORD"
owner = "example-app"
mode = 256 # 0o400
[[secret.reload]]
type = "run-command"
user = "example-app"
command = "/usr/bin/systemctl"
args = ["restart", "example-app.service"] The most useful answer is no
Every secret in the registry carries a policy, and only auto lets the broker generate a value. Here is the registry on Zeus:
| Policy | Secrets | What stile does |
|---|---|---|
| auto | 8 | Generates, stores, deploys, reloads and verifies |
| provider-assisted | 1 | Takes a value the provider issued from a hidden prompt, then deploys it the same way |
| manual | 1 | Refuses. The change needs coordinated steps outside the machine. |
| forbidden | 3 | Refuses. Rotating would destroy data. |
The three forbidden entries are Inbox Zero’s email encryption key, its salt and an API key salt. Inbox Zero encrypts users’ OAuth The redirect-and-consent flow that lets an application ask a provider such as Google for access to an account without ever seeing the password. The application ends up holding a token, and whoever holds the token has the access. tokens in its database with that key. Replace it and every stored token becomes unreadable. The file that leaked had those three lines in it next to the ones that were safe to rotate, and an agent told to “rotate everything in the .env” would rotate them too. This is what the broker says instead, run against the live broker on Zeus:
$ stile rotate inbox-zero/email-encrypt-secret
{
"status": "error",
"operation": "rotate",
"secret": "inbox-zero/email-encrypt-secret",
"store_updated": false,
"runtime_updated": false,
"services_reloaded": false,
"verification_passed": false,
"message": "policy is forbidden (data-encryption key for persisted DB fields (OAuth tokens etc.); rotation without a re-encryption migration makes existing data unreadable) — rotation refused"
} The check runs before anything else. The broker doesn’t generate a value or open the store.
The provider-assisted secret is the Google OAuth client secret. Only Google can issue a new one, so a person resets it in the Google Cloud console and runs stile import-provider-secret google/oauth-client-secret. The CLI turns off terminal echo, reads one line and sends it over the socket once. That message is the only one in the protocol that carries secret bytes, and it only goes from client to broker.
One key, three model servers
The model server key was the awkward one. Three servers on Zeus use the same bearer token: swift-qwen for swift-qwen3.8-rtx3090, syv-vllm, and llama-server, which doesn’t check the key at all. They share one GPU, so at most one runs at a time. Two clients hold the key as well: my coding agent’s config and Inbox Zero.
The registry declares each server as a backend with its own “am I running” command, which is systemctl --user is-active. On rotation the broker asks each backend in turn, restarts only the one that’s up, and probes it twice. The new key has to get a 200 from /v1/models, and the old key has to get a 401. The second probe uses the stored old value, never anything the caller sends, so it can’t be used to test guesses at a key.
Switching model servers left clients holding stale config, so I added reconcile. It deploys the stored value to any consumer that differs, restarts whatever reads it and verifies, without generating anything new.
What running it taught me
The first version passed its tests and still broke in four ways on the first day on a real machine.
- The CLI hung forever. It read the response until end of file, and the broker keeps the connection open. It now reads exactly one line.
- Nobody in
stile-accesscould reach the socket. systemd creates the runtime directory asroot:root, so the broker now changes its group at startup. sops encryptfailed with “no creation rules”. sops looks for.sops.yamlby walking up from the current directory, and a systemd service runs from/. Every sops call now starts in the repo root.- Every Inbox Zero rotation reported a 502.
docker compose up -dreturns before the container accepts traffic, so the first probe always failed. Probes now retry, eight times, ten seconds apart.
I also had to turn off NoNewPrivileges in the systemd unit. With it on, systemd withholds the setuid capability and runuser can’t switch to the declared users. The unit says why.
Verification is only as good as what the registry declares. The model key’s probe proves the new key works and the old one doesn’t. Some Inbox Zero secrets have no endpoint that tests them directly, so their probe checks that the login page loads. That proves the app came back after the restart and says nothing about the key itself. The report says verification_passed: true for both.
Attacking it before publishing
Before making the repository public on 30 September I went through it as an attacker who is in stile-access and can write anywhere the agent user can. That review found four real problems.
- The broker staged each consumer file under a predictable name ending in
.sb-new, in the target’s directory. Anyone who could write to that directory could put a Symlink A small file whose contents are another file's path. Opening a symlink opens whatever it points at, so a symlink planted where a privileged program writes can send that write somewhere it was never meant to go. there first, and the broker, running as root, would write the secret through it. Staging files now get a random name and are created withO_EXCLandO_NOFOLLOWat mode0600. - The tracked SOPS file in a Git checkout is often mode
0644. For a moment duringsops encrypt --in-place, plaintext sat in a world-readable file. The broker now tightens the mode to0600before writing any plaintext, and refuses a symlink at that path. - The broker handles one request at a time, which rules out two rotations of the same secret racing each other. That also meant a client that connected and sent nothing held up everyone else forever. Requests now have a read timeout and a size cap of 64 KiB, or 1 MiB for an import.
- Error text from subprocesses reached the client’s report and the audit log. It now goes only to the broker’s journal, which root reads.
Writing this page found two more, and I fixed both. The README said the protocol types made it impossible to add a field that could carry a secret. That isn’t quite true. Responses have two free-text fields, the report’s message and each stage’s detail. The broker fills them only from registry identifiers, exit codes and fixed strings, and the tests check them, but the type system doesn’t stop a future change from putting a value in one. The README and the protocol docs now say exactly that. The threat model also said every operation was audited, but a rotation the policy refused returned before reaching the audit code. The refusal above left no record of who tried. It does now, and the tests for both refusal paths check the audit log.
Testing for leaks
The 42 tests include 24 integration tests, most of which start the real broker against fake sops, curl and runuser scripts. The fakes record every argument they receive. The store starts out holding a sentinel, OLD-SENTINEL-4f3c2b1a-not-a-real-secret, and after each operation the suite searches ten channels for the old and new values: the audit log, four argv logs, curl’s decisions, the broker’s stdout and stderr, and two reload logs. It also checks that no curl header file is left in the work directory, and that every key in the JSON response is on a fixed list of fifteen.
The adversarial tests send oversized requests, operations named reveal, decrypt and exec, extra fields, an id of ../../etc/passwd, a rotate that tries to supply its own value, and a connection that never sends a byte. They plant a symlink at the store path and make the socket directory world-writable. The broker has to reject each one and keep serving. The whole suite runs in about two seconds, most of it one test waiting out a stalled connection.
What it doesn’t protect against
Being able to rotate a secret is real power. Anyone in stile-access can rotate any auto secret whenever they like and log every Inbox Zero user out. With import-provider-secret they choose the new value. Group membership grants every operation on every secret, and per-secret permissions are on the list for a later version.
Whoever writes the registry is trusted completely. A malicious registry could declare a verification URL that logs the bearer token it receives. The broker would never hand the token to the caller, but it would send it where the registry said.
Root on the machine, the broker process, the age A file-encryption tool built on public-key pairs. Anything sealed to the public key can only be opened with the matching secret key, and SOPS can keep its encrypted values closed with age keys. key and the kernel are all inside the boundary. If any of them is compromised, stile protects nothing. It keeps secret buffers in ordinary Rust strings and doesn’t wipe them. It runs on Linux only, for services on one machine. SOPS stays the source of truth, so after a rotation I commit and push the new encrypted file like any other change.
It is v0.1.0 and nobody else has audited it. The Threat model A written list of what attackers are assumed to be able to do and what the system promises to stop. Every other security claim is read against it: outside the listed attackers, nothing is promised. lists each operation, what it lets a caller do, and the file and function that enforce each rule.
Using it with an agent
The repository ships an agent skill, skills/secret-rotation, that tells the agent to treat anything that appeared in a transcript as burned, to never try to read a value, and to stop and ask when the broker refuses. The skill is advice. The socket permissions and the missing get command are what stop the agent. When a leak turns up now, the agent runs stile list, rotates what it’s allowed to, reports what needs me, and never sees a secret.
git clone https://github.com/liamwh/stile && cd stile
cargo build --release --locked
# or download a Linux x86_64/aarch64 binary from the GitHub release Setup is an access group, an age key readable only by root, the registry, a daemon config and the systemd unit. The README goes through each step.