Engineering ·
How Styx keeps coding agents away from production
Agents run as you, with your logged-in CLIs. This is how Styx puts a person between them and production, where that stops, and a bug I fixed the day before launch.
Every coding agent I use runs as me. Claude Code, Codex, Gemini CLI and Cursor’s agent start with my PATH, my files and my logged-in CLIs. If I’ve run vercel login, gcloud auth login or supabase login on this machine, the agent has too. Nothing in that setup knows the difference between vercel deploy and vercel deploy --prod, or between a supabase db push to a scratch project and one to the database people are using.
I run several at once, across several projects, and I don’t read every command. One wrong --prod and it’s done before I look.
Styx is the app I built to run those agents. This is how the part between an agent and your deploy targets works, and where it stops. The code is Apache-2.0; I’ve linked the files.
The shape
A command like supabase db push takes this path:
- A shim named
supabase, first on the agent’sPATH, catches it. - The shim asks a local broker. Its connection is bound to one agent session.
- A pure policy function decides: approve, ask or deny.
- On ask, the agent waits and you get a request, in its chat and as a notification.
- You grant it for a set time. Production writes, deploys and deletes need Touch ID, Windows Hello or your system password first.
- The real command runs with that grant’s credentials in its environment, and nothing else.
- Every step is written to an append-only, hash-chained audit log.
Shims on PATH
When Styx starts an agent, a directory of small scripts goes first on its PATH: vercel, gh, aws, gcloud, supabase and ssh. This is the vercel one, from shim-service.ts:
#!/bin/sh
# Styx shim: authorises the command through the grant broker, then execs the real vercel with injected credentials.
exec env ELECTRON_RUN_AS_NODE=1 "$STYX_EXE" "$STYX_CLI" wrap vercel "$@"styx wrap connects to the broker, calls exec_authorize with the tool, its arguments and the working directory, and waits up to ten minutes for an answer. On yes, it starts the real binary with the grant’s environment added. On no, it prints styx: access to vercel was not granted and exits 77. When it finishes, the shim reports the exit code for the log.
The broker reads the command to decide what it needs. ls or view is a read, deploy a deploy, delete or drop a delete. Anything it doesn’t recognise counts as a write, so a misread command is treated as more dangerous, never less. Agents can also ask up front through an MCP tool, request_access.
A broker bound to one session
The broker runs in Electron’s main process on a per-user Unix socket, in a directory that must be yours with mode 0700 (a named pipe on Windows). Each agent session gets its own token in its environment. Styx stores only its SHA-256 and compares in constant time. After the handshake a connection belongs to that one session: it can see its own grants, or always grants on its own project’s targets, and nothing else. New requests are limited to five a minute per session.
Policy is a pure function
The decision is made in packages/core, which has no Electron, no I/O and no clock of its own. CI enforces 100% test coverage on it. Its shape:
// simplified: packages/core/src/policy/engine.ts
evaluate(input: {
target: { id; provider; env; policy };
scope: Scope[]; // read | write | deploy | delete
session: { id; mayRequestTargets } | null;
appRules: Policy[];
projectRules: Policy[]; // .styx/project.json, after you accept them
persistentGrants: Grant[];
now: number; // time is passed in, never read
}): {
decision: 'auto' | 'ask' | 'deny';
requireMfa: boolean;
maxDuration: Duration;
idleMs: number | null;
policyId: PolicyId | null;
}It stops at the first step that decides: a task not allowed to request targets is denied; a covering always grant answers; a target set to Always allow approves; then your rules, top to bottom; otherwise it asks. Rules from a repo’s .styx/project.json can only make Styx ask more until you accept them, since anyone with commit access can edit that file.
One predicate sits under all of it:
export const requiresMfa = (env: Env, scopes: readonly Scope[]): boolean =>
env === 'prod' && scopes.some((s) => WRITE_SCOPES.includes(s));An auto-approve rule that matches a production write becomes an ask with verification. The grant state machine checks the same predicate again on issue and refuses the transition unless verification happened. The only exception is an always grant you approved earlier, with verification, that covers the request.
Asking you
On ask, the request appears in the agent’s chat, on its task, in the Access screen and as a system notification; answering in one place answers it everywhere. You see the agent, the target, its reason and the scopes. You can untick scopes to grant less, but not add any. Until you answer, the shim is blocked, so the agent is just waiting on a command.
Grants are scoped and expire
A grant is one target, a set of scopes, and a duration: once, 1h (the default), session or always. Once and 1h end an hour after you grant them at the latest, and a built-in rule ends any grant except always after an hour without use. Session grants end with the session, and you can revoke one early. The lifecycle is an exhaustive transition table, tested for every pair of state and event, invalid ones included.
Production needs the OS to say it’s you
On a production target, a grant that includes write, deploy or delete is only issued after the operating system confirms it’s you: Touch ID on a Mac, Windows Hello on Windows, polkit on Linux. A Mac without Touch ID (no sensor, or the lid closed) shows the macOS password prompt instead. If the machine can’t verify at all, Styx refuses the grant rather than skip the check.
What matters is where that’s decided. The window you click in is sandboxed and has no say. GrantService.approve, in the main process, recomputes it from the database:
const needMfa =
requiresMfa(target.env, scopes) ||
target.policy === 'ask-mfa' ||
decision.requireMfa ||
this.unscopedProd(target, scopes);The last term is for providers that can’t narrow a credential to a scope. For Vercel, Supabase, GitHub and SSH, any grant on a production target needs verification, reads included. While writing the docs I also found that STYX_MFA=auto, a switch that fakes the OS prompt for our end-to-end tests, was honoured by release builds. An agent runs as you and could relaunch Styx with it set. Packaged builds now ignore it.
What the command gets
Stored credentials never go into an agent’s starting environment. Styx also drops tokens such as GH_TOKEN, VERCEL_TOKEN and AWS_SECRET_ACCESS_KEY from what agents inherit, so one exported in your shell profile doesn’t reach them. The granted command gets credentials for the life of the grant. On AWS that’s temporary STS credentials with a session policy limited to the granted scopes (unless you connected a CLI profile with no role to assume). On GCP it’s an access token that lasts at most an hour. Vercel, Supabase and GitHub get the token you stored, because they can’t issue a narrower one.
SSH never gets a key file. Styx runs its own SSH agent in-process, holding the key in memory for the grant’s lifetime, and points the command’s SSH_AUTH_SOCK at it. The socket closes when the grant ends.
Secrets stay in the keychain
The secrets Styx holds live in the OS credential store (Keychain, Credential Manager, Secret Service). The database, logs, messages between processes, the interface and .styx/project.json only ever hold a reference of the form styx:v1:<provider>:<targetId>:<kind>. By default it stores none: it reuses your provider CLI’s own login and asks it for a current token per grant.
An append-only, hash-chained log
Every request, grant, denial, use, revoke and expiry is a row in audit_entries, with the actor, the session, the worktree and what triggered it; for a use, that’s the command line, with secrets scrubbed before it’s written. The table can only grow. From the first migration:
CREATE TRIGGER audit_no_update BEFORE UPDATE ON audit_entries BEGIN SELECT RAISE(ABORT, 'audit_entries is append-only'); END;
CREATE TRIGGER audit_no_delete BEFORE DELETE ON audit_entries BEGIN SELECT RAISE(ABORT, 'audit_entries is append-only'); END;A revoke inserts a new row; nothing edits an old one. Each row also carries the previous row’s hash and its own, computed in the same transaction as the insert:
export function hashRow(row: Omit<AuditRow, 'hash'>): string {
const canonical = JSON.stringify(row, Object.keys(row).sort());
return createHash('sha256').update(row.prevHash).update('\n').update(canonical).digest('hex');
}Editing a field or removing a row breaks the chain from that point on; the audit log docs have a short script to check it. The chain shows entries weren’t edited one at a time. Someone who can already write your files could rewrite the whole log and recompute every hash, so keep a copy of the latest hash elsewhere if that matters to you.
Failing closed: a bug I found writing the docs
A project can have two targets for one provider: a Supabase staging project and a Supabase production project, say. When a shimmed command came in, the broker had to pick which one it meant. The rule was: a target with a live grant covering the command won; otherwise --prod meant production; otherwise the first non-production target.
I found the problem while writing the docs, the day before launch, trying to say which target supabase db push is judged against. The command names no environment; it pushes to whichever project the directory is linked to, which may be production. Under the old rule it was judged against staging, so staging’s looser policy, or an open staging grant, could carry a command that changes the production database.
Now the environment is decided first, and not knowing counts as production. BrokerHost.pickTarget:
const flagged = argv.some((a) => a === '--prod' || a === '--production' || a === 'production')
? 'prod'
: null;
const env = flagged ?? adapter.envOfCommand?.(argv, tool) ?? null;
const prod = candidates.filter((t) => t.env === 'prod');
const rest = candidates.filter((t) => t.env !== 'prod');
const pool =
env === 'non-prod' ? (rest.length > 0 ? rest : candidates) : prod.length > 0 ? prod : candidates;
return (
pool.find((t) => this.deps.grants.covering(t, ctx.session.sessionId, scopes) !== null) ??
pool[0] ??
null
);Where the CLI makes it knowable, the adapter says. Vercel does: only a deploy without --prod or --target production, an env command that names preview or development, and a read count as preview. Everything else, from promote to rm, is production. For every other CLI, a command that names no environment is judged against the production target whenever there is one, and only a grant on that target can cover it. A staging grant can’t carry it. The cost is the occasional extra prompt for a command that was meant for staging.
Where it stops
Styx is a guardrail, not a sandbox. In the words of the security model page:
Agents run as you. An agent can read and write anything your user account can, run any program, and reach the network. Styx doesn’t contain it.
And an agent that goes around Styx isn’t stopped. The shims catch vercel, gh, aws, gcloud, supabase and ssh when they’re run by name. They don’t catch:
- the real CLI called by its full path. If you connected that provider through its CLI, the CLI is still signed in as you and works without a grant. Styx tells agents not to do this, and when Claude Code or Codex does it anyway, Styx posts a warning in the chat saying no grant was asked and nothing was audited. It warns; it doesn’t block;
- other tools from the same providers:
scp,rsync,sftp,gsutil,bq,samandcdkaren’t wrapped today; git pushwith your usual git credentials, or a provider’s SDK or API called with a token the agent found on disk.
For Vercel, Supabase and GitHub, a granted command receives your whole stored token. A command could copy it during the grant, and revoking stops Styx delivering it but doesn’t cancel it at the provider. For production, use dedicated tokens with the least access that works. Styx makes the safe path the easy one and keeps a record of it. It won’t stop an agent that’s trying to escape; if you need that, run agents in a container or VM as well.
What’s next
On the list:
- shims for the other tools the provider adapters already classify (
scp,rsync,sftp,gsutil,bq,sam,cdk); - verifying the hash chain and exporting the log from inside the app, instead of with
sqlite3; - more targets: Azure and Netlify aren’t supported yet.
To check any of this, start with the policy engine and the provider adapters. If your provider is missing, adding a target walks through one adapter end to end: the name, how it connects, how it reads a command’s scope, and the shim. And if you find a way past any of this, please report it privately as SECURITY.md describes, not in a public issue.
Questions and arguments are welcome on GitHub Discussions or at hello@heystyx.com.
Nic