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

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 generates the new password, writes it into the -encrypted store in Git, runs ALTER ROLE in the database container, updates the app’s , 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 , plus the API key for the model server on Zeus, turned up in a ’s . 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 and is the only process that ever holds a secret.

Diagram of the stile boundary. On the left, inside a dashed box labelled agent user, untrusted, a coding agent calls the stile CLI, which has no get, show, reveal, exec or export command. A dashed vertical line marks the Unix socket, mode 0660 root:stile-access, where the kernel names the caller with SO_PEERCRED. An arrow labelled rotate id crosses to stile-brokerd, which runs as root and reads a root-owned registry.toml on every request. An arrow labelled booleans, stage names returns to the CLI. Orange arrows, marking where secret bytes travel, run only from the broker to four targets on the right: the SOPS store in Git, a pre-reload hook that runs ALTER ROLE through psql stdin, consumer files with a declared owner and mode, and a verification probe that sends the bearer token in a 0600 header file.
The caller controls one string, a logical id. Everything to the right of the socket comes from the registry.

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

StageWhat the broker does
GenerateAt least 16 random bytes from a cryptographic RNG, hex or URL-safe, held in broker memory
StoreEncrypted backup of the old file, then sops encrypt in place, then decrypt and compare. Any failure puts the old bytes back.
Pre-reloadDeclared hooks, such as ALTER ROLE … PASSWORD sent to psql on stdin inside the declared container
DeployWrites one key in a dotenv file or a whole raw file, then sets the declared owner and mode
ReloadDeclared commands only, run as declared users through runuser
VerifyHTTP probes against declared URLs. A 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 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:

PolicySecretsWhat stile does
auto8Generates, stores, deploys, reloads and verifies
provider-assisted1Takes a value the provider issued from a hidden prompt, then deploys it the same way
manual1Refuses. The change needs coordinated steps outside the machine.
forbidden3Refuses. 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’ 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-access could reach the socket. systemd creates the runtime directory as root:root, so the broker now changes its group at startup.
  • sops encrypt failed with “no creation rules”. sops looks for .sops.yaml by 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 -d returns 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 there first, and the broker, running as root, would write the secret through it. Staging files now get a random name and are created with O_EXCL and O_NOFOLLOW at mode 0600.
  • The tracked SOPS file in a Git checkout is often mode 0644. For a moment during sops encrypt --in-place, plaintext sat in a world-readable file. The broker now tightens the mode to 0600 before 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 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 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.

Open chat

Interested in working together? Reach out.

Strategy, architecture, and implementation — from workflow to production.

© 2026 Liam Woodleigh. All rights reserved.