|
| 1 | +# Sandboxing untrusted tools |
| 2 | + |
| 3 | +[‹ docs index](README.md) |
| 4 | + |
| 5 | +Agent/LLM frameworks routinely hand a model the ability to run a "tool" it |
| 6 | +picked itself, with arguments it generated itself — a shell command, a code |
| 7 | +interpreter, a scraper. That tool is, by construction, less trusted than code |
| 8 | +you wrote: it should never be able to outlive your process, exhaust the host, |
| 9 | +or run forever. This guide is not a new capability — it is a **composition** of |
| 10 | +pieces documented individually elsewhere: [Running commands](commands.md) |
| 11 | +(environment, output caps), [Process groups](process-groups.md) (whole-tree |
| 12 | +resource limits), and [Timeouts & cancellation](timeouts-and-cancellation.md) |
| 13 | +(deadlines). It ties them into one recipe, a checklist, and — most |
| 14 | +importantly — an honest statement of what this buys you and what it does not. |
| 15 | + |
| 16 | +- [The threat model](#the-threat-model) |
| 17 | +- [The recipe](#the-recipe) |
| 18 | + - [1. Locked-down environment](#1-locked-down-environment) |
| 19 | + - [2. Bounded output](#2-bounded-output) |
| 20 | + - [3. Whole-tree resource limits](#3-whole-tree-resource-limits) |
| 21 | + - [4. A timeout](#4-a-timeout) |
| 22 | + - [5. Teardown](#5-teardown) |
| 23 | +- [Checklist: run an untrusted tool safely](#checklist-run-an-untrusted-tool-safely) |
| 24 | +- [Full example](#full-example) |
| 25 | + |
| 26 | +## The threat model |
| 27 | + |
| 28 | +Be precise about what a `ProcessGroup` sandbox is — and is not — before |
| 29 | +leaning on it for anything that matters. |
| 30 | + |
| 31 | +**processkit protects against:** |
| 32 | + |
| 33 | +- **Process-tree leakage.** Every process the tool spawns, and everything |
| 34 | + *that* spawns, dies when the sandbox exits — enforced by the kernel |
| 35 | + container (Job Object / cgroup v2 / process group), not a best-effort |
| 36 | + signal to one pid. See [the no-orphan guarantee](process-groups.md#tearing-down). |
| 37 | +- **Resource exhaustion.** Whole-tree memory, process-count (fork bombs), and |
| 38 | + CPU caps are enforced by the kernel (see |
| 39 | + [Resource limits](process-groups.md#resource-limits-the-sandbox)); captured |
| 40 | + output is bounded so a chatty or malicious child cannot grow the parent's |
| 41 | + memory without limit (see |
| 42 | + [Bounding captured output](commands.md#bounding-captured-output)). |
| 43 | +- **Runaway execution time.** A timeout kills the whole tree at a deadline — |
| 44 | + see [Timeouts & cancellation](timeouts-and-cancellation.md). |
| 45 | +- **Ambient credential/environment leakage.** `env_clear()` / |
| 46 | + `inherit_env([...])` starts the child from nothing rather than handing it |
| 47 | + the parent's full environment, secrets included — see |
| 48 | + [Environment and sandboxing](commands.md#environment-and-sandboxing). On |
| 49 | + POSIX you can additionally drop privileges — see |
| 50 | + [Privileges and spawn flags](commands.md#privileges-and-spawn-flags). |
| 51 | + |
| 52 | +**processkit does NOT protect against:** |
| 53 | + |
| 54 | +- **Filesystem access.** The tool can read and write anything the OS |
| 55 | + permits its (possibly privilege-dropped) user to touch. processkit does not |
| 56 | + chroot, bind-mount, or otherwise virtualize the filesystem. |
| 57 | +- **Network access.** No firewalling or network namespace is applied; a |
| 58 | + sandboxed tool can still make outbound connections unless you restrict that |
| 59 | + another way (a container, a network policy, an egress proxy). |
| 60 | +- **Syscall/namespace isolation.** This is not seccomp, and not a |
| 61 | + PID/mount/user-namespace container. A Job Object, cgroup, or process group |
| 62 | + bounds a *tree*'s lifetime and resource consumption — it does not restrict |
| 63 | + *which* syscalls the tree may issue. |
| 64 | +- **Vetting the tool's behavior.** processkit does not sanitize, statically |
| 65 | + analyze, or judge what the program does — it bounds the blast radius (time, |
| 66 | + memory, CPU, process count, orphaned children), not the tool's actions |
| 67 | + within those bounds. |
| 68 | + |
| 69 | +**In short:** this is *resource and lifetime* containment, not *security* |
| 70 | +isolation. If you need syscall, filesystem, or network isolation, pair |
| 71 | +processkit with an actual sandbox — a container, a VM, gVisor, a seccomp |
| 72 | +profile, a restricted service account — processkit composes cleanly with any |
| 73 | +of those; it just spawns and bounds whatever program you point it at. Do not |
| 74 | +let this guide's checklist read as "fully isolated" — it is not. |
| 75 | + |
| 76 | +## The recipe |
| 77 | + |
| 78 | +Compose these five ingredients, in this order, for a locked-down run of an |
| 79 | +untrusted tool: |
| 80 | + |
| 81 | +```python |
| 82 | +from processkit import Command, ProcessGroup, ResourceLimit, Unsupported |
| 83 | + |
| 84 | +tool = ( |
| 85 | + Command("untrusted-tool") |
| 86 | + .env_clear().inherit_env(["PATH"]) # 1 |
| 87 | + .output_limit(max_bytes=8 * 1024 * 1024, on_overflow="error") # 2 |
| 88 | + .timeout(30.0) # 4 |
| 89 | + .kill_on_parent_death() |
| 90 | +) |
| 91 | + |
| 92 | +try: |
| 93 | + with ProcessGroup( # 3 |
| 94 | + max_memory=512 * 1024 * 1024, max_processes=64, cpu_quota=1.0, |
| 95 | + ) as group: |
| 96 | + group.start(tool) |
| 97 | + ... |
| 98 | + # 5. the `with` block's exit reaps the whole tree here — no orphans, ever. |
| 99 | +except (ResourceLimit, Unsupported) as exc: |
| 100 | + ... # no Job Object / cgroup-v2 root here (container, non-root cgroup, macOS) |
| 101 | +``` |
| 102 | + |
| 103 | +### 1. Locked-down environment |
| 104 | + |
| 105 | +Start the child from nothing and allow-list only what it needs — never hand |
| 106 | +an untrusted tool the parent's full environment (which routinely carries |
| 107 | +credentials). Full treatment, including the ordering of `env`/`env_remove` |
| 108 | +on top: [Environment and sandboxing](commands.md#environment-and-sandboxing). |
| 109 | + |
| 110 | +### 2. Bounded output |
| 111 | + |
| 112 | +Cap `max_bytes` so a chatty or malicious tool cannot grow the parent's memory |
| 113 | +without bound (a `max_lines`-only cap does not — one newline-free flood is a |
| 114 | +single, unbounded line). `on_overflow="error"` turns hitting the cap into a |
| 115 | +failure rather than a silent drop, which is usually what you want for a tool |
| 116 | +you don't trust. Full treatment: [Bounding captured output](commands.md#bounding-captured-output). |
| 117 | + |
| 118 | +### 3. Whole-tree resource limits |
| 119 | + |
| 120 | +`max_memory` / `max_processes` / `cpu_quota` on the `ProcessGroup` cap the |
| 121 | +*whole tree* — not just the direct child — at the kernel level. This needs a |
| 122 | +real container (a Windows Job Object or a Linux cgroup-v2 root); where one |
| 123 | +isn't available, the constructor raises `ResourceLimit` rather than handing |
| 124 | +back a silently-unbounded group. Full treatment, including the platform |
| 125 | +matrix: [Resource limits: the sandbox](process-groups.md#resource-limits-the-sandbox). |
| 126 | + |
| 127 | +### 4. A timeout |
| 128 | + |
| 129 | +Untrusted code should never run unbounded. `.timeout(seconds)` kills the |
| 130 | +whole process tree at the deadline; pair it with `.timeout_grace(...)` for a |
| 131 | +graceful signal-then-kill if the tool might want to clean up first. Full |
| 132 | +treatment: [Timeouts & cancellation](timeouts-and-cancellation.md). |
| 133 | + |
| 134 | +### 5. Teardown |
| 135 | + |
| 136 | +Prefer the context manager (`with ProcessGroup() as group: ...` / `with |
| 137 | +Command(...).start() as proc: ...`) over any manual verb — its exit path is |
| 138 | +the no-orphan guarantee, on every platform, even if the block raises. Never |
| 139 | +lean on `__del__` / `atexit`: neither runs if the parent itself is hard-killed. |
| 140 | +Full treatment: [Tearing down](process-groups.md#tearing-down). |
| 141 | + |
| 142 | +## Checklist: run an untrusted tool safely |
| 143 | + |
| 144 | +- [ ] Environment locked down: `env_clear()` + `inherit_env([...])` (or an |
| 145 | + explicit allow-list built from `env(...)` calls) — never inherit the |
| 146 | + parent's full environment into an untrusted child. |
| 147 | +- [ ] Captured output bounded: `output_limit(max_bytes=...)` — a |
| 148 | + `max_lines`-only cap does not bound memory. |
| 149 | +- [ ] Whole-tree resource limits set on a `ProcessGroup`: `max_memory`, |
| 150 | + `max_processes`, `cpu_quota` — with `ResourceLimit` / `Unsupported` |
| 151 | + handled where the kernel container isn't available. |
| 152 | +- [ ] A timeout set (`Command.timeout(...)`, and `Pipeline.timeout(...)` for a |
| 153 | + piped chain) — untrusted code should never run unbounded. |
| 154 | +- [ ] Teardown via a context manager, never `__del__` / `atexit`. |
| 155 | +- [ ] `kill_on_parent_death()` set on the tool, so it dies even if your own |
| 156 | + process crashes before teardown runs. |
| 157 | +- [ ] (POSIX only, if running as a privileged user) privileges dropped with |
| 158 | + all three of `uid` / `gid` / `groups([...])` set together — `uid` alone |
| 159 | + leaves the child holding the parent's supplementary groups. |
| 160 | +- [ ] Read [the threat model](#the-threat-model) above — this checklist buys |
| 161 | + resource and lifetime containment, not syscall/filesystem/network |
| 162 | + isolation. |
| 163 | + |
| 164 | +## Full example |
| 165 | + |
| 166 | +`examples/04_sandbox_resource_limits.py` runs this recipe end to end for an |
| 167 | +agent making a couple of tool calls in one sandboxed session: a locked-down, |
| 168 | +output-capped, per-call-timeout command; whole-tree memory/process/CPU limits |
| 169 | +on the shared group; and teardown on context-manager exit — degrading |
| 170 | +gracefully to "contained, but uncapped" where the kernel container isn't |
| 171 | +available (a container, a non-root cgroup, macOS). |
| 172 | + |
| 173 | +```bash |
| 174 | +python examples/04_sandbox_resource_limits.py |
| 175 | +``` |
| 176 | + |
| 177 | +--- |
| 178 | + |
| 179 | +Next: [Process groups](process-groups.md) · [Running commands](commands.md) · |
| 180 | +[Timeouts & cancellation](timeouts-and-cancellation.md) · |
| 181 | +[Cookbook](cookbook.md) · [Platform support](platforms.md) |
0 commit comments