# How Do I See What an AI Agent Actually Did in Its Sandbox?

> 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.

Source: https://sandboxer.com/guides/see-what-an-ai-agent-did-in-its-sandbox. Updated 2026-10-29.

**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](/guides/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](/guides/compare-an-agents-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:

```bash
#!/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)"
```

```bash
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)"
```

```text
-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:12Z
```

It 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:

```python
#!/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:

```bash
#!/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"
```

```text
{"commandId":"cmd_8bnfctsc69vo","pid":1222}
200
200
```

The 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](/docs/long-running#command-history)).

```bash
sbx history $id --limit 10
```

```text
COMMAND-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:

```bash
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'
```

```text
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:

```bash
sbx ps $id
```

```text
PID   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.1
```

`sbx 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:

```bash
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'
```

```bash
sbx exec $id "$(cat inspect.sh)"
```

```text
== 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, so `git status` alone shows only the deletion and the new folder. `git diff $base` and `git log $base..HEAD` show 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, and `tar`, `cp -p` and `touch -d` can set them too. The change time (`ctime`) is set by the kernel when the file is written.
- **Look outside the repo.** The edit to `~/.bashrc` changes every future login shell, and git never saw it.
- **`find` can't show a deleted file.** `old.txt` shows up only because git tracked it.
- **sudo leaves a line in the journal.** `sudo journalctl --since @$(stat -c %Y /var/tmp/agent-start) _COMM=sudo` printed each one, for example `user : 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

```bash
sbx exec $id "sudo agent-trail"
```

```text
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' >> ~/.bashrc
```

This 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:

```bash
sbx exec $id "sudo cat /var/log/audit/audit.log" > audit-$id-$(date -u +%H%M%S).log
```

Ours 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:

```bash
sbx fork $id --name agent-run-inspect --type small --ttl 900 --no-env --wait
```

The 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](/docs/snapshots).

## 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](/guides/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 -cnewer` and the `dpkg.log` filter 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 stored `sbx history` output live inside the sandbox, so code with root there can alter them. In our test, `sudo systemctl stop auditd` and `sudo truncate` emptied the trail even with `auditctl -e 2` set. After a root `rm` of one command's stored output, `sbx history` still 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-monitoring` keeps 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

- [Require human approval before an agent runs a command](/guides/require-human-approval-before-an-agent-runs-a-command)
- [Compare an agent's change against a baseline with forks](/guides/compare-an-agents-change-against-a-baseline-with-forks)
- [Try an unknown GitHub repo safely in a sandbox](/guides/try-an-unknown-github-repo-safely-in-a-sandbox)
- [Long-running work and command history](/docs/long-running)
