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
- The parent pre-warms its syscall tables and forks.
- The child calls
ptrace::traceme(), installs a seccomp-BPF filter, and execs your tool. The filter installation is unconditional. - The filter returns
SECCOMP_RET_TRACEfor the curated trap list andSECCOMP_RET_ALLOWfor everything else. The curated set is 87 security-relevant syscall identities, less the four caught as ptrace events instead (below). - The tracer sets
PTRACE_O_TRACESECCOMPalong with the exec, fork, clone andEXITKILLoptions. - A trapped syscall arrives as a
PTRACE_EVENT_SECCOMPstop at entry, before the kernel has done anything. grith reads the arguments from the kernel's ownPTRACE_GET_SYSCALL_INFOrecord, 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:
| Group | Syscalls |
|---|---|
| File open and create | open, openat, openat2, creat, truncate, ftruncate |
| File mutate and rename | rename*, unlink*, rmdir, mkdir*, chmod*, symlink*, link* |
| Directory read | getdents64 |
| Descriptor plumbing | close, close_range, dup*, fcntl, pipe*, socketpair, pidfd_getfd |
| File-backed memory maps | mmap (anonymous maps pass through) |
| Network | socket, connect, bind, send*, recv*, sendfile, splice, tee |
| Process | execve, execveat, clone, clone3, fork |
| Self-sandboxing | seccomp, prctl |
| Ring-buffer I/O | io_uring_setup, io_uring_enter, io_uring_register |
| Kernel image | init_module, finit_module, delete_module, kexec_load, kexec_file_load |
| Ownership and mount | chown*, mount, umount2, pivot_root, chroot, open_tree, move_mount, mount_setattr, fs* |
| Cross-process | ptrace, process_vm_readv, process_vm_writev |
| Namespaces | unshare, setns |
| Architecture-privileged | sethostname, 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.