
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
Quick answer. Every agent audit layer shipping in 2026 records the agent's own account of itself, and Anthropic documents where that account ends in its own monitoring docs: "Claude Code doesn't pass
OTEL_*environment variables to the subprocesses it spawns, including the Bash tool, hooks, MCP servers, and language servers." OneBashtool call is oneclaude_code.tool_resultevent with a duration and a success flag, whatever the process tree underneath it does. To get the commands themselves you have to read the kernel:auditdwith an execve rule if the record must survive a reboot, or a scoped tracepoint feed if you want it now on one box with nothing installed in advance.yeet run github:yeet-src/claudefeedis the second one.
I build eBPF tools for a living and I do not run anyone's compliance program, so treat the SOC 2 mapping in this post as absent rather than as advice. What I do see is the gap between the two records, over and over: someone opens an agent's telemetry to answer "what did it touch", finds a clean list of tool calls, and cannot get from there to the four processes that ran inside one of them. The telemetry is not wrong. It is answering a question about the agent, and the question being asked is about the machine.
It shows that a Bash call happened, and it shows the command string only if you turned that on. Anthropic's monitoring documentation lists fifteen claude_code.* event names, and the two that matter for execution are claude_code.tool_decision and claude_code.tool_result. A tool_result carries tool_name, tool_use_id, success, duration_ms, tool_input_size_bytes and tool_result_size_bytes as standard. The command itself arrives in tool_parameters and tool_input only when OTEL_LOG_TOOL_DETAILS=1 is set, because tool details, tool content, user prompts and assistant responses are all redacted by default.
Then the boundary, stated by the vendor without hedging:
"Claude Code doesn't pass
OTEL_*environment variables to the subprocesses it spawns, including the Bash tool, hooks, MCP servers, and language servers."
That sentence is not a bug report, and it is not a criticism of the design. It is a correct description of process isolation: an exported environment variable is the only channel by which a child process learns where to send telemetry, and a coding agent that pushed its exporter config into every command it ran would be corrupting the environment of the software it is supposed to be helping you build. The consequence is what matters. bash -c 'npm ci && npm test' is one tool call and one tool_result, and the npm, node, git and sh processes underneath it are outside the telemetry's frame entirely.
Because the telemetry counts tool calls, not processes, and npm test is one tool call that becomes a whole process tree. The agent named a shell; the shell named everything else. A claude_code.tool_result event records tool_name, success and duration_ms for the call as a unit, so the ratio between one row and what actually ran is unbounded. Here is what that looks like from the kernel side, with a curl standing in for whatever the agent was actually doing, streamed by claudefeed:
claudefeed — auditing "claude" sessions · seeded 9 processes from 288 live · streaming exec/exit/open/conn/listen …
16:11:22.023 3003897 exit sleep exit 0
16:11:22.023 3003924 exec uname · /usr/bin/uname -a
16:11:22.024 3003924 exit uname exit 0 after 1ms
16:11:22.024 3003925 exec curl · curl -s -m 3 http://example.com
16:11:22.031 3003925 open curl (r) /etc/ssl/certs/ca-certificates.crt
16:11:22.033 3003925 conn curl → 104.20.23.154:80
16:11:22.053 3003925 exit curl exit 0 after 29ms
Six of those seven lines describe processes the agent never named in a tool call, because it named a shell and the shell did the rest. The conn line is the one worth staring at: an outbound TCP connection to an address, attributed to a process, at a millisecond. Nothing in the agent's telemetry contains that, at any log level, because no OTEL_* variable reached the process that made it. This example uses illustrative traffic rather than a real session, and the shape is what matters: one tool call, one telemetry event, seven kernel events.
By recording the execution from somewhere the executing process does not control. ARMO's Yossi Ben Naim put the diagnosis precisely in June 2026: "The audit log proves the agent was scheduled; it cannot prove what the agent did." That piece proposes a fourth log class, the agent-action log, with six event types and a thirteen-field schema for tool invocations, and the diagnosis is right. The prescription lands one layer above where execution happens.
The distinction that resolves it is provenance, not detail. A tool-invocation record is generated by the agent runtime, which is the system whose behavior is under review, so it is testimony: accurate when the runtime is working correctly, and worth exactly as much as that assumption. A kernel record is generated by a party with no stake in the account, has no channel for the traced process to opt out of, and is written whether or not the runtime knows anything happened. Both are useful. Only one of them is evidence in the sense an incident review needs, and the difference shows up precisely when the runtime is the thing that went wrong.
An MCP gateway is authoritative for what crosses it and blind to what does not, and local execution does not. MintMCP's April 2026 write-up of the gateway approach claims the gateway records tool call parameters, return values, command exit codes and file modifications with before-and-after diffs, scoped to MCP-compatible clients connecting through the gateway infrastructure. For third-party tool access, that is the correct control point and a better one than anything the kernel offers: the gateway sees the authenticated identity, the authorization decision and the parameters together, which no execve record can reconstruct.
The scope is the catch, and it is structural rather than a gap someone forgot to close. The Bash tool is local to the agent process and never speaks MCP, so a gateway is not in that path by construction. The same holds for the Nylas CLI audit guide from June 2026, which logs at the CLI-command layer with eight JSON fields and 90-day retention and states plainly that it captures command structure and metadata only, "never the content being sent or read". Each of these is honest about its own boundary. The failure is in stacking them and concluding the box is covered, because all three boundaries fall in the same place: above the shell.
All three record subprocesses; they differ in what they demand from you in advance and whether the record survives. auditd is the answer when it has to survive a reboot, strace -f when you can name one PID and need return values, and claudefeed when you want one session's subtree right now with nothing installed beforehand. The two rows below them, agent telemetry and an MCP gateway, do not see subprocesses at all.
| Tool | Where it stands | Sees agent subprocesses | Needs a PID up front | Keeps the record |
|---|---|---|---|---|
| claudefeed | BPF tracepoints and kprobes | Yes, the whole subtree | No | No, pipe it yourself |
| auditd | Kernel audit subsystem | Yes, system-wide | No | Yes, durably |
| strace -f | ptrace | Yes, follows forks | Yes | No |
| Claude Code OTel | Agent runtime | No | No | Yes, via your collector |
| MCP gateway | Between agent and MCP servers | No | No | Yes |
claudefeed attaches three tracepoints and two kprobes and filters in the kernel to a set of session process IDs, so what reaches your terminal is one agent session and nothing else. tp/syscalls/sys_enter_execve gives the program path and full argv, tp/syscalls/sys_enter_openat gives the path and access mode, kprobe/tcp_connect gives the outbound peer, and tp/sched/sched_process_exit gives the exit code and how long the process lived.
The scoping is what makes it readable rather than a firehose. A tracked hash map of session process IDs gates the file and network probes, so a non-session openat costs one hash lookup and never becomes a ring-buffer write. Membership propagates across exec, which is how a git three levels deep in the tree stays in scope without being named, and a fresh session joins the set the moment it execs a program matching the needle, so attaching before the agent starts works. It is built on yeet, a JS runtime for eBPF, and the daemon performs the privileged load so the run command is never prefixed with sudo.
curl -fsSL https://yeet.cx | sh
yeet run github:yeet-src/claudefeed -- --except=open --secs=60 > session.log
--except=open drops the noisiest class and leaves a command-and-network narrative, and --secs=60 bounds the run so it exits without a signal, which is what makes it scriptable from CI. The feed is append-only rather than a repainting display and the colors no-op off a TTY, so the redirect produces plain grep-able text.
auditd is the right answer whenever the record has to outlive the terminal. An execve rule captures every program launch on the box, written to disk with retention that survives a reboot, and it catches agent subprocesses for the same reason a kernel probe does: it hooks the kernel, so it neither knows nor cares that an agent was involved.
sudo auditctl -a always,exit -F arch=b64 -S execve -k agent-exec
sudo ausearch -k agent-exec -ts recent -i
What you pay for that is a system-wide log to scope afterwards and a per-event write to disk. There is no notion of a process subtree in the rule language, so "everything this session spawned" is a filtering problem you solve in ausearch and auparse after the fact rather than a scope you declare up front.
strace -f follows forks and gives you syscall arguments and return values in full, which is more than any of the other routes here. It is the right tool when you have one process, you can name it, and you want to know exactly what a specific syscall returned.
It stops the traced process on entry and exit of every syscall to do that, and the cost compounds across a tree. That is why it is a debugging tool rather than an audit one: attaching it to a working agent session changes the timing of the thing you are measuring, and detaching loses everything not already on your screen.
Match on the program name and let the kernel maintain the set as the tree changes. This is the actual difficulty in auditing an agent, and it has nothing to do with agents: a session is a moving target, spawning a shell that spawns git that spawns ssh, each with a process ID that did not exist when you started watching and will not exist when you finish reading the output.
Userspace polling cannot close that gap. A loop over /proc or a ps snapshot sees the processes alive at the moment it ran, and a curl that lives 29 milliseconds is born and dead between two samples no matter how fast you poll. claudefeed handles it by doing membership in the kernel, at the moment the kernel performs the operation: a tracked process that execs a child propagates membership to it, the thread-group leader leaving prunes it, and a fresh session joins by basename match on execve. Nothing is named on the command line and nothing is polled.
Yes, and that constraint is what a kernel-side probe is shaped for. There is no image to pull, no sidecar to inject, no collector endpoint to point somewhere and no daemon left behind if you do not want one. One curl installs the runtime, one command streams the feed, and Ctrl-C ends it.
yeet run github:yeet-src/claudefeed -- --match=node --secs=30
The --match needle is a program basename, not a Claude-specific assumption, so --match=node, --match=python or --match=codex audits Cursor, Aider, Codex or anything else that execs under a recognizable name. It is compared as a case-insensitive prefix against the exec'd basename and never against the full command line, so a process that merely mentions the needle in an argument cannot register as a session.
What you give up in exchange is everything durable: no retention, no query language, no rule engine, no aggregation across hosts. Close the terminal and the record is whatever you redirected to a file. That is a real limit rather than a roadmap item, and it is the reason the answer to "what happened last Tuesday across the fleet" stays with auditd, Falco or your SIEM.
Not necessarily, and a quiet claudefeed is the one output you should never read as an all-clear. An empty feed and a broken feed look identical, because the header line prints before any event is captured. The header reports how many processes it seeded out of how many are live, and a seed of zero with no fresh session starting during the run means the needle matched nothing, not that nothing happened. Check the basename with ps -eo comm before concluding anything.
There are also documented blind spots that survive a correct setup, and the README lists them. Only openat is hooked, so the older open(2), openat2, mmap and descriptors passed across a socket produce nothing. exec fires at syscall entry, so a command that failed to launch still prints a line and the following exit line is what disambiguates. kprobe/tcp_connect fires on connect initiation rather than a completed handshake, and UDP is not hooked at all, which makes DNS over UDP invisible. Command lines are truncated past roughly 512 bytes and 40 arguments. Each of those is a place where an absence of evidence is not evidence of absence.
Record when you are still learning what the agent does, and refuse when you already know what it must not do. These are different jobs and running the recording tool as if it were a control is the mistake that costs the most, because it produces a log of the thing you did not want happening rather than an absence of it.
claudefeed observes and does not block. Nothing in it stops, holds, delays or modifies a syscall; it reads and prints. When the boundary needs to actually hold, agent-lock enforces one with a BPF LSM hook that returns -EPERM on the open itself, and the sandboxing comparison covers the full range from bubblewrap and containers through Landlock and seccomp. The useful sequence is usually to watch first and enforce second: a policy written from a real inventory of what an agent touched fails closed far less often than one written from a guess about it.
Run Claude Code's OpenTelemetry export when you want the agent's decisions, because tool_decision and tool_result with an end-user identity attached give you a per-user record of what was requested and permitted that no kernel probe can reconstruct. Put an MCP gateway in front of third-party tool access, where it sees identity, authorization and parameters together. Use auditd when the record has to survive a reboot and cover the whole box. Reach for strace -f when you have one process you can name and you need return values. Run claudefeed when the question is what one agent session's whole moving process tree did on this machine, right now, with nothing installed in advance and no PID to name. And use agent-lock when the answer is that it should not have been able to do it at all.
The mistake to avoid is stacking the first two and calling the box audited. They share a boundary, Anthropic documents it, and it falls above the shell.
Four things, and they are the four that carry the content. User prompt text needs OTEL_LOG_USER_PROMPTS=1, assistant response text needs OTEL_LOG_ASSISTANT_RESPONSES=1, tool details such as commands and parameters need OTEL_LOG_TOOL_DETAILS=1, and tool input and output content needs OTEL_LOG_TOOL_CONTENT=1. Raw API request and response bodies are disabled entirely unless you set OTEL_LOG_RAW_API_BODIES. Nothing is exported at all until an admin configures an OTLP endpoint, so a default install emits no telemetry anywhere.
An agent audit log records the agent's decisions: which tool it chose, whether permission was granted, how long the call took, whether it succeeded. A process audit log records what the kernel executed: every execve with its arguments, every file opened, every outbound connection, for the agent and everything it spawned. The first answers what the agent intended and was allowed to do. The second answers what ran. They are different records and an incident review usually needs both.
Three routes. auditd with an execve rule captures every exec system-wide and writes it durably to disk, which is the right answer when you need retention. strace -f follows forks from a PID you name, at the cost of stopping the traced process on every syscall. A BPF tracepoint on sys_enter_execve sees the same events with no stop imposed and no PID named in advance, which is what claudefeed uses to scope a feed to one process subtree.
It is enough for what crosses it. An MCP gateway sits between the agent and its MCP servers, so it records tool calls, parameters and returns for MCP-compatible clients routed through it. A local Bash tool call does not cross the gateway, and neither does anything that command spawns. The gateway is the right control point for third-party tool access and the wrong one for local execution.
The BPF load needs privilege, but that does not have to be your shell. With yeet the daemon performs the privileged load, so yeet run is unprivileged and never takes sudo. Loading a BPF program yourself requires CAP_BPF and CAP_PERFMON, or root, plus a kernel built with BTF for CO-RE. auditd rules likewise need root to install, and reading its logs needs read access to /var/log/audit.
Yes. A kernel-side probe needs nothing resident: install yeet with one curl, run the script, and stop it when you are done. There is no image to pull, no sidecar, no collector endpoint to configure and no daemon left behind. That is the difference that matters on a box you are SSH'd into and do not own the provisioning for.
Because a transcript records what the model reported, and a report is generated by the same system whose behavior is in question. An agent that says it queried one table and actually queried three produces a transcript showing one. Kernel records are produced by a different party than the one being audited, which is what makes them usable as evidence rather than as testimony.
Every execve on the box, durably, with retention that survives a reboot, plus file access through watch rules and a rule language for scoping. It captures the agent's subprocesses because it does not know or care that an agent was involved: it hooks the kernel's audit subsystem, not the application. The cost is that you get a system-wide log to filter afterwards and a per-event write to disk.
A BPF tracepoint does not, in the way ptrace does. strace -f stops the traced process on entry and exit of every syscall, which is why it becomes unusable on a busy process tree. A tracepoint fires on the kernel's own path with no stop imposed, and a filtering probe can drop uninteresting events in the kernel: claudefeed gates its file and network probes behind a hash lookup, so a non-session openat costs one lookup and never reaches userspace.
It sees that a file was opened, the path, and the access mode. It does not see the bytes. Recording payloads means copying file contents through a ring buffer, which is a different tool with a different cost profile. For content-level evidence you need the file itself plus a record of who opened it, which is what the path-and-mode record gives you.
A tracepoint on execve filtered in the kernel to one process subtree. Nothing is added to the agent's path, nothing is asked of the agent, and the events that do not belong to the session are dropped before they cross into userspace. The trade is that there is no retention: close the terminal and the record is whatever you piped to a file. auditd is the answer when the record has to survive.
It depends on the tool. A probe that only reads the process table at startup will not. claudefeed handles it in the kernel: a fresh session joins the tracked set the moment it execs a program whose basename matches the needle, whether or not its parent was ever tracked, so attaching first and starting the agent second works.
OTEL_* environment variables to the subprocesses it spawns, including the Bash tool, hooks, MCP servers, and language servers." Lists fifteen claude_code.* event names including tool_decision, tool_result, permission_mode_changed and mcp_server_connection; documents that user prompts, assistant responses, tool details and tool content are all redacted by default behind OTEL_LOG_USER_PROMPTS, OTEL_LOG_ASSISTANT_RESPONSES, OTEL_LOG_TOOL_DETAILS and OTEL_LOG_TOOL_CONTENT; and notes that Bash subprocesses do inherit a TRACEPARENT variable when tracing is active.auditctl rules and read back with ausearch and auparse.-f follows forks and traces children as they are created, giving full syscall arguments and return values for a named process tree, at the cost of the ptrace stop imposed on every syscall entry and exit.sys_enter_execve, sys_enter_openat, sched_process_exit) and two kprobes (tcp_connect, inet_listen), gated by a tracked hash map of session process IDs so non-session events cost one lookup and never reach userspace; requires a kernel with BTF and 5.8+ for the ring buffer; documents its blind spots in what you can't see.yeet run is never prefixed with sudo.bubblewrap to BPF LSM.-EPERM rather than recording it.Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.