grithdocs

Syscall interception

seccomp-BPF picks the syscalls worth stopping, ptrace stops them, and grith answers before the kernel acts.

grith uses both seccomp-BPF and ptrace, on every supervised session, always. They do different jobs: seccomp decides which syscalls are worth stopping, ptrace does the stopping. Neither is optional and there is no configuration that turns either off.

What happens when you run grith exec

  1. The parent pre-warms its syscall tables and forks.
  2. The child calls ptrace::traceme(), installs a seccomp-BPF filter, and execs your tool. The filter installation is unconditional.
  3. The filter returns SECCOMP_RET_TRACE for the curated trap list and SECCOMP_RET_ALLOW for everything else. The curated set is 87 security-relevant syscall identities, less the four caught as ptrace events instead (below).
  4. The tracer sets PTRACE_O_TRACESECCOMP along with the exec, fork, clone and EXITKILL options.
  5. A trapped syscall arrives as a PTRACE_EVENT_SECCOMP stop at entry, before the kernel has done anything. grith reads the arguments from the kernel's own PTRACE_GET_SYSCALL_INFO record, classifies the call, and scores it.

The seccomp filter is what makes this affordable. Without it the tracer would stop on every syscall - hundreds of thousands of them during a Node.js startup alone. With it, the kernel decides in a few instructions whether a syscall is even worth a context switch.

What gets trapped

The curated set covers the operations that can move data, change the machine, or escape supervision:

GroupSyscalls
File open and createopen, openat, openat2, creat, truncate, ftruncate
File mutate and renamerename*, unlink*, rmdir, mkdir*, chmod*, symlink*, link*
Directory readgetdents64
Descriptor plumbingclose, close_range, dup*, fcntl, pipe*, socketpair, pidfd_getfd
File-backed memory mapsmmap (anonymous maps pass through)
Networksocket, connect, bind, send*, recv*, sendfile, splice, tee
Processexecve, execveat, clone, clone3, fork
Self-sandboxingseccomp, prctl
Ring-buffer I/Oio_uring_setup, io_uring_enter, io_uring_register
Kernel imageinit_module, finit_module, delete_module, kexec_load, kexec_file_load
Ownership and mountchown*, mount, umount2, pivot_root, chroot, open_tree, move_mount, mount_setattr, fs*
Cross-processptrace, process_vm_readv, process_vm_writev
Namespacesunshare, setns
Architecture-privilegedsethostname, setdomainname, iopl, ioperm, swapon, swapoff, reboot

execve, execveat, clone and fork are deliberately left off the BPF list and caught as ptrace events instead - trapping execve before PTRACE_O_TRACESECCOMP is set returns ENOSYS.

Trapping is not the same as enforcing. Kernel-image and architecture-privileged calls are hard-denied before the proxy ever sees them, but the ownership/mount group and the namespace group ship switched off (category2_proxy and category3_namespace in [supervisor.coverage]). At stock defaults a supervised tool can call chown, mount, unshare and setns without being scored - deliberate, because sandboxes and rootless container runtimes do that work legitimately, but a real gap.

What is deliberately not trapped

read, write and writev. They are the hottest syscalls in any workload, and stopping on them would make supervision unusable. The direct consequence, stated plainly: grith does not inspect file or socket payload content. A FileWrite is fingerprinted by hashing the path, not the data. grith judges what is touched, not what is transferred.

That is also why TCP-DNS is denied rather than inspected - a DNS query and its response ride on read and write, so there is nothing to see at the syscall boundary. Blocking it is honest; pretending to parse it would not be.

Anonymous mmap also passes through uninspected.

Deny, allow and hold

Three outcomes, all applied at the entry stop before the kernel runs the call.

ALLOW resumes the thread with PTRACE_CONT.

DENY rewrites the syscall number to -1 so kernel dispatch matches nothing, and pre-seeds the return register with -EPERM so the tool sees a permission error rather than ENOSYS. One syscall fails. The process is not killed and typically retries or takes another path. The single exception is an authority-delegating spawn caught at PTRACE_EVENT_EXEC: the new image has already loaded, so there is no in-flight syscall to convert and grith sends SIGKILL instead.

QUEUE leaves the calling thread where it already is - held at its kernel stop - while the digest waits for an answer.

⚠️QUEUE holds one thread, not the process tree

Sibling threads keep running for the whole review. A multi-threaded tool can carry on working on other threads while a prompt is on screen. grith does not freeze the process tree, and freeze_timeout_seconds is a review timeout, not a freeze duration.

Failing closed

A syscall arriving on a foreign ABI - i386 int 0x80, a 32-bit exec, compat EL0 on arm64, or an x86_64 x32 number - is returned as a trace with a marker and then denied without interpreting its registers. Reading foreign register layouts as if they were native is how interception gets bypassed, so grith refuses to guess. The architecture check runs before the first syscall-number comparison in the BPF program, so a foreign call can never reach the allow path.

Requirements

Linux on x86_64 (kernel 4.8 or newer) or aarch64 (kernel 5.3 or newer, which is where PTRACE_GET_SYSCALL_INFO arrives). grith needs CAP_SYS_PTRACE or a Yama ptrace_scope of 1 or lower; the Ubuntu and Debian default of 1 is fine.

grith exec --attach <pid> is the one different path. A seccomp filter cannot be injected into a running process, so an attached session resumes with PTRACE_SYSCALL and stops on every syscall. It works, and it is considerably slower.

See also

Last updated: 2026-08-24Edit this page on GitHub →