grithdocs

Syscall interception

How grith catches what an agent does at the OS level — ptrace + seccomp on Linux.

grith intercepts a curated set of security-relevant syscalls an agent makes — the file, process, and network operations that can grant or leak authority. High-volume calls like read and write are deliberately left alone (the access decision was already made at open time). That interception does most of the work in this product. Everything else — the filters, the digest, the scoring — is downstream of being able to see and stop those syscalls before they run.

This page explains how that interception works on Linux x86_64 (the v0.1 supported target). macOS and Windows backends are different mechanisms with the same goal; see Supported platforms for status.

The mechanism: ptrace + seccomp

The grith supervisor uses two complementary kernel features:

  • ptrace lets one process attach to another and inspect/modify its execution state, including pausing the tracee before a syscall is committed.
  • seccomp-bpf lets the kernel filter syscalls in-kernel using a BPF program. With the right filter mode (SECCOMP_RET_TRACE), interesting syscalls trap to the tracer and uninteresting ones run through at native speed.

The combination is fast: most syscalls (close, mmap, futex, sched_yield) bypass grith entirely because the seccomp filter says "don't bother the tracer about these". Only the syscalls grith cares about (open, openat, execve, connect, sendto, write to certain fds) trap to the supervisor for evaluation.

What grith actually does on grith exec

grith exec --profile claude-code -- claude

Behind the scenes:

  1. fork() a child process.
  2. In the child, call ptrace(PTRACE_TRACEME) so the kernel traces it under the parent (grith). There is no PTRACE_SEIZE attach and no PR_SET_PTRACER dance on the shipped path.
  3. Child installs the seccomp-bpf filter (the "interesting syscalls" filter) via seccomp(SECCOMP_FILTER_FLAG_TSYNC, ...).
  4. Child execve()s the target binary (claude in our example).
  5. From this point on, every interesting syscall in the supervised tree raises a ptrace-stop. grith's supervisor reads the syscall args, builds a ToolCallType, sends it through the filter pipeline.
  6. If filters auto-allow: grith resumes the syscall (ptrace(PTRACE_SYSCALL, ...)) and the call runs normally.
  7. If filters auto-deny: grith rewrites the syscall number to an invalid value so the kernel skips it, and sets the return register to EPERM before resuming.
  8. If filters quarantine: the thread is already held at its ptrace/seccomp syscall-stop, so grith simply does not resume it until a reviewer decides. (No PTRACE_INTERRUPT is issued, and the rest of the process tree keeps running.)

This is a slightly simplified version of what's in crates/grith-supervisor/. The real code also handles PTY forwarding, signal forwarding, fork/clone tracking, and the noise-reduction batching for rapid reads.

Process tree tracking

grith exec doesn't only supervise the immediate child. Any process the child spawns inherits the seccomp filter and gets ptrace-attached automatically (via PTRACE_O_TRACECLONE | PTRACE_O_TRACEFORK | PTRACE_O_TRACEVFORK). An agent that shells out to bash -c 'curl ... | sh' does not escape supervision — the bash, the curl, and the resulting sh are all in the tracked tree.

The session ID identifies the tree. grith supervisor list shows one row per active tree, with the root command line.

What's "interesting"

The seccomp filter intercepts (approximately):

  • File operationsopen, openat, openat2, unlink, unlinkat, rename, renameat2, chmod, fchmod, fchmodat.
  • Process operationsexecve, execveat, clone, clone3, fork.
  • Network operationssocket, connect, bind, sendto, sendmsg, sendmmsg, recvfrom, recvmsg. DNS is inspected in-line at these syscalls: grith parses the DNS query out of the connect/sendto/sendmsg payload and reads the resolver's reply at recvfrom/recvmsg exit. There is no getaddrinfo libc hook — grith never touches libc and works purely at the syscall boundary.
  • Kernel & privileged operationsinit_module, finit_module, delete_module, kexec_load (kernel tampering — hard-denied); io_uring_setup/io_uring_enter/ io_uring_register (blocked so ring I/O can't bypass ptrace); seccomp and prctl (so a tracee can't install its own filter to hide syscalls).

Reads — read, pread64 — are not in the interesting set by default, because the data flow has already been captured at the open time. Tracking every read would flood the pipeline. The taint filter inherits the read context from the file descriptor's open.

PTY forwarding

When grith supervises an interactive program (REPL, shell, agent with a TUI), direct stdio inheritance wouldn't work — the supervisor sits between user and child. The supervisor allocates a master/slave PTY pair, hands the slave to the child, and shuttles bytes between the user's controlling TTY and the master.

The user sees no difference: their input goes to the agent, the agent's output comes back, ANSI escapes work, signals (Ctrl-C, Ctrl-Z) are forwarded.

The cost is one extra read+write per byte through the PTY. Negligible for text-rate interaction; can be measured if you're pushing tens of MB through stdio, which agents rarely do.

Noise reduction

A lot of syscalls are uninteresting in aggregate. Reading a 50-file directory of configs touches openat 50 times. Each is a separate filter pass at base rates. supervisor.noise_reduction knobs (ignore_read_only, batch_rapid_reads, batch_window_ms) collapse rapid sequences of reads on the same FD into a single filter pass per batch window. Default batch_window_ms = 50 is invisible to humans and dramatically reduces filter work for read-heavy workloads.

Permissions

ptrace requires either CAP_SYS_PTRACE or matching uid + kernel.yama.ptrace_scope allowing it. Most distros ship ptrace_scope = 1 ("only attach to descendants"), which is what grith needs — and what it does, because the supervised process is always a descendant of the supervisor.

Some hardened distros (some EC2 AMIs, Ubuntu hardening guides) ship ptrace_scope = 2 ("admin-only"). That breaks grith exec. The fix:

sudo sysctl kernel.yama.ptrace_scope=1

Add to /etc/sysctl.d/ to persist.

What this is not

grith is not a sandbox. Allowed calls run on the real filesystem against the real network. The supervisor decides yes/no; the kernel still does the work.

This is intentional. Sandboxes have their own large attack surface (chroot escape, namespace escape, redirected mounts that confuse the agent). grith leaves the agent on the real system and trusts the kernel to keep it inside the OS process model. The security guarantee comes from policy, not from re-implementing the OS.

If you do want a sandbox layer, run grith inside a container or behind a Linux user namespace. The supervision and the sandbox are complementary.

What's coming on other platforms

  • macOS — Endpoint Security framework, kext-free, requires entitlement. Status: in-flight; ships in v2.0.
  • Windows — ETW + minifilter for fs/registry, plus a process injection hook for network. Status: design done; ships in v2.0.
  • aarch64 Linux — needs the register backend (ptrace works; the BPF programs are arch-specific). Status: targeting v0.2.

See also

Last updated: 2026-05-14Edit this page on GitHub →