How Do I Limit CPU and Memory for One Run Inside a Sandbox?
Updated October 27, 2026 .md
Short answer: Wrap the run in a systemd scope, sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=100% -p TasksMax=256 -p RuntimeMaxSec=60. On a real sandbox a memory hog died with exit 137, a fork bomb stalled at 200 tasks, and the agent's own processes kept running. Use timeout, ulimit or a loop-mounted disk for narrower caps.
The sandbox is the security boundary: each one is a microVM with its own kernel (why that matters). Limits inside it solve a different problem. If your agent runs inside the sandbox and executes generated code there too, one bad run can eat all the RAM, CPU or disk the agent itself needs. This page bounds one run so the rest of the sandbox keeps working.
Everything below ran on a small sandbox (2 vCPU, 4 GB RAM) on 2026-10-27, as the default user with sudo. The kernel has cgroup v2 with the cpu, io, memory and pids controllers enabled, and systemd 255.
Which tool caps what#
| Tool | What it caps | What we measured when the cap hit |
|---|---|---|
systemd-run --scope -p MemoryMax= |
RAM for the run and all its children | Killed with exit 137 at the cap; the rest of the sandbox kept about 3 GB free |
-p TasksMax= |
Processes and threads | A fork bomb stuck at 200 tasks; fork: Resource temporarily unavailable |
-p CPUQuota= |
CPU time per second | 50% gave 5.0 s of CPU in 10 s of wall time |
-p RuntimeMaxSec= |
Wall time, for every process in the scope | Exit 143; a setsid child was killed too |
-p IOWriteBandwidthMax= |
Disk write rate | 200 MB at 20.1 MB/s instead of 4.0 GB/s |
timeout |
Wall time of one process tree | Exit 124 (137 with -k if it ignores SIGTERM); a setsid child escaped |
ulimit / prlimit |
Per-process address space, CPU seconds, file size | MemoryError (exit 1), exit 137 or 152 for CPU, exit 153 for file size |
nice |
CPU share under contention | 98.5% against 1.3% for a nice -n 19 spinner on the same core |
| Loop-mounted scratch disk | Total bytes the run can write | No space left on device at 1 GB; the root disk still had 12 GB free |
Exit 137 is not always out of memory: ulimit -t also ends with SIGKILL. To tell them apart and to fix a sandbox that is simply too small, see Fix exit code 137 or a full disk.
A wrapper that bounds one run#
Put every limit in one scope. The scope is a cgroup, so the limits cover the run's children and grandchildren, and stopping it kills all of them:
cat > ~/run-bounded.sh <<"EOF"
#!/bin/bash
# usage: run-bounded.sh <seconds> <command> [args...]
secs=$1; shift
exec sudo systemd-run --scope --quiet --uid="$(id -u)" --gid="$(id -g)" \
-p MemoryMax=1G -p MemorySwapMax=0 \
-p CPUQuota=100% -p TasksMax=256 \
-p IOWriteBandwidthMax="/dev/vda 50M" \
-p RuntimeMaxSec="$secs" \
nice -n 10 "$@"
EOF
chmod +x ~/run-bounded.sh--scope runs the command in the foreground, so your agent gets its output and exit code as usual. --uid/--gid drop back from root to user. The run keeps the working directory and HOME: run-bounded.sh 60 bash -c 'pwd; id -un; echo HOME=$HOME' printed /home/user, user, HOME=/home/user. CPUQuota=100% is one vCPU's worth, so on a 2 vCPU small the agent always has the other one. /dev/vda is the root disk (findmnt -no SOURCE /).
To see what the rest of the sandbox felt, we ran a heartbeat next to each test: a Python loop that sleeps 100 ms and records the longest gap between ticks.
# ~/heartbeat.py: stands in for the agent's own process
import time, sys
last = time.monotonic(); worst = 0
end = last + float(sys.argv[1])
while time.monotonic() < end:
time.sleep(0.1)
now = time.monotonic(); worst = max(worst, now - last); last = now
print(f"heartbeat: worst gap {worst*1000:.0f} ms")Memory: a hog stops at MemoryMax#
The hog allocates 64 MB at a time, up to 5 GB, more than the sandbox has:
# ~/hog.py
chunks = []
for i in range(80):
chunks.append(bytearray(64 * 1024 * 1024))
print(f"{(i + 1) * 64} MB", flush=True)
print("done")python3 heartbeat.py 12 &
~/run-bounded.sh 60 python3 hog.py | tail -1; echo "exit=${PIPESTATUS[0]}"
wait960 MB
exit=137
heartbeat: worst gap 100 msThe hog died at its 1 GB cap and the heartbeat never missed a tick. dmesg says which kind of OOM it was. In a separate run with a 256 MB cap:
Memory cgroup out of memory: Killed process 562 (python3) total-vm:280012kB, anon-rss:261376kB, … UID:1000Memory cgroup out of memory means your cap fired. A plain Out of memory means the whole sandbox ran out. We ran the same hog with no scope for comparison: it took 3712 MB before the kernel killed it (exit 137), and the heartbeat's worst gap grew to 336 ms. The kernel picked the hog that time because it was the largest process, but with no cap the whole sandbox ran short of memory first, and you don't choose which process dies.
MemorySwapMax=0 is a guard in case you add a swap file later. Sandboxes have no swap by default.
Processes: a fork bomb guarded by TasksMax#
Never run a fork bomb without a cap. This test started one inside a scope capped at 200 tasks, looked at the sandbox for 10 seconds, then killed the scope:
cat > ~/bomb-test.sh <<"EOF"
#!/bin/bash
# fork bomb, capped at 200 tasks by its own scope
sudo systemd-run --scope --unit=bomb --uid=user --gid=user -p TasksMax=200 \
bash -c ":(){ :|:& };:" >/dev/null 2>&1 &
sleep 10
systemctl show bomb.scope -p TasksCurrent -p TasksMax
echo "processes in the sandbox: $(ps -e --no-headers | wc -l)"
start=$(date +%s%N); python3 -c "print(sum(range(10**6)))"
echo "outside the scope, python3 took $(( ($(date +%s%N)-start)/1000000 )) ms"
sudo systemctl kill --signal=KILL bomb.scope; sudo systemctl stop bomb.scope
sleep 1
echo "bomb.scope: $(systemctl is-active bomb.scope)"
echo "processes in the sandbox: $(ps -e --no-headers | wc -l)"
EOF
chmod +x ~/bomb-test.sh && ~/bomb-test.shTasksCurrent=200
TasksMax=200
processes in the sandbox: 297
499999500000
outside the scope, python3 took 26 ms
bomb.scope: inactive
processes in the sandbox: 97The same Python one-liner took 20 ms on the idle sandbox, so a capped bomb cost the rest of the sandbox a few milliseconds. systemctl kill --signal=KILL on the scope cleared all 200 processes at once; killing a bomb one PID at a time doesn't work because it keeps forking.
Don't rely on ulimit -u for this. It counts every process of that user, so if the agent and the untrusted code both run as user, the cap starves the agent too. TasksMax counts only the scope.
CPU: CPUQuota and nice#
time sudo systemd-run --scope --quiet --uid=user --gid=user -p CPUQuota=50% \
timeout 10 python3 -c "while True: pass"; echo exit=$?
time timeout 10 python3 -c "while True: pass"; echo exit=$?exit=124
real 0m10.042s
user 0m5.027s
exit=124
real 0m10.002s
user 0m9.998sWith the quota, a spin loop got exactly half a CPU. CPUQuota is a hard cap: the run is throttled even when the CPU is otherwise idle. If you only want the agent to win when both are busy, use nice instead. Two spinners pinned to the same core for 9 seconds:
taskset -c 0 timeout 10 python3 -c "while True: pass" &
taskset -c 0 nice -n 19 timeout 10 python3 -c "while True: pass" &
sleep 9; ps -o pid,ni,time,pcpu,args -p $(pgrep -d, -f "while True"); wait PID NI TIME %CPU COMMAND
…
1817 0 00:00:08 98.5 python3 -c while True: pass
1818 19 00:00:00 1.3 python3 -c while True: passThe wrapper uses both: a quota as the ceiling and nice -n 10 so the run yields first inside it.
Wall time: timeout versus RuntimeMaxSec#
timeout is the simplest guard and returns 124 when it fires. A run that ignores SIGTERM needs -k:
timeout 3 sleep 60; echo "exit=$?"
timeout -k 2 3 bash -c 'trap "" TERM; sleep 60'; echo "exit=$?"exit=124
exit=137But timeout only signals its own process group. A child that calls setsid escapes it. A scope's RuntimeMaxSec stops every process in the cgroup:
timeout 3 bash -c "setsid sleep 301 & sleep 60"; echo "timeout exit=$?"
sleep 1; echo "left behind: $(pgrep -cx sleep)"; pkill -x sleep
sudo systemd-run --scope --quiet --unit=job --uid=user --gid=user -p RuntimeMaxSec=3 \
bash -c "setsid sleep 302 & sleep 60"; echo "scope exit=$?"
sleep 1; echo "left behind: $(pgrep -cx sleep)"timeout exit=124
left behind: 1
scope exit=143
left behind: 0From outside the sandbox, sbx exec --timeout caps a synchronous command at up to 600 seconds (CLI reference); the scope is for runs your agent starts from inside.
ulimit and prlimit: per-process caps without root#
ulimit needs no sudo and applies to one process and what it starts. Run it in a subshell so it doesn't stick to your shell:
(ulimit -v 524288; python3 hog.py 2>&1 | grep -v "^ " | tail -3)
(ulimit -t 3; python3 -c "while True: pass"); echo "exit=$?"
(ulimit -S -t 3; ulimit -H -t 6; python3 -c "while True: pass"); echo "exit=$?"
(ulimit -f 102400; dd if=/dev/zero of=big.bin bs=1M count=500 status=none); echo "exit=$?"448 MB
Traceback (most recent call last):
MemoryError
exit=137
exit=152
exit=153ulimit -vlimits address space, so the program gets a cleanMemoryError(exit 1) instead of a kill. It also breaks programs that reserve large virtual ranges (JVMs, Go and Node.js runtimes often do), and it doesn't add up across child processes.MemoryMaxis the safer memory cap.ulimit -tsets the soft and hard CPU-seconds limits together, and the kernel sent SIGKILL (137). With a lower soft limit the process gets SIGXCPU first (152) and can clean up.ulimit -fcaps the size of each file (in 1 KB blocks):ddstopped at exactly 104857600 bytes with SIGXFSZ (153). It doesn't cap the total across many files.
prlimit sets the same limits on a command, or on a process that is already running:
prlimit --as=536870912 --cpu=3 python3 hog.py 2>&1 | tail -1; echo "exit=${PIPESTATUS[0]}"
python3 -c "while True: pass" & p=$!; prlimit --pid $p --cpu=2:2; wait $p; echo "exit=$?"MemoryError
exit=1
exit=137Disk: a loop-mounted scratch directory#
ulimit -f caps one file. To cap everything a run writes, give it a scratch directory backed by a fixed-size filesystem:
sudo fallocate -l 1G /var/tmp/scratch.img
sudo mkfs.ext4 -q /var/tmp/scratch.img
sudo mkdir -p /scratch && sudo mount -o loop /var/tmp/scratch.img /scratch
sudo chown user:user /scratch
dd if=/dev/zero of=/scratch/fill.bin bs=1M count=5000 status=none; echo "dd exit=$?"
df -h / /scratch
echo hello > ~/note.txt && echo "write to home: ok"dd: error writing '/scratch/fill.bin': No space left on device
dd exit=1
Filesystem Size Used Avail Use% Mounted on
/dev/root 19G 6.4G 12G 35% /
/dev/loop0 974M 907M 196K 100% /scratch
write to home: okThe run filled its 1 GB and stopped. The root disk kept 12 GB free, so the agent could still write. Run the code with /scratch as its working directory, and delete and recreate the image between runs for a clean slate. The mount is not in /etc/fstab, so mount it again after a sandbox boots from disk (how to tell). Code that can write anywhere else, such as /tmp or your home directory, isn't capped by this, so combine it with a separate user or a read-only bind mount if that matters.
To limit how fast a run writes rather than how much, use IOWriteBandwidthMax. Writing 200 MB with dd ... oflag=direct:
with -p IOWriteBandwidthMax="/dev/vda 20M": 209715200 bytes … copied, 10.434 s, 20.1 MB/s
without: 209715200 bytes … copied, 0.0526342 s, 4.0 GB/sWe also tried ionice -c3, and it made no measurable difference. The root disk uses the mq-deadline scheduler (cat /sys/block/vda/queue/scheduler), and I/O priority classes only take effect under the BFQ scheduler (ionice man page). Use the cgroup's bandwidth cap instead.
When to use a second sandbox instead#
The limits above protect the agent's resources. They don't hide the agent's files, environment or network from the run. If the code is untrusted in the security sense, run it in its own --no-env sandbox, with none of your secrets in it, and delete it after: see A fresh sandbox per tool call and Pass secrets to a sandbox safely. To cap what many sandboxes cost in total, see Cap what agent sandboxes can cost.
FAQ#
Do I need root to use systemd-run --scope?#
A system scope needs root. Inside a sandbox you are an ordinary user with sudo (machines docs). --uid and --gid then run the command as user, so the run itself doesn't get root. Without sudo, ulimit, prlimit, nice and timeout still work.
Why did my capped run exit 143 and not 124?#
143 is SIGTERM (128 + 15). That is what RuntimeMaxSec sends when a scope runs out of time. timeout instead exits with 124 by itself when it fires. Both mean the wall-time limit was hit.
Does a scope's MemoryMax count page cache?#
Yes. cgroup v2 charges file cache to the cgroup too, but the kernel reclaims cache before it kills anything, so a run that reads big files slows down rather than dying. The kills we saw came from memory the run itself allocated.
Will the limits survive a stop and resume?#
We didn't test a stop in the middle of a capped run. A scope is created per run and isn't saved anywhere, so after a boot from disk it is gone along with its processes; the wrapper script on disk is kept and applies the same limits to the next run. The loop mount needs to be mounted again too.
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.