# How Do I Limit CPU and Memory for One Run Inside a Sandbox?

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

Source: https://sandboxer.com/guides/limit-cpu-and-memory-for-untrusted-code-inside-a-sandbox. Updated 2026-10-27.

**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](/guides/container-vs-microvm-for-untrusted-code)). 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](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox).

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

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

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

```python
# ~/hog.py
chunks = []
for i in range(80):
    chunks.append(bytearray(64 * 1024 * 1024))
    print(f"{(i + 1) * 64} MB", flush=True)
print("done")
```

```bash
python3 heartbeat.py 12 &
~/run-bounded.sh 60 python3 hog.py | tail -1; echo "exit=${PIPESTATUS[0]}"
wait
```

```text
960 MB
exit=137
heartbeat: worst gap 100 ms
```

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

```text
Memory cgroup out of memory: Killed process 562 (python3) total-vm:280012kB, anon-rss:261376kB, … UID:1000
```

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

```bash
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.sh
```

```text
TasksCurrent=200
TasksMax=200
processes in the sandbox: 297
499999500000
outside the scope, python3 took 26 ms
bomb.scope: inactive
processes in the sandbox: 97
```

The 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

```bash
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=$?
```

```text
exit=124
real	0m10.042s
user	0m5.027s
exit=124
real	0m10.002s
user	0m9.998s
```

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

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

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

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

```bash
timeout 3 sleep 60; echo "exit=$?"
timeout -k 2 3 bash -c 'trap "" TERM; sleep 60'; echo "exit=$?"
```

```text
exit=124
exit=137
```

But `timeout` only signals its own process group. A child that calls `setsid` escapes it. A scope's `RuntimeMaxSec` stops every process in the cgroup:

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

```text
timeout exit=124
left behind: 1
scope exit=143
left behind: 0
```

From outside the sandbox, `sbx exec --timeout` caps a synchronous command at up to 600 seconds ([CLI reference](/docs/cli)); 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:

```bash
(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=$?"
```

```text
448 MB
Traceback (most recent call last):
MemoryError
exit=137
exit=152
exit=153
```

- `ulimit -v` limits address space, so the program gets a clean `MemoryError` (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. `MemoryMax` is the safer memory cap.
- `ulimit -t` sets 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 -f` caps the size of each file (in 1 KB blocks): `dd` stopped 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:

```bash
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=$?"
```

```text
MemoryError
exit=1
exit=137
```

## Disk: 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:

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

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

The 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](/guides/detect-a-cold-boot-after-resume)). 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`:

```text
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/s
```

We 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](https://man7.org/linux/man-pages/man1/ionice.1.html)). 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](/guides/a-fresh-sandbox-per-tool-call) and [Pass secrets to a sandbox safely](/guides/pass-secrets-to-a-sandbox-safely). To cap what many sandboxes cost in total, see [Cap what agent sandboxes can cost](/guides/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](/docs/machines)). `--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

- [Fix exit code 137 or a full disk in a sandbox](/guides/fix-exit-code-137-or-a-full-disk-in-a-sandbox)
- [A fresh sandbox per tool call](/guides/a-fresh-sandbox-per-tool-call)
- [Container vs microVM for untrusted code](/guides/container-vs-microvm-for-untrusted-code)
- [Machines and sizes](/docs/machines)
