Multi-tenant sandboxed execution for untrusted code — where the escape-attempt suite is the headline feature, not an afterthought.
Anyone can spawn a subprocess with a timeout. The engineering is in making the escape surface small and demonstrably small, and in never quietly providing weaker isolation than the operator asked for. Every control here has a paired negative test that actively tries to defeat it, and the sandbox refuses to run if the host cannot enforce the policy you specified.
$ agsbx doctor
platform : darwin: process_ceiling, rlimits, sandbox_exec
kernel mediation : True
memory ceiling : False
settable rlimits : core, cpu, fsize, nofile, nproc
ISOLATION GAPS on this host:
- hard memory ceiling (RLIMIT_AS unsettable, no cgroup v2)
Jobs needing these controls are refused unless --allow-degraded is passed.
That output is the point of the project. macOS defines RLIMIT_AS but its kernel rejects every attempt to set it, so a memory ceiling on Darwin without cgroups is a fiction. Most sandboxes apply the limit, ignore the error, and report success. This one probes what the host actually accepts, refuses the job, and — if you override — records NO-MEMORY-CEILING(rlimit) in the job's permanent record.
git clone https://github.com/vinzabe/agent-sandbox && cd agent-sandbox
python -m pip install -e ".[dev]"
agsbx doctor # what can this host enforce?
agsbx run -c 'print("hello")' # refused if there are gaps
agsbx run --allow-degraded -c 'print("hi")' # runs, gap recorded on the job
pytest tests/escapes/ -v # watch the escape attempts fail| Layer | Mechanism | Verified by |
|---|---|---|
| Policy | Declarative limits validated before a process exists | test_policy.py |
| Filesystem | Private scratch root; writes confined to the job dir | escapes/test_fs_escapes.py |
| Kernel | seccomp allowlist (Linux) / sandbox-exec SBPL (macOS) |
escapes/test_profile_invariants.py |
| Resources | Probed rlimits; cgroup v2 where present | escapes/test_resource_escapes.py |
| Environment | Minimal env; loader-hijack vars refused at construction | escapes/test_env_escapes.py |
| Network | Denied by default (netns on Linux, SBPL on macOS) | test_profile_invariants.py |
| Lifecycle | Durable job records + a reaper that cannot leak | test_service.py |
Not "does it run" — does it stay contained:
- CPU spin → killed by wall clock
- 512 MiB allocation against a 64 MiB ceiling → contained, or skipped with a stated reason if the host cannot cap memory
- Fork bomb (2000 forks) →
RLIMIT_NPROCrefuses or wall clock kills; the host survives - 10 MB stdout flood → truncated at read time, flagged
output_truncated - 64 MiB file write against a 1 MiB
RLIMIT_FSIZE→ denied - 10 000 open file descriptors → bounded by
RLIMIT_NOFILE LD_PRELOAD,DYLD_INSERT_LIBRARIES,PYTHONPATH,NODE_OPTIONS,BASH_ENV(12 vars) → rejected at policy construction, not filtered later- Write to
/etc→ denied; read~/.ssh,~/Documents,~/Library/Keychains→ denied - Child raising its own
RLIMIT_AS→ refused (hard == soft) - A rule table edit that widens the syscall allowlist → caught by an invariant test asserting
SECCOMP_ALLOW ∩ SECCOMP_NEVER == ∅
| Code | Meaning |
|---|---|
0 |
Job completed successfully |
2 |
Job ran but failed, or was killed / timed out |
3 |
Host cannot provide the requested isolation — job not run |
1 |
Usage or runtime error |
2 and 3 are deliberately distinct: "your code is broken" and "my sandbox is weaker than you asked for" demand completely different responses, and collapsing them into one non-zero code is how degraded isolation goes unnoticed.
| Command | Purpose |
|---|---|
agsbx run |
Execute untrusted Python. --wall, --cpu, --memory-mb, --max-procs, --network, --allow-env, --tenant, --allow-degraded, --json |
agsbx doctor |
Report host capabilities and isolation gaps |
agsbx recover |
Reap jobs orphaned by a crash |
agsbx jobs |
List recent jobs with state and confinement |
agsbx policy |
Print the exact confinement a policy requests (syscall allowlist included) |
Two mechanisms, because one is not enough:
- Every child gets its own process group (
setsid()inpreexec_fn) and is killed as a group — SIGTERM, then SIGKILL after a grace period. Thefinallyblock runs on timeout, exception, and interpreter shutdown alike. - Jobs are written to SQLite before the process starts. A crash mid-execution leaves a
runningrow, andagsbx recoverfinds it, kills any surviving group, and marks itreaped. Call it at startup.
An orphaned sandbox surviving a restart is the failure mode that actually bites in production — far more often than a clever syscall escape. It gets two controls and its own tests (test_recover_reaps_orphans, test_recover_is_idempotent).
- Call
agsbx recoveron startup. Without it, a crash leaks a sandbox permanently. - Read
doctorbefore trusting a host. Linux with cgroup v2 + seccomp is the only configuration where every advertised control is real. --allow-degradedis recorded, not forgotten. The weakened confinement is stored on the job row, so an audit can find every job that ran under reduced isolation.- State is
agsbx.db(SQLite, WAL). Back it up; it is your job ledger. - Boundaries and non-goals:
THREAT_MODEL.md. Read it before pointing this at genuinely hostile input.
python -m pip install -e ".[dev]"
pytest --cov=agsbx # 91 tests, ~91% coverage
pytest tests/escapes/ -v # the interesting part
mypy --strict src/agsbx # clean
ruff check src tests # cleanMIT © vinzabe