Sandbox and Monitor an AI Agent on Linux

Necco Ceresani
Necco Ceresani··35 min read

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.

Last updated: August 2026

Quick answer. A proxy sees the request and cannot name the process that sent it. That is not a configuration problem, it is where a proxy stands: it receives a connection and reads the bytes, and the sending process's identity is not in the bytes. Microsoft's Dev Proxy states the gap as a CLI flag, since you supply the process list from outside with --watch-pids 870 135100. The kernel does not have that problem, because bpf_get_current_pid_tgid() and bpf_get_current_comm() run in the calling task's context and stamp pid and command name onto the same event as the payload. To see which processes on a host are dialing out right now, with the pid attached, start here: sudo tcpconnect-bpfcc.

I write the kernel side of agent confinement tools at yeet, where thousands of engineers run these scripts on their own machines, and the last stretch of that has been spent on programs that decide what an agent may open and then have to prove what it did. I am not the person who signs off on your organization's AI policy, and I have never had to defend a MITM proxy rollout to a security review board. What I keep running into is narrower and more annoying than a policy question. Someone asks which agent on a shared build host is talking to an endpoint nobody recognizes, and the answer that comes back is a hostname, a byte count, and a timestamp, with no process attached to any of it. The trace is real. The sender is missing.

The five questions below are ranked by how much of the answer survives the agent doing something you did not anticipate, which is the only failure mode that matters for a workload whose whole design is to decide its own next action. That ranking is a judgment from building these tools and watching where the answers break, not a benchmark, and each item says what its position is based on so you can disagree with the order and still use the list. What the five share is that every one of them is a fact about who, and every layer in the usual agent-sandboxing stack is built to answer what.

What does a MITM proxy see when an AI agent makes a request, and what is missing from it?

A proxy sees the full request and response, decoded, and it does not see the process that sent them. Those two facts are the whole shape of the problem, and it helps to draw both sides of the line before ranking what falls on the wrong side of it. A proxy that terminates TLS gives you method, host, path, headers, body, timing and status, which is a great deal of information and more than any kernel hook hands you for free. What it receives is a TCP connection from an address, and the operating system does not attach the sender's pid, command name or cgroup to the packets. The proxy can log that 127.0.0.1 opened a connection to api.anthropic.com. On a host running four agents, six build jobs and a language server, that identifies nothing.

The workaround the field has settled on is to tell the proxy who to watch, from outside. Microsoft's Dev Proxy documents it plainly: "By default, Dev Proxy is registered as a system wide proxy and all requests made by your machine are passed through the proxy," and to narrow that you run devproxy --watch-pids 870 135100 or devproxy --watch-process-names msedge pwsh. Read that as an admission rather than a feature. The pids come from you, which means you already knew which processes to care about, which means the proxy is confirming an attribution you supplied rather than producing one. For an agent that spawns git, npm, curl and a language server as children, the list you supplied is stale the moment the agent starts working.

Underneath the attribution gap sits a second one, which is that routing through the proxy is voluntary. The mechanism is an environment variable, and three documented ways past it need no exploit: a subprocess spawned without the variable inherits a different environment, since subprocess.run(["curl", "https://example.com/"], env={}) is a normal thing for a tool loop to do; raw TCP, UDP and QUIC clients never consult HTTP_PROXY-style variables at all; and anything in NO_PROXY is excluded by design. That writeup's own summary is the sentence worth keeping: "the application-layer hint, however well-intentioned, is policy. The kernel rule is the control."

LayerSees the request contentNames the sending processSurvives a subprocess that ignores it
container-traffic and yeet (uprobe plus kprobe/tcp_sendmsg)Plaintext, for a dynamically linked OpenSSLYes: pid, comm and cgroup id on the same eventYes, the hook is on the kernel path, not the environment
AgentSight (SSL uprobes plus process tree)Yes, including statically linked agentsYes, with a fork and exec process treeYes
mitmproxy and MITM proxies generallyYes, fully decoded, with rewritingNo, only the source address and portNo, requires the variable and a trusted CA
Claude Code's built-in proxyNo by default, hostname onlyNoWithin the sandbox boundary only
Dev ProxyYesOnly the pids you supply with --watch-pidsNo
Container network namespaceNoNo, isolates the stack without identifying the callerYes, but tells you nothing about who

The layer that produces identity for free is the one where the connection is actually made. connect() runs in process context, so a kernel program on that path is executing as the calling task and can simply ask who that is. This is old, boring and well-trodden: tcpconnect from bcc "works by tracing the kernel tcp_v4_connect() and tcp_v6_connect() functions using dynamic tracing" and prints PID and COMM next to SADDR, DADDR and DPORT, at an overhead its own man page calls "negligible" below roughly 1000 connects per second. Nothing about that is new. What is new is that the workload asking the question is now one that picks its own destinations.

1. Sender identity: which process made the outbound connection on this host

The pid of the process that called connect(), available at the moment of the call and nowhere afterwards. It ranks first because it is the only item on this list that no amount of proxy configuration produces, and because it is the question every incident actually opens with. When an egress alert fires on a shared host, "which workload was that" is the first thing asked and the last thing anyone can answer, and Yelp's pidtree-bcc exists for exactly that reason: its README notes that products can flag anomalous outbound requests, but "because of the transient nature of processes, often any useful context is lost by the time investigation can occur." An agent process that ran for ninety seconds and exited has taken its identity with it.

What the kernel sees that your proxy doesn't. A BPF program attached at the connect path runs in the calling task's context, which means the identity helpers are simply available: bpf_get_current_pid_tgid() returns current_task->tgid << 32 | current_task->pid, bpf_get_current_comm() copies "the comm attribute of the current task," and bpf_get_current_cgroup_id() returns "a 64-bit integer containing the current cgroup id based on the cgroup within which the current task is running." None of those are inferences. They are reads of the task struct that is running the syscall. In wssnoop the strongest form of this is a single fexit/tcp_connect program that emits the socket pointer, pid, tid, remote address, remote port and family at one chokepoint covering both IPv4 and IPv6, with no TLS involvement at all. You get the 4-tuple and the dialer together because they were never apart.

Best for. A shared host or build fleet where several agents and their children run as the same user, and the question is which workload opened a connection rather than what it said. It is also the answer that holds when an agent shells out, because the child's connect is its own event with the child's pid on it.

What it costs you. The connect path gives you the destination and the caller and no content whatsoever, so this item answers who and refuses to answer what. Attributing an event to a pid is also a fact about a number, not about a human: mapping pid to team or tenant is work you do in userspace against your own metadata, and on a host where an agent runs as a normal user account, the cgroup id is usually the more durable handle. Our own corpus is a useful warning that pid columns can lie in other contexts, and the retransmit-attribution item in the APM post covers the case where the on-CPU process is the wrong answer because a timer did the work. Connect is the friendly case, because a process really did call it.

How to start. One line, on any host with bcc installed, and it prints every outbound connect with the pid attached:

sudo tcpconnect-bpfcc

-P 443 narrows to a destination port and -p PID narrows to one process tree, which is useful once you have a candidate and want to watch only it.

2. Enforcement and observation drift: the policy engine and the audit log disagree

Two mechanisms deciding and recording the same event, and the gap between them. It ranks second because it is the failure that produces a confident wrong answer rather than no answer, which is worse, and because every architecture that separates the enforcer from the auditor has it by construction. If a proxy allows a hostname and a separate agent log records the request, you have two accounts of one event, written by two components with two views of the world, and reconciling them is a task nobody has budgeted for. The dashboard says the agent did the thing; the enforcer says it allowed something; whether those are the same thing is unproven.

What the kernel sees that your proxy doesn't. One hook can be both. agent-lock puts a single eBPF program on lsm/file_open, which the kernel documents as a place where privileged users "implement system-wide MAC (Mandatory Access Control) and Audit policies using eBPF," where a zero return allows and a negative errno denies. Its README states the reason directly: that hook "sits after the kernel has resolved the path and before the descriptor is returned, holding a struct file. That is the one place where 'which file is this, really' and 'may this process have it' are both answerable, which is why the enforcement and the dashboard can be the same program instead of a policy engine plus an auditor that drift apart." The same call that returns -EPERM writes the record. There is nothing to reconcile because there is one event.

Best for. Anywhere you have to prove after the fact what the boundary actually did, rather than what it was configured to do. That is most of what an internal platform team gets asked for, and it is the reason a decision log written by the decider is worth more than two logs written by observers.

What it costs you. This is filesystem enforcement, and it does not extend to sockets. agent-lock's own README is unambiguous: "The agent's calls to model APIs keep working, which is deliberate, and so would exfiltration over the same socket," and therefore "a filesystem jail is not an exfiltration control." Its enrollment is also the weakest link in its own design and worth naming rather than discovering. Confinement is matched by process name, the shipped wrapper hardcodes --comm omp, and as the README says, "an agent launched under its own name is never enrolled and runs unconfined. That failure mode looks like success: the dashboard is simply empty." The wrapper's ordering guarantee between launching the agent and loading the program is a two-second sleep in a shell script. Confirm the name enrolled before trusting a quiet dashboard.

For the filesystem half of this argument in full, including the container and Landlock routes and what each refuses, the sandboxing post is the one to read. This post stands on the other side of the boundary it draws.

3. Egress attribution inside a container: the namespace isolated the stack and named nobody

Which process in a container opened a connection, which a container boundary does not tell you and was never built to. It ranks third because provisioning a container is the most common thing a platform team is currently asked to do about agents, and it moves the accountability without moving the visibility. A network namespace isolates "network devices, IPv4 and IPv6 protocol stacks, IP routing tables, firewall rules, the /proc/net directory ... port numbers (sockets)." Every word of that is about the stack. None of it is about the caller. You now have a separate routing table and the same unanswered question, plus a NAT hop that makes the source address less informative than it was.

The relocation cost is real and already argued at length in the sandboxing post, whose summary is that you reconcile git config, registry credentials, toolchain and caches back into the container and "each thing you pass through is a hole in the boundary you just built." Take that as settled. The part not yet said is that the boundary you paid for does not come with attribution attached, and that the consensus reason to reach for a container is a different question entirely. Northflank's guide is direct that "standard containers aren't sufficient for AI-generated code because they share the host kernel," ranking microVMs above gVisor above hardened containers, and an eight-CVE catalog opens on Marina Moore's line that "containers are not a security boundary. They are a mechanism to control resource usage," listing CVE-2024-21626 (Leaky Vessels), CVE-2025-23266 (NVIDIAScape) and CVE-2025-31133 among escapes that all worked because "every container on a host shares the same kernel." Those are arguments about isolation strength, and they are correct. Isolation strength and sender identity are different properties, and buying the first does not deliver the second.

What the kernel sees that your proxy doesn't. A container is a process in a cgroup, and the cgroup id is on the event. container-traffic states the mechanism in its README: "A container is just a process in its own cgroup. Its HTTP requests leave through the same kernel socket calls as any other process; the only thing that ties a request to 'the checkout container' is which cgroup the calling task belongs to." On cgroup v2 the leaf cgroup directory name carries the Docker container id, so a CO-RE read of task->cgroups->dfl_cgrp->kn->name resolves to a container name rather than a number, and traffic with no container behind it buckets as host. That is attribution the namespace itself never offered.

Best for. A multi-tenant build host or a shared node where several teams' agents run in containers and the question is per-tenant rather than per-process. Cgroup id is the more stable handle in that setting, because it outlives the individual pids inside it.

What it costs you. Reaching inside a container is where this gets honest. A uprobe attaches to a file, and bpfman's writeup on the problem explains why that is not a detail: "A container typically has its own mount namespace that is isolated both from those of other containers and its parent," so "to attach a uprobe to a file in a container, we need to have access to that container's mount namespace so we can see the file to which the uprobe needs to be attached," which means setns into that namespace and back. container-traffic does not do this today, and says so: a container shipping its own libssl inside its image is a different library the host-side uprobe cannot reach, so in-container HTTPS is not captured, and the status line distinguishes it as enc(host). Plaintext HTTP from any container is captured, and encrypted traffic from the host is captured. In-container TLS is not.

4. TLS plaintext without a CA in the trust store: what the agent actually sent

The request body an agent sent to a model provider, read at the library boundary before encryption rather than decrypted in the middle. It ranks fourth rather than first because the mechanism is well established and its limits are narrow enough to matter, not because the content is unimportant. The technique itself is settled: a uprobe on SSL_write and SSL_read reads the buffer the application handed the library, which is plaintext by definition, as sslsniff demonstrates by resolving a library path and attaching entry and return probes to those symbols with no key material anywhere in the design. Our own WebSocket post covers the mechanism end to end and is the place to send anyone who wants it explained rather than used.

What is worth costing out here is the alternative, at fleet scale. Terminating TLS means distributing a CA and configuring every runtime to trust it, per runtime, and the recipes make that concrete rather than theoretical. A working mitmproxy setup for a coding agent needs HTTPS_PROXY, HTTP_PROXY and NODE_EXTRA_CA_CERTS together, because "setting only HTTPS_PROXY and HTTP_PROXY will cause Node.js TLS verification to fail." Anthropic's own documentation reaches the same requirement from the vendor side: if your threat model needs more than the default, "configure a custom proxy that terminates TLS and inspects traffic, and install its CA certificate inside the sandbox." For a platform team, that sentence is a security review, a per-runtime configuration change, a CA whose private key is now a fleet-wide liability, and a golden path that engineers route around the first time a Go binary fails verification. Anthropic's docs even note that case: gh, gcloud and terraform "may fail TLS verification under Seatbelt" and are recommended for excludedCommands, which is to say excluded from the sandbox.

What the kernel sees that your proxy doesn't. Identity welded to content. In container-traffic's BPF source, the uprobe/SSL_write handler fills cgroup_id, pid and comm on the same event structure as the payload, because all three helpers run in the context of the process that called the library. A proxy correlating a request to a process after the fact is joining two datasets on a timestamp. Here there is nothing to join.

What it costs you, and the limit that matters most for coding agents specifically. The probes attach by bare soname libssl.so, which means they find dynamically linked OpenSSL and nothing else. That excludes the agents most readers of this post are running. AgentSight's README states the problem precisely: "These applications statically link their SSL library (BoringSSL for Claude/Bun, OpenSSL for all Node.js, both NVM and system installs) into their own binary instead of using system libssl.so, so there's nothing for sslsniff to hook by default." So do not read this section as a claim that pointing a uprobe at libssl.so reads Claude Code's traffic today, because it does not. Attaching to a statically linked binary is possible in principle, by path plus a resolved symbol offset rather than by soname, and AgentSight does exactly that with auto-discovery. That is an implementation gap in the yeet scripts and a solved problem in AgentSight, not a property of eBPF. The other stacks carry their own brittleness: hooking Go's crypto/tls.(*Conn).Write depends on a struct offset read from go1.24 DWARF, and rustls attaches by mangled symbol, so a full binary strip removes what both need.

Note also what none of this touches. No script in the yeet corpus blocks traffic. Every BPF program here is an observer, and the one script on a hook that could drop packets, pktscope on tcx/ingress and tcx/egress, deliberately returns TCX_NEXT on both paths. If you need to stop a connection rather than record it, this is not the layer, and item 5 says what is. Two more absences are worth stating plainly rather than leaving you to discover them: there is no DNS parser and no TLS SNI extraction anywhere in the script corpus, so any question that starts with the hostname an agent looked up before it connected is one this layer cannot answer. Hostname-based allowlisting is what a proxy genuinely does well, which is why Claude Code's sandbox does it that way.

5. Connect-time denial: stopping an agent's outbound connection, not recording it

Refusing the connection before it is established, which is a capability no script in the yeet corpus ships today and several other tools do. It ranks last on this list and it is on the list because leaving it off would misrepresent the shape of the field. Enforcement is the half of the observe-and-enforce argument that the yeet network scripts do not yet have, and a post arguing that enforcement and observation belong in one place has to be straight about where that is not true of its own tooling yet.

What the alternatives do better. Landlock is the strongest honest answer for a single host, and unlike a BPF program it needs no privilege: any process can restrict itself. Since ABI v4 in Linux 6.7 it carries LANDLOCK_ACCESS_NET_CONNECT_TCP, so it can "control inbound and outbound TCP connections according to the source or the destination port." Read that granularity carefully before planning around it, because the kernel documentation defines the right as "connect TCP sockets to the given remote port," which is a port and not a hostname: allowing 443 allows all of it. Claude Code's built-in proxy does enforce a domain allowlist, and its own docs give the honest scope, that the decision comes "from the client-supplied hostname without inspecting TLS," so "code running inside the sandbox can potentially use domain fronting or similar techniques to reach hosts outside the allowlist." For total isolation, bubblewrap gives an unprivileged network namespace with only a loopback device, which is all-or-nothing and, for an agent that has to reach a model API, usually nothing.

What the kernel could do here, stated as capability rather than product. The hooks people reach for to deny a connection are cgroup_sock_addr, cgroup_skb, sockops and the LSM socket hooks, and none of those are in the yeet runtime's attach set, which is Xdp, Tcx, Uprobe, Usdt, Perf and ProgArray and nothing else. Per-container egress policy and socket-level deny are not currently expressible, and that is a real gap rather than a wiring detail. What does exist is the TCX and XDP path, where the verdict is whatever the program returns, and both attach specs carry a network-namespace scope so a program can be attached inside one container's netns. The plumbing for per-container network enforcement is there; no shipped script uses it to drop anything.

Best for. If connect-time denial is your requirement today, use Landlock or the agent's own allowlist and do not wait on this layer. A monitor that names the sender is a different purchase from a control that stops it, and conflating them is how a team ends up with neither.

Which tool gets me process attribution, TLS plaintext, or connect-time denial for an AI agent?

Pick on what you need the answer for, because the three columns below almost never point at the same tool. Enforcement and observation should live in one place, and where they currently do not, the honest move is to say which tool has which half rather than to describe one as if it had both.

ToolNames the sending processReads what was sentBlocks the connectionNeeds a client or runtime change
container-traffic on yeetYes: pid, comm, cgroup id, container namePlaintext HTTP, plus TLS via dynamically linked OpenSSLNo, observer onlyNo
wssnoopYes, plus the 4-tuple from fexit/tcp_connectWebSocket frames over OpenSSL, Go and rustlsNoNo
AgentSightYes, with a fork and exec process treeYes, including statically linked agentsNo, observation onlyNo
pidtree-bccYes, full process-tree ancestryNo, connection metadata onlyNoNo
mitmproxyNoYes, fully, with rewritingYes, it is in the pathYes: two variables and a trusted CA
Claude Code sandboxNoNo by defaultYes, by hostname allowlistNo, it is built in
agent-jail and LandlockNoNoYes, by TCP port from ABI v4Self-restriction before exec

AgentSight is the one to read before choosing anything here, and it beats every script named here on the case this post's readers care about most. It is peer-reviewed work (arXiv 2508.02736, Zheng, Hu, Yu and Quinn), it measures 2.9% average overhead across three developer workflows on Claude Code 1.0.62 under Linux 6.14, and its architectural argument is the same one made here: "all agent interactions must traverse well-defined and stable system boundaries: the kernel for system operations and the network for external communications," which holds up because "system call ABIs and network protocols evolve far more slowly than agent frameworks." It pairs SSL_read and SSL_write uprobes with sched_process_exec tracepoints to build a process tree, and critically it solves the static-linking problem by discovering the binary, where the yeet probes attach by soname and miss Node and Claude Code entirely. Choose AgentSight when you want to read a statically linked coding agent's LLM traffic today, which is the common case. Its own scope statement is where the seam is: it monitors and correlates, with no enforcement path, so it is the observation half of the argument done well and the enforcement half not attempted.

The strongest argument against ranking identity this way comes from ARMO, which grants that eBPF sees "that a connection was made, that a file was opened, that a process spawned" but "cannot see why," and so cannot separate exfiltration from an agent legitimately reaching a new data source. That is fair, and it is the reason this post ranks identity rather than intent. Naming the sender does not tell you the sender was wrong.

None of these scripts is the shape I need. What would I have to write myself?

Roughly forty lines, because the parts you would otherwise build are the parts that are already there. The reason a platform team ends up writing its own here is rarely the kernel side, which is well-trodden, and almost always the fleet side: you want the connect event joined to the tenant who owns the workload, dropped into the queue your on-call already reads, with a policy about which destinations are boring. That join is specific to your environment and no shipped tool is going to guess it, which is the honest argument for writing rather than adopting at this layer.

The kernel half of item 1 is one hook and one struct. tcp_connect is the single chokepoint both IPv4 and IPv6 outbound connections pass through, and attaching at fexit rather than entry means the destination is already populated when the program runs, so one probe gets the remote endpoint and the dialing pid together:

/* int tcp_connect(struct sock *sk): the one v4+v6 outbound-connect chokepoint;
 * at fexit the destination is set, so we get the full remote endpoint + dialing
 * pid. This is the discovery signal: which process is reaching what, host-wide. */
SEC("fexit/tcp_connect")
int BPF_PROG(fexit_tcp_connect, struct sock *sk, int ret)
{
    if (ret == 0)
        emit_conn(sk, CONN_OPEN);
    return 0;
}

emit_conn is where the identity gets stamped, and it is the whole argument of this post expressed as three helper calls: bpf_get_current_pid_tgid() for the pid, bpf_get_current_comm() for the command name, and bpf_get_current_cgroup_id() for the container, all read in the calling task's context and written into the same ring buffer record as the endpoint. A proxy has no equivalent of those three lines, because by the time it sees the connection the calling task is gone.

The userspace half is JavaScript, and the reason to mention that is not novelty but the cost of the alternative: the usual version of this program is a C or Rust binary with a build toolchain, a libbpf version to pin, and a deployment story. Binding a compiled object to a ring buffer and subscribing to typed events is the shape wssnoop uses in production, and it reads like this:

import { BpfObject, RingBuf } from "yeet:bpf";

const ctl = await new BpfObject({ exe: "socket.bpf.o", base: import.meta.dirname })
  .bind("conns", { kind: "ringbuf", btf_struct: "conn_event" })
  .start();

new RingBuf(ctl, "conns").subscribe((w) => {
  const e = w?.conn_event ?? w;
  if (!e) return;
  // e.pid, e.comm, e.raddr, e.rport: the sender and the destination, together.
  // Join to your own tenant map here; this is the part no shipped tool can guess.
});

The btf_struct name is doing real work in that snippet. The event arrives as a typed object read through BTF rather than a byte buffer you cast and hope about, so adding a field to the C struct changes what shows up in JavaScript without a parsing layer in between to keep in sync. That property is worth more than it looks on a program you intend to keep, because the thing that rots in a homegrown tracer is almost never the probe.

Three cautions before you plan a week around this. Attaching fexit needs BTF on the running kernel (CONFIG_DEBUG_INFO_BTF=y), which most current distribution kernels ship and older ones do not, so check before promising it to anyone. The pid you capture is the dialing task, so an agent that hands work to a long-lived daemon gives you the daemon, which is correct and is not what you wanted. And the connect path costs a little on every outbound connection, which is nothing for an agent making API calls and is worth measuring on a host doing tens of thousands of connects a second. tcpconnect-bpfcc's own manual page puts that threshold at roughly 1000 connects per second, and the same arithmetic applies here because it is the same hook.

The bottom line: buy attribution from the kernel, enforcement from Landlock, and content from whichever one can attach

If you need to read what a Node-based or Claude Code agent sends to a model provider today, use AgentSight; it discovers the statically linked binary that the soname-attached yeet probes miss, it is peer-reviewed at 2.9% overhead, and being observation-only is not a problem when observation is what you want. If you need an agent's outbound connections refused rather than logged, use Landlock by TCP port or the agent's own hostname allowlist, and do not wait on eBPF at this layer, because the socket hooks that would express it are not in the yeet runtime's attach set. If you need a different environment rather than a boundary, a microVM is the current consensus answer and the eight-CVE case for it is worth reading before you settle for a container. If you need to know which process on a shared host opened a connection, and to keep knowing it when the agent spawns children that never saw your HTTPS_PROXY variable, that is the kernel: bpf_get_current_pid_tgid(), bpf_get_current_comm() and bpf_get_current_cgroup_id() on the same event as the payload, fexit/tcp_connect for the 4-tuple with the dialer attached, and container-traffic or wssnoop as the ready-made shapes, both written in yeet when neither is the shape you want.

The part worth keeping is smaller than the tooling. Every layer in the standard agent stack was designed to answer what was sent, and a workload that chooses its own actions turns who sent it into the question the rest depends on. A proxy cannot answer it because the sender's identity is not in the bytes. A container cannot answer it because a namespace isolates a network stack without naming a caller. The kernel can, for the boring reason that connect() runs in the caller's context, and most teams running agents today cannot answer it at all.

Frequently asked questions

Can eBPF tell me which process made an outbound network connection?

Yes. A BPF program on the connect path runs in the calling task's context, so bpf_get_current_pid_tgid and bpf_get_current_comm return the pid and command name of the process that called connect. The bcc tool tcpconnect prints PID and COMM alongside the source and destination addresses, and Yelp's pidtree-bcc extends that to full process-tree ancestry.

Does a MITM proxy know which process sent a request?

No. A proxy receives a TCP connection from an address and port, and the operating system does not attach the sending process's identity to the packets. Tools work around this by taking a process list from outside: Microsoft Dev Proxy accepts --watch-pids or --watch-process-names, which means you supply the attribution rather than the proxy producing it.

Do I need to install a CA certificate to inspect AI agent HTTPS traffic?

Only if you terminate TLS. A uprobe on SSL_write and SSL_read reads the plaintext buffer the application hands the TLS library, before encryption, so no key material or CA is involved. Terminating instead means distributing a CA to every runtime, and for Node-based agents also setting NODE_EXTRA_CA_CERTS, since HTTPS_PROXY alone causes Node TLS verification to fail.

Why can't a uprobe on libssl.so see Claude Code's traffic?

Because Claude Code statically links BoringSSL into its own binary, as do all Node.js builds with OpenSSL, so there is no system libssl.so for a soname-attached probe to hook. AgentSight documents this and solves it by discovering the agent's binary and attaching by path and symbol offset instead. It is an attachment-strategy limit rather than a limit of eBPF.

Can setting HTTPS_PROXY force an AI agent through a proxy?

No, it is a hint the application chooses to honor. A subprocess spawned with a cleared environment never sees it, raw TCP, UDP and QUIC clients do not consult it, and anything listed in NO_PROXY is excluded by design. Enforcement at that layer requires a network namespace, a firewall rule, or a kernel hook rather than an environment variable.

Does a container tell me which process inside it made a connection?

No. A network namespace isolates network devices, protocol stacks, routing tables, firewall rules and port numbers, none of which identify the calling process. Attribution inside a container comes from the cgroup id on the event, which on cgroup v2 resolves through the leaf cgroup directory name to a container id and therefore a container name.

What is the lowest-overhead way to monitor AI agent network activity on Linux?

A kernel-side probe on the connect path, because the work happens in kernel context on each connect with no proxy hop and no extra process in the data path. The bcc tcpconnect man page describes the overhead as negligible below roughly 1000 connects per second, and AgentSight's paper measures 2.9% average overhead for uprobe-based agent tracing across three developer workflows.

Can eBPF block an AI agent from reaching the internet?

The technology can, at TCX, XDP or a cgroup socket hook, but nothing in the yeet script corpus does; every program in it is an observer, and pktscope sits on a TCX hook and deliberately returns TCX_NEXT. For connect-time denial today, Landlock restricts TCP connects by remote port from ABI v4 in Linux 6.7, and an agent's own hostname allowlist covers the domain case.

Is a container a security boundary for an AI coding agent?

Not on its own, because every container on a host shares one kernel, and eight documented escapes between 2024 and 2025 including CVE-2024-21626 and CVE-2025-23266 worked for that reason. A microVM adds a hypervisor and is the current consensus answer for untrusted code. A container is still the right tool when you want a reproducible environment and clean teardown.

What is process attribution and why does it matter for AI agents?

Process attribution is tying an observed event, such as an outbound connection, to the specific process that caused it, by pid, command name or cgroup. It matters more for agents than for ordinary services because an agent picks its own actions and spawns children, so the set of processes generating traffic is not known in advance and cannot be enumerated for a proxy ahead of time.

Can I monitor AI agents across a fleet without asking every team to change their runtime?

A kernel-side probe requires no change inside the workload, which is the practical difference from a proxy: no environment variables to set per runtime, no CA to add to a trust store, and nothing for a subprocess to fail to inherit. What you give up is content coverage for statically linked TLS stacks, and any enforcement, since these programs observe rather than block.

Does an AI agent's own telemetry show what it actually did?

It shows what the agent reported, which is the agent's account of itself rather than an independent record. Kernel-side events are produced by the code that performed the operation, so they do not depend on the agent's instrumentation remaining intact or on a subprocess inheriting a tracing context.

Sources

  • AgentSight: System-Level Observability for AI Agents Using eBPF (Zheng, Hu, Yu and Quinn, arXiv:2508.02736v2, August 2025): the closest prior art, pairing SSL_read/SSL_write uprobes with a process tree at 2.9% average overhead on Claude Code 1.0.62.
  • AgentSight README (eunomia-bpf, GitHub): the static-linking limit stated by the project that solves it, and its scope as monitoring with no enforcement path.
  • Configure the sandboxed Bash tool (Anthropic, Claude Code documentation): the built-in proxy allowlists by requested hostname and "by default, does not terminate or inspect TLS traffic," with the domain-fronting consequence stated in the same docs.
  • Intercept requests from specific processes (Microsoft Learn, Dev Proxy documentation, January 2026): the attribution gap as a CLI flag, since --watch-pids and --watch-process-names require the caller to supply the identity the proxy cannot derive.
  • Three things set HTTPS_PROXY cannot stop (DEV Community): the three bypasses of proxy-by-environment-variable, being a subprocess spawned without it, raw TCP/UDP/QUIC, and NO_PROXY.
  • pidtree-bcc (Yelp, GitHub): prior art for connect attribution by process ancestry, and the reason it must be captured live rather than reconstructed.
  • tcpconnect-bpfcc(8) (Debian manpages, bpfcc-tools): the one-command form of item 1, tracing tcp_v4_connect() and tcp_v6_connect(), with the overhead caveat above roughly 1000 connects per second.
  • bpf-helpers(7) (Linux manual pages): the identity primitives this post rests on, bpf_get_current_pid_tgid and bpf_get_current_comm, both read in the calling task's context.
  • bpf_get_current_cgroup_id (Ubuntu manpages, bpf-helpers section 7): the cgroup id that survives the individual pids and carries container identity.
  • BPF LSM programs (kernel.org): the mechanism behind one program both deciding and recording, where a non-zero return refuses the operation.
  • Landlock news #4 (Landlock project): ABI v4 in Linux 6.7 adding LANDLOCK_ACCESS_NET_CONNECT_TCP, the unprivileged enforcement the yeet scripts lack.
  • Landlock kernel documentation (kernel.org): the granularity limit that the connect right gates a remote port rather than a hostname.
  • Technical challenges for attaching eBPF programs in containers (bpfman, February 2024): why a host-side uprobe cannot reach a library shipped inside a container image, corroborating our own documented limit.
  • Your container is not a sandbox (2026): the eight-CVE case for microVMs over containers for agent workloads.
  • How to sandbox AI agents (Northflank): the incumbent ranked answer this post declines to argue with, listing microVMs, gVisor and hardened containers.
  • network_namespaces(7) (Linux manual pages): what a container's network boundary covers, and by omission what it does not.
  • Capturing plaintext SSL/TLS with sslsniff (eunomia-bpf, bpf-developer-tutorial): the canonical library-boundary capture, including the find_library_path step that makes the static-linking gap concrete.
  • Why eBPF is useful for watching and sandboxing AI agents (Tigera): the vendor statement of the division of labor, that eBPF contains what an agent can do while a proxy governs what it sends.
  • eBPF for AI agent enforcement: what kernel-level security catches and what it misses (ARMO): the strongest argument against this post's position, that the kernel sees the act but not the intent.
  • Setting up mitmproxy for an AI coding agent (hikari-dev, April 2026): the per-runtime cost of the interception route, needing HTTPS_PROXY, HTTP_PROXY and NODE_EXTRA_CA_CERTS together.
  • agent-lock (yeet-src, GitHub): the enforcement-and-observation-in-one-hook claim, and its own statement that it governs opens rather than sockets.
  • container-traffic (yeet-src, GitHub): the cgroup attribution argument, tagging each request with the kernel's cgroup id rather than a service mesh.
  • wssnoop (yeet-src, GitHub): the three-stack version of library-boundary capture across OpenSSL, Go crypto/tls and rustls, plus the fexit/tcp_connect chokepoint.

Related resources

Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.