Turning it on
Tracing is off unless you ask for it, and it is chosen when the sandbox is created:result.trace is that
command and nothing else. sandbox.trace.collect() gives you everything the
sandbox has done so far.
collect() and clear().
result.trace on a sandbox created without trace: true throws, rather than
handing back an empty record that would read as “it did nothing”.What ran
processes() returns the roots of a tree of programs:
A node is one successful exec, not one process. That distinction is what makes
the tree readable:
- A shell that execs its last command reuses its own pid, so
makeappears nested under theshthat became it. Both nodes carry pid 563 above, and that is correct: one process, two programs. - A fork that never execs adds no node. Its children attach to the closest program that did exec, so a pipeline’s plumbing does not show up as empty boxes.
- Threads are not programs.
process.forkevents withthread: trueare ignored here.
What it touched
files() returns one entry per path, with everything that happened to it folded
together:
Paths are recorded where the file actually is, not as the program typed it. A
program that does
cd /tmp/rel and then writes copy.txt is recorded against
/tmp/rel/copy.txt, and a child inherits its parent’s directory, so the
resolution survives child processes and shell pipelines.
denied is worth watching on its own. A failed open is not noise, it is the
most interesting line in the file:
Where it went
network() returns one entry per address and port:
Request lines appear when vpod handles the connection itself, which covers plain
HTTP and the HTTPS path it proxies. A program that brings its own TLS end to end
still gets its connection, byte counts and hostname recorded, but not the
individual requests.
What a request carried
A method and a URL say an agent called an API. They do not say which model it picked or what it pasted into the prompt. Network tracing records what the guest actually sent, so each request carries its headers and body:
Bodies are cut at 64 KB, so a large upload costs a bounded amount rather than
filling the buffer. Ten API calls with 300-byte bodies grow a trace by about
7 KiB, measured.
A credential set with
secrets appears here as
its stand-in, never the real value, because the trace is taken before the gateway
swaps it in. A real key placed in env is recorded as-is.bodyBytes: null.
Following along
watch() is an async iterable of raw events, useful when a sandbox runs for a
long time and you want to react rather than wait:
Events reach a watcher when the record is drained, which happens at the end of
every command and whenever you call
collect(). A watcher sees a command’s
events as that command finishes, not while it is still running.clear() forgets what has been recorded so far, which keeps a long-lived
sandbox from accumulating a record you are never going to read:
Was anything missed
complete answers the only question that matters before you trust a trace:
false in three cases, all of which appear in the raw events:
The third is the mild one. vpod learns where the guest kernel keeps process ids
by watching the guest tell it, which takes a couple of syscalls from two
different processes. Until that settles, events are still recorded, but without
pids and with relative paths left unresolved. In practice it settles during the
first command.
Choosing what to record
trace: true records everything. Pass an object to record less:
bufferBytes caps what the engine holds between drains, and defaults to 64 MB.
When it fills, the oldest events survive and new ones are counted into a
trace.dropped event, so the record tells you it is short rather than quietly
lying:
Noise and vpod’s own plumbing
Two filters are on by default, both of which you can lift per call. Noise is activity that is real but never what you asked about:/proc, /sys,
/dev, and shared libraries that were only read. vpod’s own plumbing is the
handful of files and processes the SDK uses to run your command at all, marked
internal in the raw events.
network() and processes() take internal too.
Raw events
Every view above is built from one flat list of events, and that list is public:v (schema version), seq, guest_ns (guest time, which is
deterministic), wall_ms (host wall clock), and kind. Raw events keep the wire
spelling, so their fields stay snake_case while the views above are camelCase.
Most carry pid and task, and vpod’s own activity carries internal: true.
A path that could not be resolved to an absolute one is left as the program
wrote it and flagged with
path_unresolved: true.
What a guest can and cannot hide from
Network activity is observed outside the guest, in the emulator’s own device code, as is activity on a mounted host directory. Nothing running inside the sandbox can perform that I/O without going through it, so those two sources are not evadable. Process and file activity is read from the guest’s syscalls, and a program that is trying to hide can get around that.io_uring is the practical route, so
vpod turns it off when tracing starts. Guest root can turn it back on, which is
why the engine also watches for it: the first ring that opens produces a
trace.blind event and complete turns false. Writing kernel memory directly
or loading a kernel module would evade it too.
So a trace is a faithful record of what a program did, and complete tells you
when it is not. It is not a security boundary. The boundary is the sandbox
itself, which holds whether or not anything is being traced.
Cost
Tracing off is not a mode, it is one predictable branch per syscall, and measures as no change. On, measured on the wasm engine:
CPU-bound work is unaffected, because nothing on the hot path changes. What
costs is syscall volume.
Resumed sandboxes
Tracing is chosen per sandbox, including when you resume one:resumed.trace.collect() covers what has happened since
it came back.