---
title: "How to See Every Command Your Build Runs in CI on Linux: Capturing Them Is the Easy Part"
description: "Every exec a build starts is already visible to the kernel through the sched_process_exec tracepoint, so the hard part is volume and scope: execsnoop, strace -f, auditd, set -x and exectop compared on what each one can scope to one process tree and what each does with 7,745 lines of output."
date: 2026-09-04
authors: Jacob Pradels
canonical: https://yeet.cx/topical-takes/how-to-see-every-command-your-build-runs-in-ci-on-linux
---
> To log every process a build starts on Linux, hook the `sched_process_exec` tracepoint rather than reading your build's own output, because the tracepoint carries the arguments the kernel was actually handed instead of the ones a script said it would use. The capture is the easy half. A nine-file C build emits 27 compiler invocations, a loop calling `echo` and `date` emits 7,745 lines, and a flat stream at that volume hides the one command you were looking for. Three routes handle it differently: `execsnoop` from [bcc](https://github.com/iovisor/bcc) prints one greppable line per exec host-wide, `strace -f` follows forks at [ptrace](https://man7.org/linux/man-pages/man1/strace.1.html) cost for one process, and [`exectop`](https://github.com/yeet-src/exectop) scopes to one process tree and folds repeated commands into one row per kind so the outliers rank on top.

A green pipeline and a wrong artifact is the shape this problem usually arrives in, and the fact somebody needs is always the same one: which programs actually ran between `git push` and the image landing in the registry. Every tool in reach answers a neighbouring question. CI logs describe what the YAML asked for, `docker history` describes the layers, `terraform plan` describes the declaration, and none of them records the exec. My own standing here is narrow. I build kernel-side tooling and watch engineers get stuck on that step; I have never owned a build farm or been on the hook for a release train.

## What are the options for logging every process a build starts on Linux?

Eight routes get used for this, and they split on two questions rather than one. The first is scope: can you point the tool at one build and have it follow that build's grandchildren, or does it hand you the whole machine and leave you grepping. The second is volume: once you have every exec, does the tool do anything about the fact that most of them are the same three commands repeated. Most of the tooling answers the first question badly and the second not at all.

| tool | can scope to one process tree | follows grandchildren | does anything about volume | records intent or execution | needs |
| --- | --- | --- | --- | --- | --- |
| [`exectop`](https://github.com/yeet-src/exectop) (yeet) | yes, by launch, container or pid | yes, membership propagates at `fork` | folds repeats into one row per kind, ranks outliers | execution | BTF kernel, the yeet daemon |
| [bcc `execsnoop`](https://github.com/iovisor/bcc/blob/master/tools/execsnoop_example.txt) | parent pid only (`-P`) | no | no, one line per event | execution | bcc, root |
| [`bpftrace`](https://github.com/bpftrace/bpftrace) | with a hand-written predicate | only if you write the fork bookkeeping | whatever you aggregate in the script | execution | bpftrace, root |
| [`strace -f`](https://man7.org/linux/man-pages/man1/strace.1.html) | yes, one process and its forks | yes | no, and it prints every syscall too | execution | ptrace, the target restarted or attached |
| [`auditd`](https://man7.org/linux/man-pages/man8/auditctl.8.html) | filter fields including `ppid` | no | no, it writes a durable log | execution | root, log storage |
| [`set -x`](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html), `make --trace`, Actions debug logging | per shell or per job | only where every layer opts in | no | intent | a change to the build |
| [`pstree`](https://man7.org/linux/man-pages/man1/pstree.1.html), `ps -ejH` | yes, rooted at a pid | yes, if they are still alive | no, it is a snapshot | neither, it is state | nothing |
| [Socket.dev](https://socket.dev/glossary/postinstall-scripts), [Snyk](https://snyk.io/product/open-source-security-management/), [`npm audit`](https://docs.npmjs.com/cli/v10/commands/npm-audit) | not applicable | not applicable | not applicable | intent, before it runs | a manifest |

### exectop and yeet: one process tree, folded into rows

`exectop` is an eBPF process-launch monitor for Linux: it shows every program one application starts, folds the repetition into one row per kind of command, and ranks anything that does not look like ordinary work above the rest. It loads three tracepoints, `sched_process_exec` for the event itself, `sched_process_fork` to grow the watched set, and `sched_process_exit` to shrink it, which is what turns "this pid" into "this application" without any bookkeeping in userspace. You name the target by launching it (`./bin/exectop -- npm ci`), by container (`yeet run . -- --container api`), or by pid (`yeet run . -- --pid 4242`), and the [scope section of the README](https://github.com/yeet-src/exectop#choosing-what-to-watch) is explicit that those three modes promise different things.

The fold is the part that has no equivalent in the other routes. Rather than a line per event, `exectop` keys each exec on the command plus its flag names, drops positional paths and deduplicates repeated flags, so `cc1 -quiet a.c` and `cc1 -quiet b.c` collapse into one row carrying a count while `cc1 -O2` stays separate. A demo workload that produced 7,745 lines of exec log reads as eight rows once the repetition is grouped. It runs in a terminal and refuses to start without one, which is a real constraint in CI, and the headless path exists for exactly that reason (see below). It is [built on yeet](https://yeet.cx/docs/?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux), a JavaScript runtime for Linux ops, so the folding, the buckets and the outlier scoring are JavaScript you can edit rather than a fixed feature list compiled into a binary.

### bcc execsnoop: a flat line per exec, host-wide and greppable

`execsnoop` is the tool everyone reaches for first and it is still the right answer for a whole class of question. Brendan Gregg's [original writeup](https://www.brendangregg.com/blog/2014-07-28/execsnoop-for-linux.html) describes it as producing "a live log of each process for later study", and the [upstream example output](https://github.com/iovisor/bcc/blob/master/tools/execsnoop_example.txt) shows the shape: PID, PPID, RET and ARGS, one row per exec, on a machine-wide stream. That shape is perfect for piping into `grep`, into a file, or into anything that expects lines. If your question is "did anything anywhere on this box run `wget` in the last hour", nothing beats it.

What it cannot do is scope to a build. The [`execsnoop-bpfcc(8)` man page](https://manpages.debian.org/unstable/bpfcc-tools/execsnoop-bpfcc.8.en.html) lists the whole filter surface: `-u USER` filters by UID, `-n NAME` prints "only commands matching this name (regex)", `-l LINE` matches an argument substring, `-x` includes failed execs, `--cgroupmap` and `--mntnsmap` filter in-kernel by cgroup or mount namespace, and `-P PPID` is documented as "Trace this parent PID only." Parent, not ancestor. That distinction is the whole problem and it gets its own section below.

### bpftrace: a one-liner when you want your own fields

`bpftrace` will attach to `tracepoint:sched:sched_process_exec` and print whatever fields you ask for, which makes it the right tool when the shape you want does not exist yet: a histogram of exec depth, a count keyed on `comm`, a per-minute rate. Its [`-p` flag](https://github.com/bpftrace/bpftrace/blob/master/man/adoc/bpftrace.adoc) is documented to "attach to the process with or filter actions by PID", and for tracepoints it "will act like a predicate to filter out events not from that pid", which is a single-pid filter rather than a subtree.

Building a subtree scope in bpftrace means writing the fork bookkeeping yourself: a map keyed on pid, an insert on `sched_process_fork` when the parent is a member, a delete on `sched_process_exit`. That is perhaps twenty lines and it is genuinely educational, and it is also the exact code that `exectop` puts in its BPF object so you do not have to maintain it. Reach for bpftrace when the aggregation you want is unusual; reach for a script when the aggregation you want is "what did this build run".

### strace -f: every syscall for one process, at ptrace cost

`strace -f` is scoped correctly by construction. The [man page](https://man7.org/linux/man-pages/man1/strace.1.html) says `-f` "traces child processes as they are created by currently traced processes as a result of the fork(2), vfork(2) and clone(2) system calls", so the tree is followed without any additional work. It also sees far more than process launches: reads, writes, opens, every syscall the tree makes, which is the reason to use it when your question is about one program's behaviour rather than about which programs ran.

The cost is stated in the same man page: "a traced process runs more slowly than a non-traced one", mitigable with `--seccomp-bpf` but not eliminated. Attaching ptrace to every process in a parallel build is painful in a specific way that is worse than the raw overhead: the output interleaves across pids, a `make -j24` produces syscall traffic in the millions, and finding the four execs you cared about inside it is a text-processing exercise. Filtering with `-e trace=execve` narrows the syscalls, not the volume of processes.

### auditd: durable execve records that survive the reboot

`auditd` is the compliance answer and it is the only route here that gives you a log which outlives the terminal. The [`auditctl(8)` man page](https://man7.org/linux/man-pages/man8/auditctl.8.html) describes the program as loading "discretionary audit rules" into the kernel, and an execve rule takes the familiar `-a exit,always -S execve` form. It supports a `-F` field filter that includes `ppid`, so a narrow rule is possible, and the records land in the audit log where a SIEM can read them next quarter.

Two things make it the wrong shape for a build. The man page notes that "syscall rules get evaluated for each syscall for every program", so the rule is a property of the machine rather than of your job, and the output is a system-wide stream you filter afterwards rather than a scope you set beforehand. The other is format: an audit record per exec, with the argv split across `a0`, `a1`, `a2` fields, is designed to be parsed rather than read. If your goal is an answer in the next ninety seconds, that is the wrong end of the trade.

### set -x, make --trace and Actions debug logging: what the script said it would run

This is the incumbent advice and it deserves fair treatment, because it costs nothing and it is often enough. The Bash manual defines [`set -x`](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html) as printing "a trace of simple commands, `for` commands, `case` commands, `select` commands, and arithmetic `for` commands and their arguments or associated word lists to the standard error after they are expanded and before they are executed". GNU make's [`--trace`](https://www.gnu.org/software/make/manual/html_node/Options-Summary.html) is shorthand for `--debug=print,why`, and `-n` prints the recipe without running it. On GitHub Actions, [`ACTIONS_STEP_DEBUG` and `ACTIONS_RUNNER_DEBUG`](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) add step and runner diagnostics to the job log.

Every one of them reports intent, one layer at a time. `set -x` shows what the shell expanded; it does not show what the Python script that shell invoked went on to exec, unless that script also traces itself. A build is a stack of programs each written in a different language, and shell tracing gives you visibility into exactly the layers that happen to be shell. The kernel does not have that gap: `sched_process_exec` fires for the Node child of the Python child of the Make recipe with the same fidelity as for the top-level command.

### pstree and ps -ejH: the tree as it looks right now

[`pstree`](https://man7.org/linux/man-pages/man1/pstree.1.html) "shows running processes as a tree", rooted at a pid you name or at init, and `ps -ejH` prints the same relationship in a flat listing. For a live service, a stuck job, or a runaway parallel build that is still running, this is the fastest orientation available and it needs nothing installed.

It is a snapshot, and build processes are the worst possible subject for a snapshot. A `cc1` invocation for one source file lives for tens of milliseconds. Run `pstree` during a compile and you will see whichever handful of processes happened to exist at that instant, with no record of the several hundred that came before and no way to know whether the one you are hunting was among them. Sampling `pstree` in a loop is the usual next idea, and it trades a guaranteed miss for a probabilistic one.

### Socket.dev, Snyk and npm audit: what the package is before it runs

Package scanners answer a different question and they answer it earlier, which is a genuine advantage. [`npm audit`](https://docs.npmjs.com/cli/v10/commands/npm-audit) "submits a description of the dependencies configured in your project to your default registry and asks for a report of known vulnerabilities", checking against advisories rather than against behaviour. [Snyk Open Source](https://snyk.io/product/open-source-security-management/) does software composition analysis over your manifests, scoring findings against exploit maturity and reachability. [Socket](https://socket.dev/glossary/postinstall-scripts) analyses the package itself, and states plainly that it "examines the behavior of packages, detecting the use of risky APIs or unexpected behaviors" before installation.

They share one boundary, and it is the reason runtime observation is a separate control rather than a redundant one: they tell you about the package, not about this install on this machine. A vendor-neutral write-up of the split puts it as "package scanning checks code and metadata before deployment" while "runtime monitoring watches what the package actually does during install and execution", and its concrete runtime advice is to watch for [unexpected child process spawning and outbound connections during install or test runs](https://nhimg.org/faq/what-is-the-difference-between-package-scanning-and-runtime-monitoring-for-npm-s/). Use both. A scanner blocks a merge, which exec tracing cannot do, and exec tracing sees the postinstall that nobody has catalogued yet, which a database cannot.

## My build is slow and the Makefile does not explain where the time goes. How do I see what it actually ran?

Watch the build under a tracer scoped to it, and read the counts rather than the events. The finding is almost always one of two shapes: something running per-file that should run once, or a step running twice because two targets both depend on it. Both are invisible in a wall-clock timing breakdown and obvious in a fold list, because a fold list is sorted by how many times each kind of command ran. Under `exectop`, a real capture reads as 425 execs in 42 seconds at 7.6 per second, with `echo` at ×124, `date` at ×90, `sleep` at ×46, then `as` at ×27, `gcc` at ×26 and `cc1` at ×26 as the actual compile work.

```sh
curl -fsSL https://yeet.cx | sh     # install yeet, once
./bin/exectop -- make -j4           # run the build, watch every process it starts
```

The second column worth reading is `fork→exec`, the median time between a process being created and its program starting, which in that same capture runs 149µs for `echo`, 137µs for `date` and 243µs for `gcc`. That number separates a slow program from a slow launch. A build where every row shows a normal fork-to-exec median and the wall clock is still bad has a slow program in it; a build where the medians climb as concurrency rises is spending its time on process creation, which is a different fix (fewer, larger invocations) than optimising any single tool.

## Why does execsnoop print every exec on the box, and how do I limit it to one build?

You cannot, with the flags `execsnoop` has. The [man page](https://manpages.debian.org/unstable/bpfcc-tools/execsnoop-bpfcc.8.en.html) filter surface is `-u USER`, `-n NAME`, `-l LINE`, `-x`, `--cgroupmap MAPPATH`, `--mntnsmap MAPPATH` and `-P PPID`, and none of them expresses "this process and its descendants". The two that come closest are worth knowing precisely. `--cgroupmap` traces "cgroups in this BPF map only (filtered in-kernel)", which is a real scope if your build already runs in its own cgroup, as it does inside a container. `-P PPID` traces "this parent PID only", which is one generation.

The general fix is to make membership propagate in the kernel at `fork`, before the child has a chance to exec anything. Seed a map with one root pid; on `sched_process_fork`, if the parent is in the map, insert the child; on `sched_process_exit`, remove it. Then the exec filter is a single hash lookup against that map. This is what `exectop` does with a `traced` hash of tgid to depth, and it is the same subtree-scoping move used to follow an AI coding agent's session in [an earlier post on auditing what an agent actually ran](https://yeet.cx/topical-takes/audit-what-ai-agent-ran-linux?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux), applied to a build instead of a session.

Scope also decides what the tool can honestly promise, which is a distinction the flags never surface. Launching the target under the probe means it is held stopped until the probe attaches, so there is no window in which it can fork unobserved. Attaching to something already running cannot make that promise: a process that forked its children before you attached is outside the traced set until it forks again. `exectop` prints `complete tree` in the first case and `pre-existing children not tracked` in the second, rather than letting you assume the first.

## Why does execsnoop -P miss gcc, cc1 and as?

Because a build is three or four generations deep and `-P` matches one. Run `make` under `execsnoop -P <make's pid>` and you will see the recipe shells `make` forked, and nothing they went on to run. The compile chain in the capture above is `bash` starting `gcc`, `gcc` starting `cc1` and `as`, so `cc1` and `as` are grandchildren of the shell and great-grandchildren of `make`. Twenty-seven `as` invocations and twenty-six `cc1` invocations, the actual work of the build, are all outside a parent-pid filter.

The failure is quiet, which is what makes it expensive. There is no error and no warning. You get output, it looks like a plausible list of commands, and the thing you were hunting is simply not in it, so the natural conclusion is that the build did not run it. Piping the host-wide stream into `grep` has the opposite failure: you see everything the machine did, including the other seven jobs on the same runner, and you cannot tell which pids belonged to your build without reconstructing the tree from PPID columns after the fact, by which time the intermediate processes are gone.

## The pipeline is green locally and red in CI. How do I diff what each one ran?

Capture the exec list on both machines and compare the sets rather than the logs. Differences that produce this failure mode almost always show up as a row that exists in one capture and not the other: a different compiler on the path, a fallback branch taken because a tool was missing, a `python3` where the other box used `python3.11`, a download step that only fires when a cache misses. The build logs will not show it, because both runs printed the same lines from the same script.

```sh
yeet run src/probes/capture.js -- $$ 60 > local.txt     # on the laptop
yeet run src/probes/capture.js -- $$ 60 > ci.txt        # in the job
diff local.txt ci.txt
```

The headless capture path prints totals, a bucket breakdown, the findings and the top folds as text, then exits, which is what makes it diffable at all. A folded list is a much better diff subject than a raw event stream for the same reason it is a better read: two runs of the same build produce the same rows in a slightly different order with slightly different counts, so `diff` over an event log is all noise and `diff` over a fold list is the one row that changed. Note that the fold key ignores positional paths, so a build compiling different files still folds to the same row and the diff stays quiet about the parts that were supposed to differ.

## I am about to bake an npm package into an image. How do I see what its install scripts run?

Run the install under a tracer and read what it launched, because the scanner and the tracer answer different halves of the question. `npm install` executes lifecycle scripts from the package by default; the [`ignore-scripts` config](https://docs.npmjs.com/cli/v10/using-npm/config) defaults to false, documented as "if true, npm does not run scripts specified in package.json files", which is the blunt instrument and breaks any package with a native build step. The observation route keeps the install working and records what it did.

```sh
./bin/exectop -- npm install     # or: npm ci, or your package manager of choice
```

What you are looking for is a process the install had no reason to start. A `node-gyp` chain, `python3`, `make` and `cc1plus` are what a native module looks like and they will dominate the fold list by count. A single `curl` fetching from a host nobody mentioned, a `chmod` widening permissions on something in `/tmp`, or a read against a credential path is the other shape, and it will be near the bottom of a chronological log and at the top of a ranked one. If you want the version of this demonstration where a deliberately obfuscated step gets caught reaching for credentials, [the GitHub Actions audit post](https://yeet.cx/blog/audit-actions-runner?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux) already walks through one with `execsnoop` and `opensnoop`, and that beat does not need repeating here.

Be clear about what this is not. It is not a gate. There are no rules to configure, no database of known-bad releases, and nothing is blocked, delayed or modified. A package that does its damage inside Node without launching a program is invisible: fetching a URL with `fetch()` looks like nothing at the exec layer, while fetching it with `curl` is a row. Treat the output as a look at what happened on this machine just now, and keep the scanner for the part where a merge gets stopped.

## How do I rank unusual commands without flagging half of an ordinary build?

Rarity alone does not work, and the measurement that proves it is worth carrying: against a real `npm install`, 11 of 34 distinct commands ran exactly once. A rule that flags anything running once would light up a third of an ordinary install, and a panel that is wrong a third of the time gets ignored inside a week. Anyone building this heuristic themselves lands on rarity first, and then has to add the second half.

The second half is behaviour observed in the arguments. `exectop` shows a row in its `doesn't fit` panel only if it ran three times or fewer **and** matched one of seven observed behaviours:

- **Fetching from the network.** A `curl` or `wget` in a step whose job is compilation, where the URL is right there in the argv.
- **Piping a download into a shell.** The `curl … | sh` shape, which is a legitimate install idiom and a bad thing to find inside a dependency's postinstall.
- **Evaluating constructed input.** A decode or an `eval` whose argument was assembled rather than written.
- **Widening permissions.** A `chmod` that adds bits, especially on something just written.
- **Changing privileges.** A jump to another user or a setuid path in a build step that had no reason to change identity.
- **Touching credential paths.** A read against `~/.ssh`, a cloud credentials file, or a token store.
- **Unpacking into `/tmp`.** An archive expanded somewhere outside the build tree.

Every reason names something present in the arguments rather than a guess about intent, and the threshold does the rest of the work: a build that legitimately fetches six times stays silent, because six is that build's normal. What the rule cannot do sits in the same place as its design. These are pattern matches on observed command lines, so anything that renames a binary, assembles its argument string at runtime, or avoids launching a process at all will not be flagged. It surfaces what happened, and it is not a control that an adversary can be prevented from evading.

## Can I run exec tracing in a CI job with no terminal?

Yes, but check which half of the tool you are running, because the TUI and the data layer have different requirements. `exectop`'s terminal interface needs a real TTY and refuses to start without one, which is correct behaviour for a program that reflows on resize, and useless in a job whose stdout is a log file. The data layer runs standalone and prints a plain-text report:

```sh
yeet run src/probes/capture.js -- <root-pid> 30
```

It seeds the traced set from the root pid you give it, aggregates for the number of seconds you name, prints the totals, the bucket breakdown, the findings and the top folds, then exits. There is no `--json` mode today, so anything machine-read is parsing text or adding a sink in the ring-buffer callback. The other routes have their own CI shapes: `execsnoop` redirects to a file happily, `auditd` is already writing to one, and `strace -f -o trace.log` works but leaves you with a file measured in hundreds of megabytes for a real build.

One thing that is not required, and is worth stating because it is the usual blocker on a managed runner: there is no agent to deploy and nothing gets added to the process you are watching. Installation is the yeet daemon on the host, and `yeet run` itself is unprivileged and never takes `sudo`. On a runner where you cannot install a daemon at all, `strace -f` on the job's own pid is the route that needs the least from the machine, and you pay for it in output volume and in the slowdown the man page warns about.

## Does tracing every exec slow down a build server?

It depends entirely on where the filtering happens, and the three routes are not close. A ptrace-based tracer stops and resumes the target around syscalls, which is why the strace man page says a traced process runs more slowly. An audit rule is evaluated in the kernel for every matching syscall from every program on the box, whether or not you care about that program. A tracepoint-based probe with an in-kernel filter does one hash lookup on an exec by a process outside your scope and returns, so the cost tracks the application you are watching rather than total activity on the host. Kernel documentation describes a disabled tracepoint as costing "a tiny time penalty (checking a condition for a branch)".

Exec is also a rare event by the standards of kernel tracing. A busy build is hundreds of execs per second, where a network path handling the same instrumentation technique sees tens of thousands of packets. The ceiling that does exist is a delivery ceiling rather than a CPU one: `exectop` measures a ring-buffer limit around 2,700 execs per second, because each record carries a 1 KiB argument window and the ring is bound by bytes moved. Under a deliberate fork storm of 96,000 execs across 24 workers, 36,000 were captured and 60,000 dropped. The drop count is reported rather than hidden, the verdict line reads `59,431 dropped`, and the headless report marks its numbers as a floor. A real `npm install` runs at tens per second and never approaches it; a `make -j24` on a large machine can.

## My container is misbehaving and the logs say nothing. How do I see if it is shelling out?

Attach to the container's process tree and read the answer, including the empty one. Container mode resolves the container name to its root pid and scopes by cgroup where one resolves, so the question becomes "what is this container launching" rather than "what is this host doing".

```sh
yeet run . -- --container api
```

There are two useful outcomes and one of them is silence. A list tells you the container is shelling out and to what, which usually names the culprit directly: a health check spawning a process per interval, a library shelling out to `git`, a sidecar-shaped behaviour hiding inside the main container. Silence tells you nothing in that container is starting processes, which means the problem is inside the application, and that is a real result rather than a failed measurement. A long-running service that has finished starting up execs almost nothing, so an empty screen is the expected state for a healthy web server and a meaningful one for a suspect container.

The caveat belongs right here, at the point of decision, because it decides whether the silence means anything. Attaching to a running container only sees what it starts from that point on, since membership propagates at `fork`. A supervisor that forked all its workers at startup shows nothing until it forks again, and the status bar says `pre-existing children not tracked` rather than implying the tree is complete. If the container is restartable, restarting it under launch mode removes the ambiguity; if it is not, treat silence as weaker evidence than a list.

## What will exec tracing never show me, whichever tool I pick?

The boundary is the same for every tool on this page, because it is a property of the hook rather than of the implementation. `sched_process_exec` fires when a program is installed into a process, so anything that never becomes a program is outside the frame. In practice that means five things, and knowing them is what keeps a clean screen from being read as a clean bill of health.

1. **Work that happens inside an interpreter.** A dependency that opens a socket from Node, decodes a payload in Python, or writes a file from the JVM without launching a program produces no exec at all.
2. **What a process touched or sent.** Exec tracing records launches. For file access, [`agent-lock`](https://github.com/yeet-src/agent-lock) is the yeet script that hooks `lsm/file_open` and returns `-EPERM` for a path outside a jail, which is enforcement rather than observation. For HTTP see [`container-traffic`](https://github.com/yeet-src/container-traffic), and for raw packets [`pktscope`](https://github.com/yeet-src/pktscope).
3. **Arguments past the copy window.** `exectop` copies a fixed 1024-byte window of the argument blob and marks the record truncated, so a very long compiler invocation is cut off while the program, its flags and the timing stay correct.
4. **Process names past 16 bytes.** `comm` is 16 bytes in the kernel, so long names arrive truncated, and that truncation is the kernel's rather than the tool's.
5. **Anything after the session ends.** There is no retention, no aggregation across machines and no alerting in a terminal tool. One host, one session, gone when you quit. `auditd` is the route that answers the durability question, at the cost of everything else on this page.

Where a limit has a sibling that covers it, take the sibling rather than stretching the tool. CPU profiling of one process is [`hotspot`](https://github.com/yeet-src/hotspot), which deliberately excludes forked children, exactly inverting what a process-launch monitor covers. That inversion is a good way to check which question you are actually asking: if you care about what one program spends its cycles on, you want a profiler, and if you care about which programs existed, you want the exec stream.

## The bottom line: pick the tool by scope and volume, not by capture

Every route here captures execs correctly. They differ on the two things that decide whether you get an answer: whether you can scope to one build and its grandchildren, and whether anything reduces the volume once you have. `strace -f` scopes perfectly and reduces nothing, at ptrace cost. bcc's `execsnoop` reduces nothing and scopes to one parent generation, which is the wrong shape for a build tree, and it remains the best flat, greppable, host-wide stream there is. `auditd` is the answer when the record has to survive the reboot and be parsed by something else next quarter. `set -x`, `make --trace` and Actions debug logging report intent, which is a genuinely different fact from execution and often the one you actually want.

[`exectop`](https://github.com/yeet-src/exectop) is the answer to one question: what did this application launch, when the answer is hundreds of events and you need it grouped. It scopes by launch, container or pid, propagates membership at `fork` so grandchildren are included, folds repeats into one row per kind, and ranks the rare-plus-suspicious above the rest. It blocks nothing, keeps nothing after you quit, and cannot see a package that never execs. Where you need a boundary rather than a record, [`agent-lock`](https://github.com/yeet-src/agent-lock) enforces on file opens through BPF-LSM, and where you need a merge stopped, [Socket](https://socket.dev/glossary/postinstall-scripts) and [Snyk](https://snyk.io/product/open-source-security-management/) do the pre-execution half that no runtime tool can.

## Frequently asked questions

### Do I need root to trace execs on a build machine?

It depends on the route. `auditd` rules and bcc tools require root. `strace` needs ptrace permission on the target, which usually means the same user or root, and can be blocked outright by a hardened `ptrace_scope` setting. With yeet, the daemon handles the privileged load and `yeet run` is unprivileged, so a `sudo yeet run` in an example is a mistake rather than a requirement.

### Does exec tracing work inside Docker and on Kubernetes nodes?

Tracepoint-based tracing runs on the host kernel, which containers share, so execs inside a container are visible from the host with no changes to the container image. That is what makes it usable on distroless images where there is no shell to exec into. Resolving a container name to its process tree is the part that needs help: `exectop`'s container mode needs Docker reachable from the host running the probe, because it looks the root pid up through the system graph.

### Which kernel version do I need?

For CO-RE-based eBPF tooling the practical floor is a kernel built with BTF, meaning `CONFIG_DEBUG_INFO_BTF=y`, which is the default on current Arch, Fedora, Ubuntu and Debian. `exectop` is verified on 6.1, 6.6, 6.12 and bpf-next, and CO-RE means no per-kernel recompile. The `sched_process_exec`, `sched_process_fork` and `sched_process_exit` tracepoints are long-standing and present across that range.

### Will a renamed or statically linked binary still appear?

Yes. The record comes from the kernel at exec time, so a binary copied to another name, a static build, or a script invoked through an interpreter all produce an exec with the arguments the kernel was handed. What renaming defeats is not the capture but the heuristics: a ranking rule that matches on the program name stops matching when the program is called something else, which is why an empty findings panel is not evidence of nothing happening.

### Does exec tracing capture the environment variables a command was given?

`exectop`'s record carries pid, ppid, `comm`, argv, depth and the fork-to-exec time, not the environment. That cuts both ways. A secret passed as an environment variable does not appear in the capture, and a secret passed on a command line does, in full, in the argv column, which is worth knowing before pasting a capture into a ticket.

### What happens to a process that forks but never execs?

It is invisible to an exec tracer, and this is a real gap rather than a corner case. A worker pool that forks copies of itself and never replaces their memory with a new program produces exactly one exec, at the top. The bcc example output notes the same limitation. If the fork itself is what you need to see, the `sched_process_fork` tracepoint is the hook, and `pstree` on the live process is the zero-install version of the same answer.

### Can I keep the exec log after the job finishes?

Not from a terminal tool, which keeps nothing after you quit. In CI the practical answer is to redirect the headless report to a file and upload it as a build artifact, which gives you a per-job record without a retention system. If the requirement is fleet-wide retention with a query language across machines, that is `auditd` feeding a log pipeline, or a commercial platform, and neither of those is what a single-host terminal tool is for.

## Sources

- **[execsnoop-bpfcc(8) man page (Debian unstable, bpfcc-tools)](https://manpages.debian.org/unstable/bpfcc-tools/execsnoop-bpfcc.8.en.html)** — the complete filter surface for bcc's execsnoop: `-u USER` filter by UID or username, `-n NAME` "only print command lines matching this name (regex)", `-l LINE` matching an argument substring, `-x` "include failed exec()s", `--cgroupmap MAPPATH` and `--mntnsmap MAPPATH` "filtered in-kernel", and `-P PPID` documented as "Trace this parent PID only", with no option expressing a process subtree.
- **[execsnoop for Linux (Brendan Gregg, 2014)](https://www.brendangregg.com/blog/2014-07-28/execsnoop-for-linux.html)** — the canonical reference for the tool and the shape being contrasted: PID, PPID and ARGS columns, one row per exec, described as "a live log of each process for later study", traced as events happen unless in-kernel buffering is requested; host-wide by default with regex name matching as the narrowing mechanism.
- **[bcc execsnoop example output (iovisor/bcc)](https://github.com/iovisor/bcc/blob/master/tools/execsnoop_example.txt)** — upstream example output showing COMM, PID, RET and ARGS as a flat per-event stream; documents `-P PPID` as "trace this parent PID only" and `-n NAME` as regex matching on any arg; records the structural limitation that the tool follows the fork-then-exec sequence, so worker processes that fork without ever calling exec never appear.
- **[strace(1) man page (man7.org)](https://man7.org/linux/man-pages/man1/strace.1.html)** — `-f` "traces child processes as they are created by currently traced processes as a result of the fork(2), vfork(2) and clone(2) system calls", which is correct subtree scoping by construction; states that "a traced process runs more slowly than a non-traced one" and that the impact "can be mitigated by using the --seccomp-bpf option"; describes the tool as intercepting and recording syscalls and signals.
- **[auditctl(8) man page (man7.org)](https://man7.org/linux/man-pages/man8/auditctl.8.html)** — auditctl "is used to configure kernel options related to auditing, to see status of the configuration, and to load discretionary audit rules"; syscall rules take the `-a exit,always -S execve` form, `-F` supports field filters including `ppid`, and the page states that "syscall rules get evaluated for each syscall for every program", which is why an audit rule is a property of the host rather than of one job.
- **[The Set Builtin (Bash Reference Manual, GNU)](https://www.gnu.org/software/bash/manual/html_node/The-Set-Builtin.html)** — defines `set -x` as printing "a trace of simple commands, `for` commands, `case` commands, `select` commands, and arithmetic `for` commands and their arguments or associated word lists to the standard error after they are expanded and before they are executed", preceded by the expanded value of `PS4`; the trace is emitted by the shell about its own expansions, not about what a child program later executes.
- **[pstree(1) man page (man7.org)](https://man7.org/linux/man-pages/man1/pstree.1.html)** — "pstree shows running processes as a tree. The tree is rooted at either pid or init if pid is omitted"; establishes the route as a snapshot of processes alive at the moment of the call, which is the property that makes it unsuitable for build steps whose processes live for tens of milliseconds.
- **[Enabling debug logging (GitHub Actions docs)](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging)** — `ACTIONS_RUNNER_DEBUG` enables runner diagnostic logging covering "the runner process log, which includes information about coordinating and setting up runners to execute jobs" and the worker process log; `ACTIONS_STEP_DEBUG` shows "more debug events" in step logs; both are set as repository secrets or variables, and both report the runner's own view rather than process execution.
- **[npm-audit (npm CLI docs, v10)](https://docs.npmjs.com/cli/v10/commands/npm-audit)** — "the audit command submits a description of the dependencies configured in your project to your default registry and asks for a report of known vulnerabilities"; the payload goes to the registry's bulk advisory endpoint at `/-/npm/v1/security/advisories/bulk`, packages with no version field are excluded, and the response carries advisory objects with severity, vulnerable ranges and IDs, from which npm computes meta-vulnerabilities.
- **[Postinstall scripts (Socket glossary)](https://socket.dev/glossary/postinstall-scripts)** — defines postinstall scripts as "automation scripts that run immediately after a package has been installed" and names three risks: downloading and installing malicious software, exfiltrating "sensitive data from the project or the developer's environment", and modifying the developer's environment; states Socket "examines the behavior of packages, detecting the use of risky APIs or unexpected behaviors" and audits every package on npm, which is pre-execution analysis.
- **[Package scanning versus runtime monitoring for npm supply chain attacks (NHI Mgmt Group FAQ)](https://nhimg.org/faq/what-is-the-difference-between-package-scanning-and-runtime-monitoring-for-npm-s/)** — states that "package scanning checks code and metadata before deployment" while "runtime monitoring watches what the package actually does during install and execution", frames them as layered rather than alternative controls, and lists the runtime indicators to watch for during install or test runs: unexpected child process spawning, outbound connections to unfamiliar hosts, secret reads and unauthorized environment variable access.

## Related resources

- **[exectop on GitHub](https://github.com/yeet-src/exectop)** — the source, the three-line quick start, the 30-second primer on fork and exec, and the panel-by-panel description of what the screen is showing.
- **[exectop: choosing what to watch](https://github.com/yeet-src/exectop#choosing-what-to-watch)** — launch, container, pid and host mode side by side, and what each one can promise about completeness before you rely on a quiet screen.
- **[exectop: what it can't see](https://github.com/yeet-src/exectop#what-it-cant-see)** — the limits in the authors' own words: work that never execs, the roughly 2,700 execs per second delivery ceiling with its measured drop numbers, the 1 KiB argument window, and pre-existing children under an attach.
- **[exectop: how it works](https://github.com/yeet-src/exectop#how-it-works)** — the three tracepoints, the four BPF maps, why the argument blob is read from `mm->arg_start` rather than by walking userspace pointers, and why a stable tracepoint beats hooking `sys_enter_execve`.
- **[How to audit what an AI coding agent actually ran on Linux](https://yeet.cx/topical-takes/audit-what-ai-agent-ran-linux?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux&utm_content=related)** — the same subtree-scoping move applied to an agent session instead of a build, with claudefeed, auditd, strace and OpenTelemetry compared on whether they record subprocesses at all.
- **[How do I audit a GitHub Action with yeet?](https://yeet.cx/blog/audit-actions-runner?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux&utm_content=related)** — the execsnoop and opensnoop walkthrough against a self-hosted runner, where an innocuous-looking community action turns out to be reaching for credentials.
- **[agent-lock on GitHub](https://github.com/yeet-src/agent-lock)** — the enforcement side of the same problem: a BPF-LSM program on `lsm/file_open` that confines a process tree to one directory and returns `-EPERM` for anything outside it.
- **[yeet documentation](https://yeet.cx/docs/?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux&utm_content=related)** — the runtime underneath, for editing the folding and ranking rules or writing your own probe rather than using a ready-made script.

See your machine the way the kernel sees it.

Built with [yeet](https://yeet.cx/docs/?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux), a JS runtime for writing eBPF programs on Linux machines. Join us on [discord](https://discord.gg/JxVseaAVAU?utm_source=blog&utm_medium=post&utm_campaign=how-to-see-every-command-your-build-runs-in-ci-on-linux).
