Skip to content

About

Records what a Linux process tree, such as an AI agent run, actually did (file writes, connects, DNS, execs) into a hash-chained, Ed25519-signed ledger with a stdlib Python verifier. Rust, ptrace + seccomp, no root. eBPF backend on a branch.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

effect-tracer

Records the file writes, socket connects, DNS queries and execs a Linux process tree makes through syscalls, into a hash-chained, Ed25519-signed ledger that a short standard-library Python script (about 300 lines) can verify. Built on ptrace, so it needs no root, no kernel headers and no eBPF.

$ effect-tracer run --out ledger -- sh -c 'echo hi > /tmp/x; cat /tmp/x; rm /tmp/x'
#1 run.start 7a4f… committed pid=1574 effect-tracer
#2 process.exec /usr/bin/dash committed pid=1576 sh
#3 file.write /tmp/x committed pid=1576 sh
#4 process.fork /usr/bin/dash committed pid=1576 sh via="vfork" childPid=1577
#5 process.exec /usr/bin/cat committed pid=1577 cat
hi
#6 process.exit /usr/bin/cat committed pid=1577 cat exitCode=0
#7 process.fork /usr/bin/dash committed pid=1576 sh via="vfork" childPid=1578
#8 process.exec /usr/bin/rm committed pid=1578 rm
#9 file.delete /tmp/x committed pid=1578 rm
#10 process.exit /usr/bin/rm committed pid=1578 rm exitCode=0
#11 process.exit /usr/bin/dash committed pid=1576 sh exitCode=0
#12 run.end 7a4f… committed pid=1574 effect-tracer
$ python3 verifier/verify_ledger.py ledger
OK: 12 events, run 7a4f…, mechanism ptrace, coverage complete, signed by keyid c4bf5567…

The use it was built for: running an agent, a build or a test suite and being able to say afterwards, with a signature, exactly which files it wrote, which hosts it talked to and which programs it ran, or that the run had a blind spot (coverage: partial) and why.

Demo

A toy agent fetches a page, writes a report, copies it and deletes the copy. The tracer prints each effect as it commits; the verifier then checks the chain and the signature, and one edited byte in the ledger breaks it.

Animated terminal: effect-tracer run on a toy agent, verify_ledger.py reports OK, a tampered copy fails at the edited line

Recorded on 2026-10-31 in an arm64 Debian container (--mode seccomp); the exact transcript is in assets/demo-transcript.txt.

What is in the box

piece where
the tracer (Rust, nix + libc) src/
ledger format LEDGER.md
how the ptrace core works, step by step EXPLAIN.md
stdlib Python verifier with its own Ed25519 verifier/verify_ledger.py, tamper suite in verifier/test_verify_ledger.py
benchmarks: method, corpus, results BENCH.md, numbers in bench/*.json
GitHub Actions example examples/action.yml
release pipeline: signed manifest, SBOM, VEX, how to verify RELEASING.md, .github/workflows/release.yml

Install

Linux 5.6 or newer (x86_64 or aarch64; the published benchmark runs are aarch64, and CI builds and runs the ptrace integration test on x86_64 ubuntu-24.04), Rust 1.80+.

cargo install --git https://github.com/YusefSyed/effect-tracer --tag v0.1.2 --locked

Or download a prebuilt binary from the releases page and check it against the signed manifest (next section).

No root is needed: the tracer only traces its own children. Inside Docker, add --cap-add SYS_PTRACE. Without it the tracer still runs under Docker's default seccomp profile, but that profile denies pidfd_getfd, so the tracer cannot read a socket's type: connects and sends are recorded as ip:host:port with detail.transportUnknown, and DNS questions are still parsed. The corpus in such a container (bench/corpus-2026-10-31-*-docker-default.json) scores 18 of 20 in full mode, the two misses being c08 and c19 recorded as ip: where the strict check expects udp: and tcp:. The CI workflow runs on GitHub's ubuntu-24.04 runners without sudo (build, Rust tests under ptrace, the verifier tamper suite, the toy-agent clean runs and the bypass corpus).

Verify a release

Each release from v0.1.2 on ships Linux binaries for x86_64 and aarch64, a CycloneDX SBOM per binary, an OpenVEX document and verify_ledger.py, all listed by SHA-256 in MANIFEST.txt, which is signed with the Ed25519 release key in release-signing-key.pub (OpenSSH signature, namespace file). From a clone of this repository, with the downloaded assets in release/:

ssh-keygen -Y verify -f allowed_signers -I effect-tracer-release -n file \
  -s release/MANIFEST.txt.sig < release/MANIFEST.txt
(cd release && sha256sum --ignore-missing -c MANIFEST.txt)

The first prints Good "file" signature for effect-tracer-release with ED25519 key SHA256:hxiDDsLKUbG2BO4GePgzO/+xwPshP++WE3rw0QNelsc; the second prints OK for every file you downloaded. RELEASING.md has the full asset list, what the workflow checks before signing, and how the key is kept.

Use

effect-tracer run [--out DIR] [--mode full|seccomp] [--dns-port N]... [--clear-argv] [--trace-reads] [--key FILE] [--pin-cpu [CPU|auto]] [--run-as UID[:GID]] [--quiet] -- COMMAND ARGS...
effect-tracer show DIR
effect-tracer keygen --out FILE
python3 verifier/verify_ledger.py DIR [--pubkey HEX] [--json]
  • --mode full (default) stops the tracee at every syscall entry and exit; every syscall in the effect table is seen, at the highest cost.
  • --mode seccomp installs the same filter but with SECCOMP_RET_TRACE on the effect syscalls only, and the filter tests arguments inside the kernel: a read-only openat, a private or read-only mmap, an ordinary socket never stop at all. read, write, futex and friends run at native speed. The ledger is the same as in full mode, except that an io_uring_setup attempt is denied inside the kernel without being written down.
  • --dns-port adds a UDP port to treat as DNS (53 is always one).
  • --key signs with a persistent key from keygen instead of a fresh key per run; pin it at verification with --pubkey.
  • --pin-cpu puts the tracer and the whole tree on one CPU (the one the tracer starts on, or the number given). Every ptrace stop then switches tasks on that CPU instead of waking another, which is much faster for serial work (a build step, a test run, an agent) and slower for parallel work (make -j). Recorded in run.start.
  • --run-as UID[:GID] (tracer as root) runs the command as another user, so it can read neither the key file nor the tracer's memory. Without it the tracee runs as your own user; see "Trust boundary" in LEDGER.md for what the signature then does and does not prove.
  • The exit code is the command's own; tracer failures exit 3.

The ledger directory holds events.jsonl (the chain), checkpoint.json (the signature over its head) and ledger.json (an unsigned summary the verifier checks against the chain). Verifier exit codes: 0 intact and complete, 1 tampered, 2 unreadable, 3 intact but incomplete coverage.

How it works, in one paragraph

The command is forked; the child installs a seccomp filter under PR_SET_NO_NEW_PRIVS and waits on a pipe until the parent has done PTRACE_SEIZE with TRACESYSGOOD | TRACECLONE | TRACEFORK | TRACEVFORK | TRACEEXEC | TRACEEXIT | EXITKILL | TRACESECCOMP. Every task in the tree is then attached before it runs. At each stop PTRACE_GET_SYSCALL_INFO (Linux 5.3+) gives the syscall number and arguments at entry and the return value at exit, architecture-neutrally. Path arguments are read with process_vm_readv and resolved through /proc/<tid>/fd and cwd; sockets are inspected through pidfd_getfd. Each event is one canonical JSON line whose prev is the SHA-256 leaf hash of the line before; the tracer's own run.start and run.end (with the coverage verdict) are inside the chain, and the chain head is signed with Ed25519. EXITKILL means a tracer that dies takes the tree with it, so there is never an unobserved tail. The io_uring syscalls return EPERM from the filter in both modes and in every ABI the kernel accepts (native, 32-bit compat, x32), because a ring, once set up, does I/O without syscalls. EXPLAIN.md walks through all of it.

Numbers

Every number below is read from a file in bench/ whose env block names the kernel, machine, tracer version and date; BENCH.md has the method. These runs: release build, kernel 6.12.76-linuxkit aarch64 (Docker Desktop 4.86.0's VM on an Apple M5), Debian 12 container, Python 3.11.2, 2026-10-31.

Seeded bypasses caught

bench/corpus-2026-10-31-full.json, bench/corpus-2026-10-31-seccomp.json: 20 ways to produce an effect outside a declared root, one tracer run each.

mode caught note
--mode full 20 of 20, plus 6 of 6 extra (thread, forked child, memfd, splice, and an ftruncate and a connect after PR_SET_DUMPABLE 0) every ledger verified
--mode seccomp 19 of 20, plus 6 of 6 extra io_uring_setup denied by the filter but not recorded (kernel-side EPERM before any stop)

With the tracer itself unprivileged (bench/corpus-2026-10-31-*-unprivileged.json, uid 65534) the scores are the same. The two non-dumpable cases come out as declared gaps (ftruncate: descriptor name unreadable, connect: sockaddr unreadable) and turn those ledgers' coverage to partial (verifier exit 3). An unprivileged tracer may not read a non-dumpable process's descriptors or memory, and the ledger says so instead of dropping the event.

The cases: subprocess and os.system shell redirects, rename over an undeclared path, O_TMPFILE + linkat, shared writable mmap, sendfile, /dev/shm, raw UDP sendto, unix socket connect, io_uring_setup, copy_file_range, a write through a symlink that leaves the root, a hard link of an outside file into the root, truncate, chmod, unlink, mkdir, a DNS query for an undeclared name, a TCP connect to an undeclared port, exec of an undeclared binary.

False positives on clean runs

bench/clean-runs-2026-10-31-full.json, bench/clean-runs-2026-10-31-seccomp.json: the toy agent (200 file writes, 50 local HTTP calls, 20 subprocesses, 10 DNS lookups to a local stub) run 30 times per mode against a declared manifest and the published ignore list bench/ignore.json.

mode runs undeclared events ignored events ledgers verified, coverage complete
--mode full 30 0 0 30 of 30
--mode seccomp 30 0 0 30 of 30

Each run records 356 events in both modes (221 file.write, 50 socket.connect, 10 socket.send, 10 dns.query, 21 process.exec, 20 process.fork, 21 process.exit, 1 file.create, plus run.start and run.end).

Overhead

hyperfine, 2 warmups and 10 runs per command, medians, release build. W1 is dd bs=1 count=500k (about a million one-byte syscalls, the ptrace worst case), W2 the toy agent, W3 an offline pip install requests into a venv, W4 a 200-test pytest suite. W1 comes from bench/overhead-2026-10-31T051602Z.json and W2-W4 from bench/overhead-2026-10-31T053837Z.json, whose rows were re-run on a quiet machine (BENCH.md says why). Each cell is the median and its ratio to the untraced run; pinned cells are compared with an untraced run pinned to the same CPU.

workload untraced --mode seccomp --mode full --mode seccomp --pin-cpu --mode full --pin-cpu
W1 dd, 1M tiny syscalls 0.264 s 0.295 s (1.12x) 78.6 s (298x) 0.241 s (0.99x) 5.59 s (23.0x)
W2 toy agent 0.064 s 0.105 s (1.65x) 0.502 s (7.90x) 0.073 s (1.25x) 0.099 s (1.71x)
W3 pip install 0.268 s 0.297 s (1.11x) 1.370 s (5.12x) 0.283 s (1.04x) 0.358 s (1.32x)
W4 pytest, 200 tests 0.194 s 0.360 s (1.86x) 1.589 s (8.20x) 0.229 s (1.18x) 0.310 s (1.59x)

Stops per run: W1 7 (seccomp) and 2,048,104 (full); W2 829 and about 11,780; W3 about 420 and 28,700; W4 3,289 and about 35,760. Every traced command exited 0, and its last run's ledger has complete coverage.

What it says: on a VM, most of ptrace's cost is not the tracer's work but the kernel waking the tracer on another CPU at every stop. W1's two million stops take 78.6 s spread across CPUs and 5.6 s on one CPU, about 2.6 microseconds per stop. With --pin-cpu, seccomp mode costs 0-25 percent on these serial workloads, and full mode 32-71 percent on W2-W4 (W1, which is nothing but syscalls, is still 23x). Unpinned, seccomp mode costs 11-86 percent. Pinning is a flag, not the default, because it would serialise a parallel build. An earlier unpinned run on the same machine (bench/overhead-2026-10-31T041430Z.json) gave 1.21x / 1.66x / 1.22x / 1.69x for seccomp mode and 311x / 7.8x / 4.9x / 7.7x for full mode.

Limits

Stated in every ledger (ledger.json.limits) and in EXPLAIN.md section 9:

  • io_uring is denied (EPERM from the seccomp filter, inherited by every descendant, in the native ABI and in 32-bit compat and x32 alike), not observed; in seccomp mode the attempt is not even recorded.
  • non-dumpable processes: a program that calls PR_SET_DUMPABLE 0 (ssh-agent, gpg-agent) or execs a binary its user cannot read hides its descriptor names and memory from an unprivileged tracer. Each effect the tracer cannot name is a coverage.gap and the run reads partial. Running the tracer as root (with --run-as for the tree) avoids it.
  • foreign ABIs: 32-bit and x32 syscalls are recorded as gaps, not decoded.
  • trust boundary: the tracer signs. A tracee running as the same user can read a --key file, so a pinned key proves something only when the tree runs as another user (--run-as). A per-run key proves the files were not edited after the run, not who made them. The tracer marks itself non-dumpable, so a same-user tracee cannot read the key out of its memory.
  • mmap: a shared writable mapping is recorded when mapped; later stores through it are not syscalls.
  • reads are not recorded unless --trace-reads.
  • namespaces: paths are resolved in the tracer's mount namespace; mount, chroot, pivot_root, setns, unshare are recorded as coverage.gap and turn the verdict to partial.
  • root and capabilities: a tracee with CAP_SYS_ADMIN can still load BPF, mount, or ptrace something outside the tree. Those are gaps, written down, not prevented. This is a ledger, not a sandbox; confinement is a separate planned mode.
  • ptrace visibility: the tracee can see TracerPid and the filter in /proc/self/status; PR_SET_NO_NEW_PRIVS means setuid binaries run without privilege under it.
  • seccomp mode and DNS: QNAMEs are read from sendto, sendmsg and sendmmsg; a resolver that uses plain write on a UDP socket is seen as the connect only.
  • inherited descriptors: a write is recorded when the file is opened for writing inside the tree; writes through descriptors inherited from outside it (a redirected stdout) are not.
  • a program that daemonises keeps the tracer alive until its last task exits.
  • inbound connections: listen is recorded; accept and accept4 are not in the effect table, so a connection the tree accepts is not an event.
  • descriptor passing: a descriptor received over a Unix socket (SCM_RIGHTS) is not tied to a path; writes through it are not recorded.
  • extended attributes: the setxattr family is not in the effect table.
  • DNS over TCP: DNS marking needs a UDP or raw IP socket; a resolver that uses TCP is seen as the connect only.

CI and the Action example

.github/workflows/ci.yml builds, runs the Rust tests (the integration test traces a shell script on the runner), the verifier tamper suite, then ci/run.sh: clean runs and the corpus in both modes, with the corpus results uploaded as artifacts. examples/action.yml shows a job that runs npm ci under the tracer, verifies the ledger, and fails on anything the manifest did not declare.

Related work and what was borrowed

strace's --seccomp-bpf (the stop-only-on-interesting-syscalls trick), Reverie (ptrace + seccomp tracer architecture in Rust), Tetragon (process exec_id lineage and JSON event shape), gVisor's seccheck (point naming), and RFC 9162 (leaf hashing). None of their code is used.

Roadmap

  • eBPF backend (--mode ebpf: BPF LSM hooks, sched tracepoints, raw sys_enter/sys_exit) writing the same ledger, checked event-for-event against the ptrace backend and a bpftrace oracle. It exists on the m3-ebpf branch, needs root, and is not in a release yet.
  • DSSE / in-toto Statement envelope around the checkpoint.
  • A Landlock confinement mode, so the ledger can also say what was refused.
  • Decoding 32-bit ABI syscalls instead of flagging them.

How this was built

Written Oct 30 to 31 2026 by Claude Code sessions that I directed, read and reviewed, with the design decisions (ptrace over eBPF or an LSM for v0.1, the seccomp effect filter, the io_uring rule, the ledger and verifier format) made and defended by me; see EXPLAIN.md for those decisions and BENCH.md for how every number was produced. The commit history on main is squashed into milestones; the full working history is longer and messier, as working history is. Bugs and design mistakes are mine: report them through SECURITY.md or an issue.

Changelog

  • 0.1.2 (2026-11-01): release pipeline: x86_64 and aarch64 Linux binaries with an OpenSSH-signed MANIFEST.txt, a CycloneDX SBOM per binary, an OpenVEX document and a cargo audit gate. No tracer or verifier changes.
  • 0.1.1 (2026-10-31): a netlink SOCK_RAW socket (what glibc's getaddrinfo opens to list interfaces) is recorded as socket.netlink instead of a raw-socket coverage.gap. Before this, any traced program that resolved a hostname through glibc ended with coverage partial. Netlink addresses render as netlink rather than af16.
  • 0.1.0 (2026-10-31): ptrace backend (full and seccomp modes), signed ledger, stdlib verifier, bypass corpus and overhead benchmarks.

Maintainer

Yusef Syed. Questions or work opportunities: yusefmsyed@gmail.com.

Licence

Apache-2.0. See LICENSE.

About

Records what a Linux process tree, such as an AI agent run, actually did (file writes, connects, DNS, execs) into a hash-chained, Ed25519-signed ledger with a stdlib Python verifier. Rust, ptrace + seccomp, no root. eBPF backend on a branch.

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages