← all posts
post №19·Aug 18, 2026·11 min read

how we made agent enforcement sub-millisecond

Enforcement used to run as a fresh process per tool call: 700ms of runtime boot before a single policy saw the command, and no way to tell a working install from a broken one. Replacing it with a Rust daemon and a warm worker took p50 to ~0.7ms and, for the first time, made fail closed a guarantee we can state in one sentence.

failproof ai does runtime enforcement for coding agent harnesses: Claude Code, Codex, Copilot, Cursor, and eight others. Runtime enforcement means the check happens synchronously, in the execution path, before a tool call runs, not after the fact in a log somewhere. Before a harness runs a command (a force push, an rm -rf, writing a file that leaks an API key) failproofai evaluates it against policy first and can block it outright.

For most of its life, that enforcement ran as a brand new process, spawned fresh for every single tool call a harness made. That was simple, but it had two problems: it was slow, and worse, if that process ever failed to start, the harness just kept going with no enforcement and no warning. We could not tell a working install from a broken one from the outside. The fix was to stop starting a new process per call and instead run one long-lived daemon on the machine, something we can actually check the health of, and that stays alive long enough to also run scheduled local audits and keep policies in sync with the cloud. This post explains why, in that order.

How it worked before

Every harness supports the same basic hook: before running a tool, run this command, hand it a JSON payload, read the verdict back.

before · one process per tool call

The harness spawns a fresh failproofai process for every tool call. The process spends about 700 milliseconds booting a JS runtime, reading three levels of config and registering more than forty policies before any policy sees the command. It returns allow or deny, then exits, and the next tool call pays the same cost again.

This is a fine way to start a project. Nothing to install as a service, nothing to keep alive, nothing that can leak memory or go stale. It shipped, and it caught real problems. But it broke down in three specific ways.

Three problems

It was slow. Booting a JS runtime cost around 700ms, before a single policy even looked at the command. On top of that: reading three levels of config, registering 40 built-in policies, and, if the team had custom policies, rewriting their imports and writing them out to temp files just to load them. All of that ran again, from scratch, for every tool call in a session that might have hundreds. People do not keep tools that add a visible pause to every command.

It could not fail closed. Runtime enforcement has to be able to say "if I can't evaluate this, block it." We could not offer that, because nothing was in a position to notice a process had failed to start. If the runtime moved off PATH, if a vendor changed its hook config schema, if the binary was simply missing, the harness just proceeded with zero enforcement. No error, no log line, nothing. The only sign that enforcement had stopped was the absence of something that was already invisible by design.

There was nowhere to keep anything running. Two things we needed, syncing policies from a central server and periodically scanning session history, both require something that stays alive between calls: a poll loop, a schedule, a cursor into what has already been uploaded. A process that lives for 40 milliseconds cannot hold any of that.

The daemon

The fix is one process on the machine, failproofaid, written in Rust, that stays running and supervises a warm worker that does the actual policy evaluation in TypeScript, unchanged from before.

after · one daemon, one warm worker, three long-lived jobs

The agent harness spawns a thin failproofai client per tool call, which talks to the failproofaid daemon over a Unix socket. The daemon talks to a warm Node worker over a second socket. Off the hot path, the same daemon also holds three long-lived jobs: a collector tailing session files, an audit lane holding a wall-clock due date, and cloud sync pulling policy updates.

Three choices worth explaining:

The policy engine did not move. It is still the same TypeScript code, just running inside a process that does not exit between calls instead of a fresh one each time. Rewriting the policy logic and changing the process model at the same time would have meant two unproven things shipping at once.

Rust is for the supervisor, not for speed. What it buys is a single static binary with nothing to locate on PATH (the exact thing that broke before), a real file lock so two daemons can't fight over one socket, and a process boring enough to stay up for weeks while the worker underneath it gets restarted.

The worker gets pre-warmed the moment the daemon starts, before any real request arrives, so the 700ms startup cost happens once at boot instead of on the first hook call after every restart.

The wire between failproofai and failproofaid

failproofai (lowercase, no d) is the thin part now. It is what a harness actually spawns on every tool call, and almost all it does is talk to the daemon and relay the answer.

the wire · 150ms to scale, 30s not

The harness spawns the thin failproofai client, which connects to the daemon over a Unix socket within a 150 millisecond budget and sends one length-prefixed JSON message: four bytes of length, then that many bytes of JSON. The daemon relays it to the warm worker over a second socket, under a separate 30 second evaluation budget that is drawn with a scale break because it does not fit the tape. The verdict returns the same way, and the client exits with a code and stdout shaped for that harness.

A few things about that wire are deliberate:

Every message, in both directions, on both sockets, is the same shape: a 4-byte length prefix followed by that many bytes of JSON. ping/pong is a liveness check. hook carries the raw payload the harness itself wrote, forwarded byte for byte. hookResult carries an exit code plus stdout and stderr. There is also a distinct error type for "I could not produce a verdict at all," kept separate from a real result so the client can tell "ran and decided" apart from "never got an answer," which is exactly the distinction fail closed depends on.

The daemon talks to the worker over its own second socket rather than the worker's stdin and stdout, because a custom policy is arbitrary code a user wrote, and a single stray console.log left over from debugging would land on stdout and corrupt a shared framed channel. Keeping the worker's actual stdout free of protocol bytes means one careless print statement stays a cosmetic annoyance instead of desyncing every request behind it.

The socket itself sits in a directory only the daemon's own user can read, and every connection is checked against that same user before anything is parsed. That is a second layer, not the only one: anything already running as that user could reach the socket regardless, so it stops a different user on a shared machine, not the same user misusing their own daemon.

And there are two separate timeouts on the client, not one, because they answer two different questions. Roughly 150ms just to connect, since a local socket that cannot accept a connection that fast is not healthy and a dead daemon should fail fast rather than add latency to every command. A separate 30 seconds once connected, to cover the actual evaluation, since a custom policy is allowed to take up to 10 seconds and the worker handles one request at a time. Collapsing those into a single tight budget would make a slow but correct answer indistinguishable from a dead daemon, and deny a command that was never actually a problem.

How evaluation stays under 5ms

A warm worker fixes the biggest cost (booting a runtime), but it does not automatically make what runs inside it fast. Config still gets read from three files on every single call, and every enabled policy still gets registered fresh each time, not just once at startup. What actually keeps evaluation quick is narrower than that: most policies never touch the disk or the network, and the few that used to now reuse an answer instead of re-fetching it.

inside the warm worker · what each path actually costs

Matching happens first, so only policies registered for this event and this tool run at all. Most policies then do a plain in-memory check against parsed argv, a regex or JSON fields, costing zero syscalls. The few that need live ground truth, such as the current git branch, check whether the cached answer is still valid using the modified time of .git/HEAD, costing one stat call, and only shell out to a git rev-parse subprocess after a real checkout.

Two things do the actual work. First, matching happens before any policy function runs, so a PreToolUse call on Bash only ever reaches policies registered for that event and that tool. A session full of file edits never pays for the policies that only care about a git push. Second, the handful of policies that need real information, like which branch you are on, cache it, invalidated by something cheap and correct rather than a guessed expiry. A plain file stat on .git/HEAD's modified time tells you for free whether the branch changed, so the actual git rev-parse subprocess only runs again after a real checkout, not on every call.

Measured directly rather than assumed: calling the same function the warm worker calls, against this repo's own real policy set, warmed up first.

MeasuredResult
Runs4 runs x 500 calls each
Total calls measured2,000
p50~0.7 to 0.8ms
p99~1.5 to 3ms

Those numbers describe what the daemon controls: process boot, matching, and caching. They do not describe what any single policy's own code does once it starts running, and that part is still on the policy author. A check that is a regex or a parsed-argv comparison against the payload already in memory stays inside the numbers above no matter how many of them run. A check that shells out or calls the network does not, and no amount of warming or caching changes that, because the daemon cannot make somebody else's server answer faster.

A few Stop-time policies are exactly that case on purpose: checking whether a pull request exists, or whether CI is green, means shelling out to gh and possibly reaching the network, because the entire point of asking "is it safe to finish now" is that a cached answer could be wrong. Those run once per turn rather than once per tool call, so the trade is deliberate: keep the check in front of every command inside the budget above, and let the one check that runs at the end take however long its own downstream call actually needs.

What fail closed actually buys you

Once a machine finishes setup, every way of not getting an answer from the daemon (socket unreachable, protocol mismatch, timeout) becomes a deny instead of a silent pass-through.

the guarantee · either the evaluator answers, or the call is blocked

A tool call arrives and the client asks whether the daemon is reachable and answers in time. If yes, the real policies are evaluated and the verdict is allow, deny, or instruct. If no, the call is denied, failing closed, with no in-process fallback.

There is deliberately no fallback to evaluating in-process if the daemon is down. The reasoning, from the code itself:

A second policy engine reachable by breaking the first is not a guarantee, and a machine where stopping one service silently disables every guardrail is not a guarded machine.

That is the actual point of the rewrite. Before, we could not state a guarantee at all. Now we can state it as one sentence: on a configured machine, either the evaluator answers, or the tool call is blocked.

Shipping session logs to the cloud

Separate from enforcement, and deliberately kept off the same threads that answer hook calls, the daemon also runs a small pipeline that tails each harness's own session files and ships them to failproof ai cloud, for the dashboard and for audit. This is off by default: it needs an explicit ingest key, and shipping actual session content (not just counts) needs a second, separate opt-in, because a transcript can contain prompts, file contents, and whatever a person pasted into a terminal.

the collector · two guarantees, split at the spool

Above the spool: the tailer reads each session log with a cursor keyed by inode, redaction applies fixed deterministic patterns, and the spool writes to a temp file, fsyncs, and renames, so a half-written batch is never read. Below the spool: a filesystem watcher notices batches fast but can miss one, while a periodic sweeper is the actual delivery guarantee. Both feed one uploader that retries in place and posts to the failproof ai cloud with a bearer token.

Each step exists because of a specific way the alternative loses data. The cursor is keyed by the file's device and inode, not its path, because some sources rotate a session file by renaming it, and a path-keyed cursor would either think a rotated file is brand new and re-ship the whole thing, or carry a stale offset onto the fresh file and skip its first lines.

Redaction happens before anything is written to disk, and it is a fixed set of patterns, not a model call: the server on the other end deduplicates uploads by hashing their content, so the same input has to redact to the exact same output every time, and anything non-deterministic would defeat that and cause endless re-uploads.

The spool write itself is atomic (write to a temp file, fsync, then rename into place), so nothing downstream ever reads a half-written batch. And two independent things notice a finished batch: a filesystem watcher, for speed, and a periodic sweep of the whole directory, which is the one that actually guarantees delivery, since a missed filesystem event, a daemon that was offline when the file appeared, or a filesystem that does not report events at all are all real, ordinary ways to lose a watcher notification.

Delivery itself retries a failed batch in place rather than deleting it, and does not trust a 2xx response blindly: the server can accept a request and still silently skip individual malformed lines inside it, so the response body is read to know what was actually kept. And the number of uploads allowed to run at once here is small on purpose, well below what a standalone uploader would use, because this process is also the one answering hook calls, and every upload in flight is CPU not going toward a tool-call decision.

What a scheduled scan emails

The daemon's audit lane, mentioned above, is what makes a scan run on its own: it keeps a wall-clock due date on disk (not a countdown timer, since a laptop that sleeps for the night would never reach a countdown), and when a scan is due it spawns failproofai audit --scheduled as its own low-priority subprocess. That part needs no account and sends nothing anywhere; results land on the local dashboard exactly like a scan you run by hand.

Emailing what that scan finds is a second, separate opt-in, and it happens entirely inside that same short-lived child process, never inside the daemon itself.

the scheduled scan · the token lives in the child, not the daemon

The daemon's audit lane sees a wall-clock due date pass and spawns a low-priority child process, which scans session history and saves results locally. Only if emailed reports were opted into and the person is signed in does the child build a digest of what would have been blocked, send it to the api-server, and save the next window start locally either way. Otherwise nothing is sent, with no network call and no log line, and the local scan is unaffected.

Two things about that shape are deliberate. First, the digest is narrow: only the findings that would actually have been blocked, or that caught a secret before it reached the model, not the full scan. Second, the child process holds the login token, not the daemon, because refreshing that token is theft-detecting (using an already-spent refresh token revokes every session that person has), and the audit path already runs one entry point at a time through a lock. If the daemon also held that same token, the two would have to coordinate across processes, and losing that race signs the person out everywhere with no clue why.

The window itself is tracked as an explicit watermark rather than "since this process started," and it is written back locally regardless of whether the server actually sent an email. That way, a digest the server holds back does not quietly widen the next one, and nothing gets double-counted or dropped just because one send did not happen.

None of this is allowed to affect the scan it rides on. A token that expired, a network that is down, or an api-server having a bad day all fail silently from the scan's point of view, since the scan already finished and is already visible locally by the time any of this runs. And a person who never opted in has to be unable to tell this code exists at all: no network call, no log line hinting that a report was even considered.

What it enabled

Speed. The 700ms cost happens once, at daemon startup, not on every tool call.

A real fail-closed guarantee, for the first time, because something is finally alive long enough to notice its own absence.

Continuous local audits, with an emailed digest if you want one. A one-shot process had no way to know a week had passed. This one holds a wall-clock schedule and can act on it unattended. See above for how the scan and the email are kept apart.

Session logs reaching the cloud dashboard, without slowing down enforcement. Same reasoning as the schedule: something has to stay alive to hold a cursor and retry a failed upload. See above for the pipeline.

Centrally managed policies. An organization can push a policy update and every enrolled machine picks it up on its next poll, with no reinstall.

What it cost

Fail closed cuts both ways. Every bug in the daemon or worker now shows up as a denied command instead of a silent miss. That is what we wanted, but it meant a string of real bugs had to get fixed before this was safe to ship as the default. A few examples:

BugEffect
Accepted sockets stayed in non-blocking mode on macOSEvery hook call on macOS failed closed
Worker was started lazily, on the first requestThe first call after every restart failed closed even on a healthy daemon
An analytics call was awaited before returning a verdictA slow network turned into denied commands on a perfectly healthy daemon

Each of these was caught by running the real daemon end to end in Docker, not by a unit test, because a mocked network or a mocked socket cannot reproduce a timing bug.

The short version

The one-shot process was not a bad idea, it was just missing the one property runtime enforcement cannot skip: being able to tell whether it is actually running. A daemon is what turns "a subprocess probably ran" into something you can check, and once you have a process that stays alive, you may as well let it do more than answer one question at a time.

failproof ai enforces your policies at runtime, on every tool call, in under a millisecond →