How Do I See What an AI Agent Actually Did in Its Sandbox?
Updated October 29, 2026 .md
Short answer: Start with sbx history, which lists every command sent through the exec API with its exit code and output. Then look inside the VM, with git diff against a base commit, find -cnewer a start marker, the dpkg log, ss and journalctl. To see what those commands started on their own, turn on auditd before the agent runs.
An agent's summary is a claim. The sandbox holds the evidence. This page shows where that evidence lives and what each source misses. It ran on prod on 2026-10-29 in a small sandbox created with --no-env. No model ran. A script stood in for the agent, making seven tool calls: it edited and committed a file, deleted another, wrote a helper script, installed an apt package and a pip package, started a background server and made outbound HTTPS requests. Output below is real and trimmed with ….
If you want a person to approve commands before they run, see require human approval before an agent runs a command. If you want to score the agent's change against a baseline, see compare an agent's change against a baseline with forks. This page is about reading what happened afterward.
What each source shows#
| Source | Shows | Misses | Set up before? |
|---|---|---|---|
sbx history |
Every command sent through the exec API, its exit code, times and stored output | Programs those commands started | No (on unless --no-monitoring) |
sbx ps, sbx exec --status |
What is running now, linked to the command that started it | Anything that already exited | No |
git log / git status / git diff |
Commits, edits and deletions in the repo | Files outside the repo or in .gitignore |
A base commit |
find -cnewer |
Files created or changed anywhere on disk | Deleted files | A start marker |
/var/log/dpkg.log, site-packages |
apt and pip installs | Downloads that weren't installed | No |
ss -ltnp |
Listening ports and their process | Connections that already closed | No |
journalctl _COMM=sudo |
Every sudo command line |
Commands that didn't use sudo |
No |
auditd (agent-trail below) |
Every program started and every outbound connection | What was sent over a connection | Yes |
1. Before the agent runs: a base commit, a marker and auditd#
Most of the evidence needs nothing in advance. Three things do: a commit to diff against, a timestamp to compare files with and a recorder for programs and connections. sbx history only knows the command line the agent sent. If that line is python3 tools/check.py, the history can't tell you that the script ran curl.
The kernel in a sandbox has audit support (CONFIG_AUDITSYSCALL=y), and the agent's commands run as user with sudo. Save this as setup-recording.sh and run it once, as the last step before you hand the sandbox to the agent:
#!/bin/bash
# setup-recording.sh: run once in the sandbox, before the agent gets it.
set -e
# 1. auditd: record every program start and every outbound connect().
sudo apt-get update -qq
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq auditd >/dev/null
sudo tee /etc/audit/rules.d/agent.rules >/dev/null <<'RULES'
-a always,exit -F arch=b64 -S execve -F success=1 -k agent-exec
-a always,exit -F arch=b64 -S connect -F success=1 -k agent-net
RULES
sudo systemctl enable --now auditd 2>/dev/null
sudo augenrules --load >/dev/null
sudo install -m 755 /tmp/agent-trail /usr/local/sbin/agent-trail
# 2. Commit the repo's starting state and keep the commit id.
cd ~/app && git add -A && git -c user.name=setup -c user.email=setup@example.com commit -qm base
git rev-parse HEAD > ~/.agent-base
# 3. Mark the start; find -cnewer and journalctl --since use it.
sleep 1; sudo touch /var/tmp/agent-start
sudo auditctl -l; echo "base $(cut -c1-7 ~/.agent-base), start $(date -u -r /var/tmp/agent-start +%FT%TZ)"sbx new --name agent-run --type small --no-env
id=sb_e3gbnqszhl # the id sbx new printed
sbx exec $id "echo $(base64 -w0 agent-trail.py) | base64 -d > /tmp/agent-trail"
sbx exec $id --timeout 300 "$(cat setup-recording.sh)"-a always,exit -F arch=b64 -S execve -F success=1 -F key=agent-exec
-a always,exit -F arch=b64 -S connect -F success=1 -F key=agent-net
base 9b38d0a, start 2026-10-29T11:05:12ZIt took 6.1 seconds. Installing auditd doesn't start it in the sandbox, so the systemctl enable --now line is needed. Without it, ausearch has no log to read.
Save this as agent-trail.py on your machine; the setup step copies it in. It turns the audit log into one line per program or connection. It skips DNS lookups, local sockets and dpkg's internal steps:
#!/usr/bin/env python3
# agent-trail: what ran and what connected out since /var/tmp/agent-start, from auditd.
import os, re, subprocess, time
since = time.strftime("%m/%d/%Y %H:%M:%S", time.localtime(os.path.getmtime("/var/tmp/agent-start")))
SKIP = ("/usr/bin/locale-check", "/usr/bin/dpkg", "dpkg-", "/usr/lib/apt/methods", "agent-trail", "ausearch")
rows, seen = [], set()
for key in ("agent-exec", "agent-net"):
out = subprocess.run(["ausearch", "-i", "-k", key, "--start", *since.split()],
capture_output=True, text=True).stdout
for block in out.split("----"):
m = re.search(r"audit\(\S+ (\d\d:\d\d:\d\d)\.\d+:(\d+)\)", block)
sc = re.search(r"type=SYSCALL.* euid=(\S+) .* comm=(\S+)", block)
if not m or not sc:
continue
ts, serial, user, comm = m.group(1), int(m.group(2)), sc.group(1), sc.group(2)
if key == "agent-exec":
ex = re.search(r"type=EXECVE .*?argc=\d+ a0=(.*)", block)
if not ex:
continue
what = " ".join(re.split(r" a\d+=", ex.group(1).strip()))
if what.startswith(SKIP):
continue
rows.append((serial, ts, user, "exec", what[:100]))
else:
dst = re.search(r"saddr_fam=inet6? laddr=(\S+) lport=(\d+)", block)
if not dst or dst.group(2) == "53": # skip local sockets and DNS lookups
continue
if (comm, dst.group(2), ts) in seen: # one line per program, port and second
continue
seen.add((comm, dst.group(2), ts))
rows.append((serial, ts, user, "net", f"{comm} -> {dst.group(1)} port {dst.group(2)}"))
for serial, ts, user, kind, what in sorted(rows):
print(f"{ts} {user:<5} {kind:<4} {what}")2. The agent's session#
This script stands in for the agent. Each line is one tool call, sent through the exec API the way an agent framework would send it:
#!/bin/bash
# agent-session.sh <id>: stands in for an agent. Each line is one tool call.
id=$1
sbx exec $id "cd app && sed -i 's/Hello/Hello, world/' app.py && git -c user.name=agent -c user.email=agent@example.com commit -qam 'Greet the world' && rm old.txt"
sbx exec $id "mkdir -p app/tools && printf '%s\n' 'import subprocess, urllib.request' \
'print(urllib.request.urlopen(\"https://pypi.org/simple/requests/\").status)' \
'subprocess.run([\"curl\", \"-sSo\", \"/dev/null\", \"-w\", \"%{http_code}\\\\n\", \"https://example.com\"])' > app/tools/check.py"
sbx exec $id --timeout 300 "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq tree >/dev/null"
sbx exec $id --timeout 300 "cd app && python3 -m venv .venv && .venv/bin/pip install -q requests"
sbx exec $id --detach "cd app && python3 -m http.server 8000 --bind 127.0.0.1" | jq -c '{commandId, pid}'
sbx exec $id "cd app && python3 tools/check.py"
sbx exec $id "echo 'export PATH=\$HOME/app/bin:\$PATH' >> ~/.bashrc"{"commandId":"cmd_8bnfctsc69vo","pid":1222}
200
200The whole session took 5.4 seconds. Its "summary" would be: greeting updated, tooling installed, server up.
3. From outside: the command history#
sbx history lists every command sent through the exec API, newest first. It works while the sandbox is stopped. Commands are kept 90 days, and other processes' output 72 hours (command history).
sbx history $id --limit 10COMMAND-ID STATUS MODE STARTED COMMAND
cmd_dk10xrg47yts exit 0 sync 2026-10-29 11:05:20 echo 'export PATH=$HOME/app/bin:$PATH' >> ~/.bashrc
cmd_vc4flt663mci exit 0 sync 2026-10-29 11:05:20 cd app && python3 tools/check.py
cmd_8bnfctsc69vo running detached 2026-10-29 11:05:20 cd app && python3 -m http.server 8000 --bind 127.0.0.1
cmd_1c8tf5nt3479 exit 0 sync 2026-10-29 11:05:16 cd app && python3 -m venv .venv && .venv/bin/pip install -q requests
cmd_ly1a3orh570s exit 0 sync 2026-10-29 11:05:15 sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq tree >/dev…
cmd_53zp5lnyuid5 exit 0 sync 2026-10-29 11:05:15 mkdir -p app/tools && printf '%s\n' 'import subprocess, urllib.reques…
cmd_stpjlisjbwlf exit 0 sync 2026-10-29 11:05:15 cd app && sed -i 's/Hello/Hello, world/' app.py && git -c user.name=a…
cmd_siu43i5dc17x exit 0 sync 2026-10-29 11:05:07 #!/bin/bash # setup-recording.sh: run once in the sandbox, before the…
…Pass a command id to read what it printed, or --stderr for its errors. Piped, sbx history prints JSON, so you can cut the list to the agent's run:
sbx history $id cmd_vc4flt663mci
sbx history $id --limit 100 | jq -r '.commands[] | select(.startedAt > "2026-10-29T11:05:12")
| [.startedAt[11:19], .mode, (.exitCode|tostring), .command[:60]] | @tsv'200
200
11:05:20 sync 0 echo 'export PATH=$HOME/app/bin:$PATH' >> ~/.bashrc
11:05:20 sync 0 cd app && python3 tools/check.py
11:05:20 detached null cd app && python3 -m http.server 8000 --bind 127.0.0.1
…sbx ps links what's still running to the command that started it:
sbx ps $idPID CPU% MEM MB USER HISTORY COMMAND
260 8.0 10.0 root - /usr/local/bin/sbx-guest serve
…
1222 0.0 19.0 user cmd_8bnfctsc69vo python3 -m http.server 8000 --bind 127.0.0.1sbx exec $id --status 1222 returned {"status":"running","exitCode":null,"commandId":"cmd_8bnfctsc69vo"}.
The history has a gap you can see in the list above. The check.py line says nothing about what the script did. You have to look inside.
4. Inside the VM: repo, files, packages, ports#
Save this as inspect.sh. One sbx exec runs it and collects the rest. Run it as soon as the agent stops, before you change anything:
start=/var/tmp/agent-start; base=$(cat ~/.agent-base)
echo "== git: commits since base, then uncommitted changes"
cd ~/app && git log --oneline $base..HEAD && git status --short && git diff --stat $base
echo "== files created or changed since the start (ctime; skips .venv and caches)"
sudo find / -xdev -cnewer $start -type f \
-not -path '/proc/*' -not -path '/sys/*' -not -path '/run/*' -not -path '/var/log/*' \
-not -path '/var/cache/*' -not -path '/var/lib/apt/lists/*' -not -path '*/.venv/*' \
-not -path '*/__pycache__/*' -not -path '/home/user/.cache/*' -not -path '/home/user/.sandboxer/*' -not -path '/var/lib/sandboxer/*' | sort
echo "== packages"
grep " install " /var/log/dpkg.log | awk -v s="$(date -u -r $start '+%F %T')" '$1" "$2 >= s'
ls ~/app/.venv/lib/python3*/site-packages | grep -i dist-info
echo "== listening ports and the processes behind them"
sudo ss -ltnpH | grep -v -E 'sshd|sbx-guest|systemd-resolve|dockerd'sbx exec $id "$(cat inspect.sh)"== git: commits since base, then uncommitted changes
d403a31 Greet the world
D old.txt
?? tools/
app.py | 2 +-
old.txt | 1 -
2 files changed, 1 insertion(+), 2 deletions(-)
== files created or changed since the start (ctime; skips .venv and caches)
/home/user/app/app.py
/home/user/app/.git/COMMIT_EDITMSG
…
/home/user/app/tools/check.py
/home/user/.bashrc
/usr/bin/tree
/usr/share/doc/tree/changelog.Debian.gz
/usr/share/doc/tree/copyright
/var/lib/apt/extended_states
/var/lib/dpkg/info/tree.list
…
== packages
2026-10-29 11:05:16 install tree:amd64 <none> 2.1.1-2ubuntu3.24.04.2
certifi-2026.10.23.dist-info
charset_normalizer-3.5.2.dist-info
idna-3.20.dist-info
pip-24.0.dist-info
requests-2.34.2.dist-info
urllib3-2.8.0.dist-info
== listening ports and the processes behind them
LISTEN 0 5 127.0.0.1:8000 0.0.0.0:* users:(("python3",pid=1222,fd=3))What to read from this:
- Diff against the base, not
HEAD. The agent committed its edit, sogit statusalone shows only the deletion and the new folder.git diff $baseandgit log $base..HEADshow everything since you handed it over. - Use
-cnewer, not-newer. Our first pass used-newer(modification time) and missed/usr/bin/tree. dpkg keeps the package's original timestamps, andtar,cp -pandtouch -dcan set them too. The change time (ctime) is set by the kernel when the file is written. - Look outside the repo. The edit to
~/.bashrcchanges every future login shell, and git never saw it. findcan't show a deleted file.old.txtshows up only because git tracked it.- sudo leaves a line in the journal.
sudo journalctl --since @$(stat -c %Y /var/tmp/agent-start) _COMM=sudoprinted each one, for exampleuser : PWD=/home/user ; USER=root ; ENV=DEBIAN_FRONTEND=noninteractive ; COMMAND=/usr/bin/apt-get install -y -qq tree.
5. The audit trail: what the commands started#
sbx exec $id "sudo agent-trail"11:05:15 user exec /bin/bash -lc cd app && sed -i 's/Hello/Hello, world/' app.py && git -c user.name=agent -c user.emai
11:05:15 user exec sed -i s/Hello/Hello, world/ app.py
11:05:15 user exec git -c user.name=agent -c user.email=agent@example.com commit -qam Greet the world
11:05:15 user exec /usr/lib/git-core/git maintenance run --auto --quiet
11:05:15 user exec rm old.txt
…
11:05:15 root exec apt-get install -y -qq tree
11:05:16 _apt net http -> 2620:2d:4002:1::102 port 80
…
11:05:18 user exec /home/user/app/.venv/bin/python3 .venv/bin/pip install -q requests
…
11:05:19 user net pip -> 151.101.64.223 port 443
11:05:20 user exec python3 -m http.server 8000 --bind 127.0.0.1
11:05:20 user exec /bin/bash -lc cd app && python3 tools/check.py
11:05:20 user exec python3 tools/check.py
11:05:20 user net python3 -> 2a04:4e42:400::223 port 443
11:05:20 user exec curl -sSo /dev/null -w %{http_code}\n https://example.com
11:05:20 user net curl -> 2606:4700:10::6814:179a port 443
11:05:20 user exec /bin/bash -lc echo 'export PATH=$HOME/app/bin:$PATH' >> ~/.bashrcThis is the part the history couldn't show: check.py opened an HTTPS connection itself, then started curl, which opened another. It also names the user each program ran as (root for the apt install, _apt for its download). The full trail had about 60 lines for seven tool calls, mostly pip and apt internals. The audit log can't name the host behind an IP. Compare the address with dig +short for the hosts you expect, or allow only those hosts in the first place.
To keep a copy outside the VM, pull the raw log while the agent works, or as soon as it stops:
sbx exec $id "sudo cat /var/log/audit/audit.log" > audit-$id-$(date -u +%H%M%S).logOurs was 336 KB with 155 EXECVE records.
6. Fork to inspect without touching the original#
To dig around while the agent's sandbox stays as it is, fork it:
sbx fork $id --name agent-run-inspect --type small --ttl 900 --no-env --waitThe fork was ready 1.3 s later. It had the commit, the deleted file, tools/check.py and the audit log, and agent-trail printed the same curl lines. Port 8000 wasn't listening: a fork copies the disk only, so the background server didn't come with it. Check processes and ports in the original. Use the fork for anything that changes the disk, such as running the agent's tests or reverting files. When you're done, sbx delete <fork> --yes removes it. To keep a known-good state you can roll back to before the agent starts, see snapshots, forks and templates.
What you can't see after the fact#
- What went over the wire. The trail shows that a program connected to an address and port, not what it sent. For that, restrict outbound traffic before the run. What can reach a sandbox and what it can reach covers the network.
- Anything before your marker. auditd records only from the moment its rules load, and
find -cnewerand thedpkg.logfilter only compare with the marker. - Memory. Environment variables, open files and anything a process held in RAM are gone once it exits.
- Records changed from inside. The agent has
sudo, and the audit log and storedsbx historyoutput live inside the sandbox, so code with root there can alter them. In our test,sudo systemctl stop auditdandsudo truncateemptied the trail even withauditctl -e 2set. After a rootrmof one command's stored output,sbx historystill listed the command and its exit code, but its output came back empty. For a trail the agent can't touch, record from outside: pull the logs out while it runs. - Nothing at all with monitoring off. A sandbox created with
--no-monitoringkeeps no command output, process list or CPU and memory samples.
FAQ#
Does sbx history show commands that a script started?#
No. It records the command line sent through the exec API, its output and its exit code. Programs started by that command, like the curl inside check.py, are visible only from inside the VM. auditd is one way to record them.
Can I read the history after I stop or delete the sandbox?#
After a stop, yes: sbx history works while a sandbox is stopped. Commands are kept 90 days, and the output of other processes 72 hours. Copy anything you need to keep before you delete the sandbox.
Does auditd slow the agent down?#
We didn't benchmark it. With two rules, the setup took 6.1 s, and the seven tool calls took 5.4 s. Rules that log every execve and connect cost more on workloads that start thousands of processes, such as large builds. Narrow the rules if that matters.
Do I need auditd if the agent only runs one command at a time?#
Not always. If each tool call is a single program and you can read every command line in sbx history, the history plus git diff and find -cnewer covers most questions. Add auditd when the agent runs scripts, installers or test suites that start other programs.
Related#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.