Blocking binaries is not enough. Seccomp allowlists shrink the system vocabulary so common exploits fail closed — Tetragon decides which programs may start; seccomp decides which syscalls those programs may still speak.
Part 4 answered which programs may start after authority says yes. This post answers the residual question: when the binary you allow is still running — node, python3, the agent runtime itself — which syscalls may it speak, and what fails closed when an exploit needs a word you never taught it?
The Binary You Allowed Kept Talking
Part 4’s kill-switch is green. The agent image is near-distroless. Tetragon never sees curl, npx, or pip. The only execve that matters is the one you expected:
node (agent / tool handler) ✅ allowlisted
Then a hostile skill — or a memory-safe-looking library that isn’t — needs an escape. It doesn’t spawn a new binary. It asks the kernel for tools the language runtime already exposes:
socket(AF_PACKET, …) # sniff / craft outside the intended path
ptrace(PTRACE_ATTACH, …) # inspect or mutate a sibling
process_vm_writev(…) # write into another process's memory
unshare(CLONE_NEWUSER) # start a namespace dance toward breakout
mount(…) # remount, bind, or pivot
Panguard saw an approved tool call. Tetragon saw an approved binary. The vocabulary of that binary was still the full Linux dictionary.
Process containment answers who may speak. Seccomp answers what words remain in their mouth.
After the Binary Is Allowed
Part 4’s residual risk was grandchildren. Part 5’s residual risk is the runtime’s own mouth.
| Layer | Sees | Misses |
|---|---|---|
| Prompt / model | Natural language | Everything after the decision |
| Panguard | Named tool + params + claims | Syscalls inside an allowed handler |
| Tetragon (Part 4) | execve paths / process tree | Abuse that never starts a new binary |
| Seccomp (this part) | Syscall number + args at the kernel gate | Logic bugs that only use still-allowed calls |
| Kata / gVisor (Part 7) | Where those calls may land | Nothing — when you need a stronger host boundary |
Classic residual cases Part 4 doesn’t close: an approved runtime exploited without execve of curl; FD and socket tricks using raw sockets, packet sockets, or surprising dup3/sendmsg paths; container escape primitives like ptrace, process_vm_*, userfaultfd, mount, unshare, keyctl; debugging syscalls left in production profiles.
Tetragon is the tripwire on new doors. Seccomp is the lock on the doors the process already holds keys for.
The Architecture Pattern: Syscall Allowlisting
Assume every agent binary will eventually be coerced into an unexpected syscall. Prefer default deny — or a hardened baseline that is effectively a short allowlist of mundane I/O — over blocking the scary names and hoping. Attach the profile at container / sandbox launch so the process can’t widen its own diet after start. Role-shape diets: agent runtime, test sidecar, and untrusted-document worker each get different profiles. Treat denials as signals into WORM / SIEM; teach agents to surface them rather than silently invent workarounds.
Agent / tool handler (allowed binary)
│
▼
syscall(ptrace | mount | socket(AF_PACKET) | …)
│
▼
Kernel seccomp-BPF filter
│
├── allow → continues
└── deny → SCMP_ACT_ERRNO / KILL
└── Post / audit → Fluent Bit / OTel → WORM + SIEM
Seccomp is the universal baseline whether you later add Kata, gVisor, or stay on standard containers. Sandboxes shrink where code can reach the host kernel; seccomp shrinks which calls get that far even before you pay for a stronger runtime.
RuntimeDefault: The Boring Win
Before you write a custom JSON profile, ship this:
# Kubernetes pod / container securityContext
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ['ALL']
seccompProfile:
type: RuntimeDefault
# Docker / Compose
services:
clawql-agent:
image: ghcr.io/example/clawql-agent:distroless
read_only: true
security_opt:
- no-new-privileges:true
- seccomp:/etc/clawql/seccomp/agent-runtime.json
cap_drop:
- ALL
RuntimeDefault (containerd / CRI-O default, close cousins of Docker’s default profile) already blocks a long list of escape-adjacent calls — including common ptrace/process_vm_* paths and other favorites from the Kubernetes restricted story. Many agent pods still ship with Unconfined because someone pasted a debug profile into production.
If your “hardened agent” is seccompProfile: Unconfined, you have a documentation site, not a diet.
Default-Deny Sketch
ClawQL core agent paths are boring on purpose: read/write under a workspace, talk to a gateway over ordinary TCP, maybe clone/futex/poll for a runtime. They don’t need packet sockets, mount, or cross-process memory writes.
Conceptual allowlist (always derive from strace / runtime traces of your actual image):
ALLOW (illustrative core)
read, write, close, lseek, fstat, newfstatat
openat, readlinkat, getdents64
mmap, mprotect, brk, munmap
futex, nanosleep, clock_gettime, getrandom
clone, exit, exit_group, wait4, rt_sig*
socket(AF_INET|AF_INET6, SOCK_STREAM|SOCK_DGRAM)
connect, sendto, recvfrom, epoll_*, poll, ppoll
DENY / kill (high-confidence agent diet)
ptrace
process_vm_readv, process_vm_writev
userfaultfd
perf_event_open
bpf
mount, umount2, pivot_root, swapon, swapoff
unshare, setns
keyctl, add_key, request_key
init_module, finit_module, delete_module
reboot, kexec_load
open_by_handle_at
socket(AF_PACKET | AF_RAW | …)
JSON profile shape (Docker / cri-o localhost file — trimmed):
{
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"archMap": [
{
"architecture": "SCMP_ARCH_X86_64",
"subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]
}
],
"syscalls": [
{
"names": [
"read",
"write",
"close",
"openat",
"newfstatat",
"mmap",
"futex",
"clone",
"exit_group"
],
"action": "SCMP_ACT_ALLOW"
},
{
"names": [
"ptrace",
"process_vm_readv",
"process_vm_writev",
"userfaultfd",
"mount",
"unshare"
],
"action": "SCMP_ACT_ERRNO",
"errnoRet": 1
}
]
}
Wire a custom file in Kubernetes:
securityContext:
seccompProfile:
type: Localhost
localhostProfile: clawql/agent-runtime.json
localhostProfile is relative to the node’s seccomp profile root — pin the file via your node image / DaemonSet, not a hopeful README.
Start from RuntimeDefault, subtract what your agent never needs, and only then add rare exceptions with an owner and an expiry. Inventing a 12-call allowlist from scratch on day one is how you brick node’s thread pool and blame “seccomp is too hard.”
Observe the Vocabulary First
Do for syscalls what Part 4 did for execve: observe before enforcing.
# Inside a representative agent container (dev only)
strace -f -c -p "$(pgrep -n node)"
# Or capture a full trace of a golden path
strace -f -o /tmp/agent.strace \
node /app/agent.js --once summarize /workspace/README.md
Cross-check denials under a candidate profile:
# Typical failure surface when the diet is too tight
EPERM / ENOSYS on: clone flags, mmap hints, getrandom, eventfd2, …
Stage the rollout: RuntimeDefault everywhere still running Unconfined, then trace golden paths (idle, tool call, egress to gateway, crash recovery), then subtract high-risk calls with SCMP_ACT_ERRNO (debuggable) before SCMP_ACT_KILL for the final few, then promote per role.
A surprise EPERM in staging is a gift. The same surprise in production, with silent retries, is how you train agents to wander into worse paths.
Role Diets
| Role | Seccomp posture | Notes |
|---|---|---|
| Agent runtime | RuntimeDefault → custom subtract of ptrace/mount/raw sockets | Must keep ordinary TCP + workspace I/O |
| Trusted short tool sidecar | Often tighter; may use gVisor later (Part 7) | High invocation frequency, shorter lifetime |
| Exec / untrusted documents | Strictest diet + stronger sandbox (Kata / gVisor in Part 7) | Assume hostile content; seccomp is necessary but not sufficient here |
| Build / CI image | Different profile; never ship as the agent | Toolchains need syscalls agents must not keep |
Helm attachment (same RuntimeClass story Part 4 started; Part 7 deepens):
helm upgrade --install clawql ./charts/clawql-mcp -n openclaw --create-namespace \
--set security.seccomp.default=RuntimeDefault \
--set security.seccomp.agentLocalhostProfile=clawql/agent-runtime.json \
--set security.kata.enabled=true \
--set security.kata.runtimeClassName=kata-qemu
How Denials Sit Next to Panguard and Tetragon
| Signal | Layer | Reading |
|---|---|---|
| Panguard allow(tool) | Protocol | Intent was in-policy |
| Tetragon kill(npx) | Exec | Intent escalated into a forbidden binary |
| Seccomp deny(ptrace/mount) | Syscall | Intent stayed in an allowed binary and still reached for escape primitives |
Panguard (MCP / ATR)
→ agent runtime (allowed exec)
→ seccomp deny OR Tetragon kill on grandchild
→ Fluent Bit / OTel
→ WORM audit
→ SIEM (+ quarantine hooks)
Label workloads with session, pod, profile name, and role so a seccomp EPERM is not an orphan syslog line. Part 8 deepens TraceID propagation into kernel and sandbox events.
When Panguard allows a call but seccomp (or Seatbelt / Kata) denies the underlying syscall, the sandbox wins — same complementary-layer rule as Part 3’s fail-closed gating.
Environment Matrix
| Environment | Primary syscall diet |
|---|---|
| Kubernetes | RuntimeDefault / Localhost seccomp on every agent pod + RuntimeClass (Part 7) |
| Linux edge / bare | Docker/Podman seccomp profiles; same JSON you pin on nodes |
| macOS laptop agent | Seatbelt — not Linux seccomp JSON |
Seatbelt is the macOS expression of the same idea: the process can’t expand its own policy after launch; forbidden syscalls/paths fail before completion. It’s not a production substitute for container seccomp + Kata/gVisor. gVisor re-implements syscalls in userspace; Kata gives the workload its own kernel. Both still pair with a baseline seccomp story. Part 7 owns the runtime choice; this part owns the diet you apply even when you stay on standard containers.
Honest Failure Modes
Default deny is easy to overfit. Trace real workloads. Agent runtimes use more syscalls than README fiction admits (eventfd, rseq, membarrier, …).
Argument filters are sharper — and sharper to get wrong. Filtering socket by domain (deny AF_PACKET) beats a blanket deny that also breaks healthchecks. Start with name-level denies for high-risk calls; add arg filters when the blunter diet breaks legitimate paths.
SCMP_ACT_KILL vs ERRNO. Kill is excellent for never-should-happen primitives (ptrace in an agent pod). ERRNO is better while learning — and for anything the runtime might handle gracefully. Mirror Part 4’s observe → enforce staging.
Profiles drift with glibc / runtime upgrades. Pin profile versions next to image digests. A Node LTS bump that starts using a newly required call will look like “random production flakes” if nobody owns the diet.
Debug holes. Leaving ptrace allowed “temporarily” is how temporary becomes the threat model. Break-glass profiles need the same time-bound, audited posture as ATR scope expansion in Part 3.
Seccomp is not FIM. Deny openat of /etc/shadow by path belongs to Part 6 and Seatbelt path rules on Mac. Seccomp can sometimes constrain via args, but filesystem no-go zones deserve their own control plane.
Getting Started
Inventory agent/tool pods and containers — list anything still Unconfined. Enforce RuntimeDefault + no-new-privileges + dropped caps + read-only rootfs as the floor. Trace one golden path per role (strace -c / full traces); document required syscalls. Subtract high-risk calls into a Localhost profile; stage ERRNO before KILL. Wire denials into Fluent Bit / OTel → WORM → SIEM with session/pod/profile labels. Teach the agent UX to surface “blocked by syscall policy” rather than mute-retrying into a worse tool.
Part 6: shrink which paths an allowlisted, diet-constrained runtime may still open.
Companion: DevSecOps-boilerplate. Docs: Sandboxing (Kata, gVisor, Seatbelt, seccomp) · Seatbelt essay.
