Supervising AI agents with seccomp-BPF and ptrace

grith team··6 min read·engineering
grith is live

A security proxy for AI coding agents, enforced at the OS level. Install grith and put a real boundary around your agent.

grith puts a supervisor underneath AI coding agents: security-relevant syscalls are intercepted and scored before the kernel executes them. That sentence hides all the interesting engineering. This post is about the interception layer - how you watch the calls that can create real effects without making the agent unusable.

The naive approach does not survive contact

The textbook way to watch a process's syscalls is ptrace with PTRACE_SYSCALL: the tracee stops at every syscall entry and exit, the tracer inspects registers, then resumes it. Two context switches per syscall, for every syscall.

A coding agent is not a quiet process. It shells out, spawns toolchains, walks directory trees, and hammers stat and read in tight loops. Paying the stop-inspect-resume tax on every one of those calls makes the agent visibly, painfully slow - and almost all of those calls are ones nobody needs to review.

seccomp-BPF as a pre-filter

The fix is to make the kernel do the boring part. grith installs a seccomp-BPF filter that classifies syscalls in kernel space: the calls we do not care about run at native speed, and only syscalls of interest generate a ptrace stop for the supervisor to inspect.

Two properties make this workable:

  • TSYNC. The filter is installed with the thread-sync flag, and seccomp filters are inherited across fork, clone, and execve. Every descendant process the agent spawns - the shell it launches, the build tool that shell launches - carries the same filter. There is no window where a child runs unfiltered.
  • Selectivity. The expensive path (a ptrace stop, register inspection, argument extraction, scoring) is only taken for the syscall classes that can actually do damage: file access, network, process spawning, and friends. The performance target is P95 supervisor overhead under 50 microseconds per intercepted syscall and under 5% wall-clock slowdown end to end.

Launching the tracee

The launch sequence is the classic one: fork, the child calls PTRACE_TRACEME, then execve of the wrapped tool (grith exec -- claude-code "fix the bug" execs the real claude-code underneath). A migration to PTRACE_SEIZE, which has cleaner semantics around group-stops and signal delivery, is in progress.

From there the supervisor follows the whole process tree - forks, clones, execs - so supervision applies to everything the agent transitively runs, not just the top-level process. The PTY is forwarded, so interactive tools behave normally under supervision: prompts render, colours work, Ctrl-C does what you expect.

Verdicts are just syscall mechanics

Each intercepted call is converted to a typed tool-call representation and scored by 18 filters in three phases: static checks first (path matching, allowlists, operation risk), then pattern filters (1,618 secret-scanning regexes, Aho-Corasick command matching, egress policy, destructive-op detection), then contextual filters (taint tracking, behavioural, rate limits). Phases run in sequence with early termination at phase boundaries; measured scoring p50 is 0.02ms, with the pattern phase costing 1-3ms when it actually fires.

The three verdicts map directly onto ptrace mechanics:

  • Allow (score under 3.0): the syscall proceeds untouched.
  • Deny (over 8.0): the supervisor injects EPERM into the syscall return. The agent sees a permission error - the same thing it would see from the OS - and gets to decide what to do about it, which is usually to try something more sensible.
  • Queue (3.0 to 8.0): the process is frozen mid-syscall until a human approves or denies it from the digest or dashboard, with a configurable timeout (default 300 seconds). This is the verdict that static sandboxes do not have: not yes, not no, but "a human should see this one".

There is deliberately no LLM in this path. The enforcement decision is made by filters that a prompt cannot talk out of a verdict.

The stateful filters also have to observe what actually happened, not merely what was proposed. As of v0.3.2, egress-rate evaluation is side-effect free and the supervisor reports the call outcome separately. A denied retry therefore does not inflate delivered-throughput counters, while attempts to spread blocked traffic across distinct destinations still produce a bounded policy signal. This prevents a denial loop from manufacturing its own rate-limit storm.

Constraints and honest gaps

It ships as a single static musl binary per architecture: x86_64 (kernel 4.8+) and, as of v0.2.4, aarch64 (kernel 5.3+). Rust 1.88.

The arm64 port is a story of its own. The x86_64 code read named registers (orig_rax, rdi, rsi...); aarch64 has no PTRACE_GETREGS at all, so register access moved to PTRACE_GETREGSET with NT_PRSTATUS. The deny path was the interesting part: on arm64, writing the syscall-number slot through NT_PRSTATUS does not change which syscall executes - skipping a syscall requires writing -1 through the dedicated NT_ARM_SYSTEM_CALL regset, then seeding x0 with -EPERM. And the aarch64 syscall table is the asm-generic one, with no legacy non-at calls (no open, no fork, no rename) - which cost zero classification coverage, because every *at variant was already in the interest set. arm64 also hard-requires kernel 5.3+ for PTRACE_GET_SYSCALL_INFO instead of falling back to entry/exit heuristics.

The CHANGELOG documents the bypass classes we know about, because a supervisor that hides its own gaps is worse than no supervisor. Authority-delegating spawns (systemd-run, docker, tmux, crontab) and control-injection socket connects (session D-Bus, tmux, X11, including abstract Unix addresses) are covered and enforcement has been on by default since v0.2.5. The classifier uses canonical basenames plus session-pinned content hashes, so ordinary renames and byte-identical copies are covered too.

Residual gaps remain. A modified but runnable copy changes its hash; an execute-only disguised copy may be unreadable to the classifier; and some loopback delegation checks remain basename-based. A from-scratch delegation client under a novel name is outside the known-shape classifier. More broadly, the hard boundary is the supervised process tree: IPC delegation to a pre-existing separate process is a structural class, so grith is defence in depth rather than a substitute for a VM or container around fully untrusted code.

If you have done the PTRACE_TRACEME to PTRACE_SEIZE migration in production, or you can see an escape route we have not documented, the repo is open: github.com/grith-ai/grith. We would genuinely rather hear about it from you than from an incident.

Like this post? Share it.