Monitoring Commands Executed by Claude Code with eBPF
Overview
This article introduces how to use eBPF technology to monitor all Linux commands executed by Claude Code processes and their child processes. By hooking the sched_process_exec tracepoint, we can capture all execve system calls within the target process tree, enabling comprehensive monitoring of commands executed by AI agents.
Technical Principles
Claude Code's Command Execution Model
Claude Code is a Node.js-based AI programming assistant. When it needs to execute shell commands, it follows this process:
Claude Code (Node.js, PID=1000)
│
└─ fork() → Child process (PID=1001, PPID=1000)
│
└─ execve("/bin/sh", ["-c", "git status"])
↓
BPF capture: Detected descendant of target process
↓
Output: git statusWhy Use sched_process_exec?
Linux provides multiple hook points for monitoring process execution:
| Hook Point | Pros | Cons |
|---|---|---|
sys_enter_execve | Can get original arguments | Arguments in user space, complex to read |
sys_exit_execve | Triggered after execution | Also triggered for failed execve |
sched_process_exec | Stable, easy argument reading | Only triggered on successful execution |
We choose sched_process_exec because it triggers after a successful execve, when the new process's memory mapping is established, making it convenient to read command line arguments.
Implementation Details
1. Data Structure Definition
#define MAX_ARGS_SIZE 256
#define MAX_FILENAME_SIZE 256
struct exec_event {
__u32 pid; // Process ID
__u32 ppid; // Parent process ID
char comm[16]; // Process name
char filename[MAX_FILENAME_SIZE]; // Executed program path
char args[MAX_ARGS_SIZE]; // Command line arguments
};2. BPF Map Definitions
// Command execution event ring buffer
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} exec_events SEC(".maps");
// PID filter configuration (shared with SSL monitoring)
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u32);
} target_pid_map SEC(".maps");3. Process Tree Check Function
Commands executed by Claude Code may be nested child processes, so we need to traverse up the process tree to determine if it belongs to the target process:
// Check if current process is a descendant of target (search up to 10 levels)
static __always_inline bool is_descendant_of_target(__u32 target_pid) {
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
// Traverse up the process tree
#pragma unroll
for (int i = 0; i < 10; i++) {
__u32 ppid = BPF_CORE_READ(task, real_parent, tgid);
// Found target process
if (ppid == target_pid) {
return true;
}
// Reached init process, stop traversal
if (ppid == 0 || ppid == 1) {
return false;
}
// Continue traversing up
task = BPF_CORE_READ(task, real_parent);
if (!task) {
return false;
}
}
return false;
}Key Points:
#pragma unroll: Unroll the loop since BPF doesn't support dynamic loopsBPF_CORE_READ: Safely read kernel data structures- 10-level limit: Balances depth traversal and BPF instruction limits
4. Tracepoint Hook Implementation
SEC("tp/sched/sched_process_exec")
int trace_execve(struct trace_event_raw_sched_process_exec *ctx) {
struct task_struct *task;
__u32 pid, ppid;
// Get process information
pid = bpf_get_current_pid_tgid() >> 32;
task = (struct task_struct *)bpf_get_current_task();
ppid = BPF_CORE_READ(task, real_parent, tgid);
// Check if descendant of target process
__u32 key = 0;
__u32 *target = bpf_map_lookup_elem(&target_pid_map, &key);
if (!target || *target == 0) return 0;
if (!is_descendant_of_target(*target)) return 0;
// Allocate event
struct exec_event *e = bpf_ringbuf_reserve(&exec_events, sizeof(*e), 0);
if (!e) return 0;
// Fill basic event data
e->pid = pid;
e->ppid = ppid;
bpf_get_current_comm(&e->comm, sizeof(e->comm));
// Read command path - use filename provided by tracepoint
unsigned int fname_off = ctx->__data_loc_filename & 0xFFFF;
bpf_probe_read_str(e->filename, sizeof(e->filename), (void *)ctx + fname_off);
// Read complete command line arguments (from mm->arg_start)
struct mm_struct *mm = BPF_CORE_READ(task, mm);
if (mm) {
unsigned long arg_start = BPF_CORE_READ(mm, arg_start);
unsigned long arg_end = BPF_CORE_READ(mm, arg_end);
unsigned long arg_len = arg_end - arg_start;
// Limit to buffer size
if (arg_len > MAX_ARGS_SIZE - 1)
arg_len = MAX_ARGS_SIZE - 1;
if (arg_len > 0) {
__builtin_memset(e->args, 0, MAX_ARGS_SIZE);
// Read entire argument region
long ret = bpf_probe_read_user(e->args, arg_len, (void *)arg_start);
if (ret == 0) {
// Replace null bytes with spaces for readability
#pragma unroll
for (int i = 0; i < MAX_ARGS_SIZE - 1; i++) {
if (i >= arg_len - 1) break;
if (e->args[i] == '\0')
e->args[i] = ' ';
}
e->args[arg_len] = '\0';
}
}
}
bpf_ringbuf_submit(e, 0);
return 0;
}5. Command Line Argument Reading Technique
Linux process command line arguments are stored in the memory region from mm->arg_start to mm->arg_end, with arguments separated by NULL bytes:
Memory layout: "git\0status\0--short\0"
↓ Replace NULL with space
Display result: "git status --short"Why Not Use bpf_probe_read_user_str?
bpf_probe_read_user_str stops at the first NULL, reading only the first argument. We need to use bpf_probe_read_user to read the entire buffer, then manually replace NULLs with spaces.
6. User Space Event Handling
static int handle_exec_event(void *ctx, void *data, size_t data_sz) {
const struct exec_event *e = data;
char timestamp[32];
get_timestamp(timestamp, sizeof(timestamp));
// Check if sh -c or bash -c command, extract actual command
if (strstr(e->filename, "/sh") || strstr(e->filename, "/bash")) {
const char *cmd_start = strstr(e->args, "-c ");
if (cmd_start) {
cmd_start += 3; // Skip "-c "
printf("[%s] EXEC | %s\n", timestamp, cmd_start);
return 0;
}
}
// Directly display command and arguments
printf("[%s] EXEC | %s\n", timestamp, e->args);
return 0;
}Shell Command Optimization:
Claude Code typically executes commands via sh -c "actual command". We extract the actual command content after -c for clearer output.
Process Execution Flow Diagram
User specifies target PID: 1000 (Claude Code)
│
▼
┌───────────────────┐
│ sched_process_exec │
│ tracepoint │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Get current │
│ process task_struct│
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ is_descendant_of │──No──→ Ignore
│ target(1000)? │
└─────────┬─────────┘
│Yes
▼
┌───────────────────┐
│ Read filename │
│ from tracepoint ctx│
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Read args from │
│ mm->arg_start │
└─────────┬─────────┘
│
▼
┌───────────────────┐
│ Submit event to │
│ ring buffer │
└───────────────────┘Usage
# Monitor all commands executed by specified PID
sudo ./claude_trace -p <CLAUDE_CODE_PID>
# Monitor command execution only (disable SSL and Bash)
sudo ./claude_trace -p <PID> -S -B
# Monitor command execution and Bash (disable SSL)
sudo ./claude_trace -p <PID> -SOutput Example
Claude Trace Monitor
PID: specified
SSL events: disabled
EXEC events: enabled
BASH events: enabled
✅ EXEC: command capture enabled for PID 12345
Monitoring... (Ctrl+C to stop)
[14:30:01] EXEC | git status
[14:30:02] EXEC | ls -la /home/work
[14:30:03] EXEC | find . -name "*.c" | grep main
[14:30:04] EXEC | make clean && make
[14:30:05] EXEC | cat /etc/passwd
Shutting down...
Total: 0 SSL packets, 0 bytes, 5 exec commands, 0 bash commandsSecurity Application Scenarios
- AI Agent Behavior Auditing: Record all system commands executed by AI assistants
- Security Compliance: Ensure AI doesn't execute dangerous commands (e.g.,
rm -rf) - Debug Analysis: Understand AI assistant workflow
- Permission Control: Foundation for implementing command whitelist/blacklist
Notes
- PID Must Be Specified: Command monitoring requires
-pparameter to specify target process - Level Limit: Maximum 10 parent process levels, very deep nesting may miss events
- Argument Truncation: Command line arguments maximum 256 bytes
- Performance Consideration: High-frequency command execution scenarios generate many events