How Do I Check What an Unknown GitHub Repo Really Does, Safely?
Updated October 28, 2026 .md
Short answer: Run it in a fresh no-env sandbox. Fetch one pinned commit, run the README's install and quickstart verbatim inside a capped systemd scope, record each step's exit code and time, cut outbound traffic for the claim checks, copy the report out and delete the VM. Your laptop, keys and other sandboxes stay out of reach.
This page is a reusable flow for you or an agent: two short files and one driver script. We ran it on prod on 2026-10-28 against fzf, a popular command-line fuzzy finder, pinned to tag v0.74.5 (commit c0a193e0). The whole run, from create to delete, took 39 seconds on a small sandbox.
The flow#
- Create a
smallsandbox with--no-envand a 30-minute TTL, so a forgotten one stops on its own. - Pin the code to a commit hash, not a branch. A branch can change between your check and your install.
- Run each README step verbatim as its own step. Keep its exit code, its time and its output.
- Cap the whole run with one systemd scope, so a runaway build can't take the sandbox down with it.
- Cut outbound traffic before you check the claims, so you also learn whether the tool needs the network.
- Copy the report out, then delete the sandbox.
The step runner#
check.sh runs inside the sandbox. Each non-comment line of a steps file is one step, run in a fresh bash with no stdin and a 10-minute limit:
#!/bin/bash
# check.sh [steps-file]: run each line as its own step; record exit code, time and output.
steps=${1:-steps.txt}; mkdir -p ~/report; cd ~
[ -f ~/report/results.tsv ] || printf 'step\texit\tms\tcommand\n' > ~/report/results.tsv
n=$(( $(wc -l < ~/report/results.tsv) - 1 ))
while IFS= read -r cmd; do
case "$cmd" in ''|'#'*) continue ;; esac
n=$((n+1)); t0=$(date +%s%N)
timeout 600 bash -c "$cmd" > ~/report/step$n.log 2>&1 < /dev/null; rc=$?
printf '%s\t%s\t%s\t%s\n' $n $rc $(( ($(date +%s%N)-t0)/1000000 )) "$cmd" >> ~/report/results.tsv
done < ~/"$steps"
tar czf ~/report.tgz -C ~ report< /dev/null matters. Install scripts often ask questions. With no stdin, a prompt either takes its default or fails, instead of hanging until a timeout.
The steps file for fzf#
Each step is copied from fzf's README or BUILD.md, or checks one of the README's claims:
# Inside the capped scope: which sandbox variables can the code see? (names only)
env | cut -d= -f1 | grep -cE '^SANDBOXER_'; ls ~/.config/gh ~/.git-credentials 2>&1 | cut -c1-60
# Fetch exactly one commit (fzf v0.74.5) and check it
git init -q ~/.fzf && git -C ~/.fzf fetch -q --depth 1 https://github.com/junegunn/fzf.git c0a193e031f01fb50bf6f7be4381990a3753f7cf && git -C ~/.fzf checkout -q --detach FETCH_HEAD && git -C ~/.fzf log -1 --format='%H %cs %s'
# README "Using git": the install script, verbatim
~/.fzf/install
# What the install changed
~/.fzf/bin/fzf --version; git -C ~/.fzf status --short; ls ~/.fzf.*; tail -1 ~/.bashrc
# Is bin/fzf the published release binary?
cd /tmp && curl -fsSLO https://github.com/junegunn/fzf/releases/download/v0.74.5/fzf-0.74.5-linux_amd64.tar.gz && curl -fsSL https://github.com/junegunn/fzf/releases/download/v0.74.5/fzf_0.74.5_checksums.txt | grep linux_amd64.tar.gz | sha256sum -c && tar xzf fzf-0.74.5-linux_amd64.tar.gz && cmp fzf ~/.fzf/bin/fzf && echo "bin/fzf matches the release"
# BUILD.md: build the pinned source (no tags fetched, so set the version as BUILD.md says)
cd ~/.fzf && FZF_VERSION=0.74.5 FZF_REVISION=c0a193e0 make && ./target/fzf-linux_amd64 --version && sha256sum target/fzf-linux_amd64 bin/fzf
# The project's own tests at this commit
cd ~/.fzf && FZF_VERSION=0.74.5 FZF_REVISION=c0a193e0 make test > /tmp/test.log 2>&1; rc=$?; grep -E '^(ok|FAIL)' /tmp/test.log; grep -c '^--- PASS' /tmp/test.log; exit $rc
# Cut outbound traffic for the claim checks
sudo ufw default deny outgoing >/dev/null && curl -s -m 5 -o /dev/null https://github.com; echo "curl exit $?"
# Claim: "Portable: distributed as a single binary"
file -b ~/.fzf/bin/fzf | cut -d, -f1,2,4; ldd ~/.fzf/bin/fzf
# README "Usage", verbatim
cd ~/.fzf && find * -type f | ~/.fzf/bin/fzf > selected
# Claim: "Fast: optimized to process millions of items in milliseconds"
seq 1000000 > /tmp/1m.txt; TIMEFORMAT='%R s'; for q in 123456 99999 "'777"; do time (~/.fzf/bin/fzf --filter "$q" < /tmp/1m.txt | wc -l); doneFor another repo, keep the first two steps, change the URL and hash, and replace the rest with that README's commands and claims.
The driver#
try-repo.sh runs on your machine, or wherever your agent runs, with your API key in SANDBOXER_API_KEY. The key never enters the sandbox:
#!/bin/bash
# try-repo.sh: run steps.txt in a fresh no-secrets sandbox, bring back the report, delete the sandbox.
set -u
id=$(sbx --json new --name "${NAME:-try-repo}" --type small --ttl 1800 --no-env | jq -r 'select(.event=="ready") | .id')
[ -n "$id" ] || { echo "create failed"; exit 1; }
trap 'sbx delete "$id" --yes >/dev/null; echo "deleted $id"' EXIT
echo "sandbox $id"
# Variable names only, never values
sbx exec "$id" "env | cut -d= -f1 | grep -E 'SANDBOXER|KEY|TOKEN|SECRET' | sort | paste -sd' '"
sbx scp check.sh "$id:/home/user/check.sh" && sbx scp steps.txt "$id:/home/user/steps.txt" || exit 1
# Cap the whole run: 2 GB RAM, 512 tasks, 1.5 CPUs, 20 minutes
pid=$(sbx --json exec "$id" --detach "sudo systemd-run --scope --quiet --uid=user --gid=user \
--setenv=PATH=\$PATH -p MemoryMax=2G -p TasksMax=512 -p CPUQuota=150% -p RuntimeMaxSec=1200 \
bash check.sh steps.txt" | jq -r .pid)
sbx exec "$id" --attach "$pid" > /dev/null
sbx scp "$id:/home/user/report.tgz" "./report-$id.tgz"Why it's built this way:
--detach, then--attach. A synchronoussbx execstops after 30 seconds by default, and a build plus tests can take longer.--attachfollows the detached run until it ends.trap … EXIT. The sandbox is deleted even if a step fails or you press Ctrl-C.--setenv=PATH=\$PATH.sudoresetsPATH, and Go lives in/usr/local/go/bin. Our first run without it failed the build withgo: command not found, exit 127.- One scope for the whole run. The caps apply to every child the README's commands start. What each cap does when it fires is in Limit CPU and memory for untrusted code.
What happened on prod#
NAME=try-fzf ./try-repo.shsandbox sb_blng135nw7
SANDBOXER_API_URL SANDBOXER_ID SANDBOXER_SUBDOMAIN SANDBOXER_TOKEN
deleted sb_blng135nw7Then, on our machine, from the report it brought back:
tar xzf report-sb_blng135nw7.tgz && cut -f1-3 report/results.tsvstep exit ms
1 0 13
2 0 370
3 0 183
4 0 11
5 0 271
6 0 11347
7 0 19808
8 0 874
9 1 20
10 2 6
11 0 438An exit code is not a verdict on its own. Read each step's log next to it:
| Step | What it checked | Exit | What the log said | Verdict |
|---|---|---|---|---|
| 1 | Secrets visible to the code | 0 | 0 sandbox variables inside the scope; no gh login, no git credentials |
Nothing of yours to steal |
| 2 | Pinned fetch | 0 | c0a193e031f0… 2026-10-11 0.74.5 |
Right commit |
| 3 | ~/.fzf/install, verbatim |
0 | Downloading bin/fzf ..., then Update /home/user/.bashrc … + Added |
Works, but it downloads a binary and edits your shell config |
| 4 | What the install changed | 0 | 0.74.5 (c0a193e0), a new ~/.fzf.bash, one line added to ~/.bashrc |
Version matches the pin |
| 5 | Release checksum | 0 | fzf-0.74.5-linux_amd64.tar.gz: OK, bin/fzf matches the release |
The binary is the published one |
| 6 | Build from source | 0 | 0.74.5 (c0a193e0), built in 11.3 s; a different SHA-256 from the release |
Source builds and reports the same version |
| 7 | make test |
0 | 4 packages ok, 143 tests passed |
Tests pass at this commit |
| 8 | Outbound cut | 0 | curl exit 6 |
Network is off for the next steps |
| 9 | "Single binary" | 1 | statically linked, not a dynamic executable |
Claim holds; ldd exits 1 on a static binary |
| 10 | README "Usage", verbatim | 2 | inappropriate ioctl for device |
Needs a real terminal; not a bug |
| 11 | "Millions of items in milliseconds" | 0 | 1,000,000 lines filtered in 0.090 to 0.197 s, three queries, offline | Holds, with the CPU capped at 1.5 cores |
Five things the README alone wouldn't have told you:
- The install script doesn't build the commit you pinned. It downloads a prebuilt release binary from GitHub. Step 5 is how you tie that binary to something you can check: the project's published checksum. Step 6 builds the pinned source instead.
- Your own build won't match the release byte for byte. fzf's BUILD.md makes release archives with goreleaser; our build used plain
makeand the sandbox's Go. Compare the version string and behaviour, not hashes, unless a project documents reproducible builds. Two of our clean builds gave the same hash; one built after other steps had left an extra file in the checkout did not. - Pinning by hash drops the tags. Our first build failed with
Makefile:24: *** Not on git repository; cannot determine $FZF_VERSION. Stop., exit 2. BUILD.md's own fix, settingFZF_VERSIONandFZF_REVISION, worked. - A non-zero exit can be a pass.
lddexits 1 when a binary has no dynamic libraries, which is what the claim says. - Interactive quickstarts fail headless. fzf's usage line opens a full-screen picker. With no terminal it exits 2. Check those by hand with
sbx exec -itorsbx ssh(CLI), or test the non-interactive mode the tool offers, as step 11 does with--filter.
The safety settings that matter#
| Setting | What it does here | What we saw |
|---|---|---|
--no-env |
None of your variables, files or credentials go in. The sandbox's own SANDBOXER_TOKEN can only read that one sandbox and host its ports (environments) |
No API key, gh login or git credentials inside |
sudo systemd-run --scope |
Caps RAM, tasks, CPU and wall time for every process the run starts | Inside the scope, the code saw 0 SANDBOXER_* variables, where a plain sbx exec sees 4 |
ufw default deny outgoing |
Cuts outbound traffic after the downloads | curl exit 6: name lookups fail too, because no rule allows the DNS relay. For an allowlist instead of all-or-nothing, see What can reach a sandbox |
--ttl 1800 and trap … delete |
The VM ends even if your script dies | sbx info on the id returned 404 not_found after the run |
--type small |
2 vCPU, 4 GB, 12 GB disk; enough for most CLI tools | The build and tests fit with room to spare |
sudo cleared the sandbox's variables from the scope's environment. That is a side effect, not a barrier: the run is still the default user, which has passwordless sudo. Code that goes looking can raise its own limits, turn ufw off, or read anything on the disk. Treat everything inside the VM as the repo's.
What a VM protects, and what it doesn't#
It protects:
- Your machine. The install script edited a
~/.bashrc, just not yours. Each sandbox is a microVM with its own kernel (why that matters). - Your account and keys, as long as you don't pass any in. With
--no-env, there are none to take. - Your other sandboxes and internal networks. Sandboxes can't reach each other, private ranges or the metadata address; the networking guide has the tests.
It doesn't protect:
- Against the internet. Outbound is open by default. During install and build, the code can download anything or send out anything that is inside the VM. Cut it before the steps that shouldn't need it.
- Against a repo that behaves well on purpose. A clean run shows what the code did in this run, on this date, in a fresh Linux VM. It is not proof that the code is safe on your laptop, with your files, next month.
- What you copy out. The report and any build output came from the repo's code. Read logs as text; don't run binaries from the sandbox on your own machine unless you would have run the release anyway.
- Your dependencies' future. The checksum check covers the release archive. Go modules were pinned by fzf's
go.sum. A project without a lockfile gets whatever its registry serves that day.
Cost of one check#
small costs $0.018/hour, metered per second while the sandbox is up (billing). Our 39-second run cost 39 × $0.018 / 3600, about $0.0002. A repo that builds for 10 minutes on small costs about $0.003.
FAQ#
Can my agent run this without me?#
Yes. The driver needs only the sbx CLI and an API key on the machine the agent runs on. Have the agent write steps.txt from the README, run try-repo.sh, and read results.tsv and the logs. It never needs your key inside the VM.
Should I pin to a tag or a commit hash?#
A commit hash. A tag can be moved to another commit. Fetch the hash and print it back, as step 2 does. Then set any version variables the build expects from tags, as fzf's BUILD.md describes.
What if the README says to pipe a script from the internet into a shell?#
Download the script into the sandbox first, save it to the report, then run that copy. You keep the exact bytes that ran, even if the URL serves something else tomorrow.
Can I keep the sandbox to look around after a failure?#
Remove the trap line, and the sandbox stays up until its TTL ends. Connect with sbx ssh to inspect it, then delete it yourself.
How do I test docs I own, rather than someone else's repo?#
Run your own pages' code blocks and compare their output with what the page promises. That flow is in Sandboxes for DevRel and runnable docs.
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.