kern-sandbox 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ build/
7
+ dist/
8
+ .kern-env
9
+ kern-ws-*/
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: kern-sandbox
3
+ Version: 0.1.0
4
+ Summary: Run code in a fast, local, daemonless kernel sandbox — no cloud, no account, no KVM.
5
+ Project-URL: Homepage, https://github.com/getkern/kern
6
+ Project-URL: Source, https://github.com/getkern/kern/tree/main/bindings/python
7
+ Author: getkern
8
+ License: Apache-2.0
9
+ Keywords: agent,ci,code-execution,edge,isolation,namespaces,sandbox,seccomp
10
+ Classifier: License :: OSI Approved :: Apache Software License
11
+ Classifier: Operating System :: POSIX :: Linux
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Security
14
+ Classifier: Topic :: Software Development :: Testing
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+
18
+ # kern-sandbox
19
+
20
+ Run LLM/agent-generated code in a fast, **local**, daemonless kernel sandbox from Python.
21
+
22
+ ```python
23
+ import kern_sandbox as kern
24
+
25
+ # one-shot
26
+ r = kern.run_code("import sys; print(sys.version)")
27
+ print(r.stdout, r.success)
28
+
29
+ # a session: FILE state persists across steps (a workspace on disk); each step is a fresh box
30
+ with kern.Sandbox(setup="pip install pandas matplotlib") as sbx:
31
+ sbx.write_file("data.csv", "a,b\n1,2\n3,4\n")
32
+ r = sbx.run_code("import pandas as pd; print(pd.read_csv('data.csv').shape)") # (2, 2)
33
+ sbx.run_code(
34
+ "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as p; "
35
+ "p.plot([1, 4, 9]); p.savefig('out.png')"
36
+ )
37
+ png = sbx.read_file("out.png") # bytes of the plot the previous step created
38
+ ```
39
+
40
+ A thin, safe wrapper around the [`kern`](https://github.com/getkern/kern) binary — it shells out to
41
+ `kern box`, it does **not** re-implement isolation in Python. Each `run_code`/`run` spawns a fresh,
42
+ ephemeral kernel sandbox (user namespace + seccomp + cgroups). See [Performance](#performance) for
43
+ measured numbers.
44
+
45
+ ## The model: file-state persists, processes are ephemeral
46
+
47
+ - **File state persists between steps** via a `/workspace` directory on disk, shared into every box.
48
+ Write a file in one `run_code`, read it in the next.
49
+ - **Processes are ephemeral**: each call is a *fresh* box. **In-memory REPL state does NOT persist** —
50
+ a `x = 40` set in one call is gone in the next. Write to disk if you need continuity (agents should
51
+ anyway: it survives crashes and is inspectable).
52
+
53
+ This is deliberate. It keeps the cold-start/density win (hundreds of ephemeral boxes, not hundreds of
54
+ resident interpreters holding RAM) instead of a cloud-session model. If you need in-memory Jupyter-style
55
+ state, this isn't that — and that's the point.
56
+
57
+ ## Why this and not a cloud sandbox
58
+
59
+ E2B / Modal / Daytona run code in cloud microVMs — control plane, API key, KVM, network latency.
60
+ **kern-sandbox runs on your own machine, in CI, on an edge box** — no daemon, no cloud, no account,
61
+ no KVM. The sandbox for an agent's dev loop, a CI step, or an air-gapped host.
62
+
63
+ ## Performance
64
+
65
+ Measured on one x86_64 laptop (kern 0.6.3, `python:3.12-slim`), not aspirational. Your hardware will
66
+ differ — measure and claim your own number.
67
+
68
+ **Single call, sequential** (p50):
69
+
70
+ | call (p50) | `enforce_limits=False` | default (`enforce_limits=True`) |
71
+ | --- | --- | --- |
72
+ | `run(["true"])` (bare box) | ~3.5 ms | ~7.5 ms |
73
+ | `run_code("print(1)")` (+ Python interpreter start) | ~16 ms | ~32 ms |
74
+ | `docker run python:3.12-slim python3 -c` | — | ~344 ms |
75
+
76
+ For reference, `kern box` **natively** (no Python wrapper) is ~1.9 ms — the ~3.5 ms bare-box row is that
77
+ plus the wrapper's subprocess + reader-thread overhead.
78
+
79
+ `run_code` runs *Python code*, so it pays the **CPython interpreter start** (~12 ms) on top of the box —
80
+ that's a Python cost, not kern's, and it's why `run_code` is ~16 ms, not the bare box's ~3.5 ms. Even so:
81
+ **~16 ms vs Docker's ~344 ms is about 20× faster** for the same task, and we quote the number you get from
82
+ `run_code`, never the bare-box best case dressed up as the code-execution number.
83
+
84
+ **Concurrency** — the default hard-enforces caps via a per-call systemd scope, which **contends under
85
+ heavy parallelism**. 100 concurrent `run_code` calls, 100/100 succeeded, zero leaked boxes, but:
86
+
87
+ | 100 concurrent `run_code` | wall | per-call p50 | per-call p95 |
88
+ | --- | --- | --- | --- |
89
+ | default (`enforce_limits=True`) | ~0.58 s | ~510 ms | ~550 ms |
90
+ | `enforce_limits=False` (best-effort caps) | ~0.12 s | ~59 ms | ~89 ms |
91
+
92
+ If you fire many boxes concurrently and can accept best-effort (not hard-enforced) resource caps, set
93
+ `enforce_limits=False` for the ~5× density win. The default stays hard-enforced and safe.
94
+
95
+ ## Safe by default
96
+
97
+ A bare `Sandbox()` has **no network, no host mounts, seccomp on, dangerous caps dropped, and a
98
+ mandatory finite timeout**. Every relaxation is an explicit, named argument.
99
+
100
+ ```python
101
+ Sandbox(
102
+ image="python:3.12-slim", # OCI image (default: a small Python base)
103
+ setup="pip install pandas", # the ONLY network window — a separate net-on setup box; run_code is net-off
104
+ workspace=None, # None → temp dir, deleted on __exit__; a path → persists across sessions
105
+ memory_mb=512,
106
+ cpus=None, # CPU cap in cores (e.g. 1.5); None = uncapped
107
+ pids=256, # fork-bomb ceiling
108
+ timeout_s=30, # MANDATORY per-call wall-clock limit
109
+ network=False, # RELAXES ISOLATION — True shares the host network for every run
110
+ mounts=None, # {host_src: box_target} or {src: (target, "ro")}; sensitive sources refused
111
+ max_output_bytes=64 << 20, # cap on captured stdout/stderr EACH; overflow discarded, result.truncated set
112
+ deps_readonly=False, # True → run_code can't modify setup= deps (blocks cross-run poisoning)
113
+ enforce_limits=True, # hard-enforce caps via a systemd scope; False = best-effort, faster under load
114
+ )
115
+ ```
116
+
117
+ Host mounts over sensitive sources (`/`, `/etc`, `$HOME`, the docker socket, …) are **refused even if
118
+ you ask**. Captured output is **bounded** (`max_output_bytes` each) — a flooding box can't OOM the host.
119
+
120
+ **Network policy:** the network is on **only** during `setup=` (a separate box that dies when setup
121
+ ends); every `run_code` runs network-off. There is no per-call network override — `network=True` is a
122
+ session-level, explicit choice.
123
+
124
+ **Dependencies (`setup=`)** install into `<workspace>/.deps` (on `PYTHONPATH`). By default that dir is
125
+ writable, so code run in a session *can* modify the deps a later step in the **same session** sees
126
+ (sessions are isolated from each other — distinct workspace). If you run untrusted code and need dep
127
+ integrity across steps, pass `deps_readonly=True`.
128
+
129
+ ## Results, and what a fault means
130
+
131
+ ```python
132
+ @dataclass
133
+ class ExecutionResult:
134
+ stdout: str
135
+ stderr: str
136
+ exit_code: int
137
+ duration_ms: int
138
+ fault: SandboxFault | None # set ONLY when the SANDBOX acted; None for ordinary user-code failures
139
+ files: list[FileInfo] # workspace files created/modified this step (.deps excluded)
140
+ success: bool # exit_code == 0 AND fault is None
141
+ ```
142
+
143
+ **A Python exception in your code is NOT a fault** — it's `exit_code != 0`, a traceback in `stderr`,
144
+ `fault is None`. `fault` is set only when the sandbox stopped the code:
145
+
146
+ - `timeout` — the call exceeded `timeout_s` (the binding owns and enforces this deadline).
147
+ - `escape_blocked` — a syscall was blocked by the seccomp filter (SIGSYS).
148
+ - `killed` — the box was SIGKILLed, not by our deadline (message notes it's *likely* OOM; the binding
149
+ can't read the box cgroup to confirm, so it won't claim `oom` as the type).
150
+ - `startup_failed` — kern couldn't start the box (best-effort, from kern's own diagnostics).
151
+
152
+ ## API
153
+
154
+ - `kern.run_code(code, **kwargs)` — one-shot: a throwaway `Sandbox` under the hood. Returns an `ExecutionResult`.
155
+ - `Sandbox(...).run_code(code, language="python"|"bash")` — run code on the session workspace (fresh box).
156
+ - `Sandbox(...).run(argv_list)` — run an arbitrary command (an **argv list**, never a shell string).
157
+ - `Sandbox(...).write_file(path, data)` / `.read_file(path)` / `.list_files(subdir="")` — workspace I/O,
158
+ confined to `/workspace` (symlink- and `..`-safe).
159
+
160
+ ## Threat model (honest)
161
+
162
+ kern is a **kernel-boundary** sandbox for **your own or semi-trusted** code. The seccomp filter is a
163
+ **denylist** — suitable for semi-trusted agent code, **not** a hard boundary against deliberately
164
+ hostile multi-tenant code. For that, use a microVM (Firecracker / Kata) or gVisor. A deny-by-default
165
+ allowlist mode is on the roadmap. See the project
166
+ [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
167
+
168
+ ## Requirements
169
+
170
+ The `kern` binary on `PATH` (or set `$KERN_BIN`). Linux only.
171
+
172
+ ## License
173
+
174
+ Apache-2.0.
@@ -0,0 +1,157 @@
1
+ # kern-sandbox
2
+
3
+ Run LLM/agent-generated code in a fast, **local**, daemonless kernel sandbox from Python.
4
+
5
+ ```python
6
+ import kern_sandbox as kern
7
+
8
+ # one-shot
9
+ r = kern.run_code("import sys; print(sys.version)")
10
+ print(r.stdout, r.success)
11
+
12
+ # a session: FILE state persists across steps (a workspace on disk); each step is a fresh box
13
+ with kern.Sandbox(setup="pip install pandas matplotlib") as sbx:
14
+ sbx.write_file("data.csv", "a,b\n1,2\n3,4\n")
15
+ r = sbx.run_code("import pandas as pd; print(pd.read_csv('data.csv').shape)") # (2, 2)
16
+ sbx.run_code(
17
+ "import matplotlib; matplotlib.use('Agg'); import matplotlib.pyplot as p; "
18
+ "p.plot([1, 4, 9]); p.savefig('out.png')"
19
+ )
20
+ png = sbx.read_file("out.png") # bytes of the plot the previous step created
21
+ ```
22
+
23
+ A thin, safe wrapper around the [`kern`](https://github.com/getkern/kern) binary — it shells out to
24
+ `kern box`, it does **not** re-implement isolation in Python. Each `run_code`/`run` spawns a fresh,
25
+ ephemeral kernel sandbox (user namespace + seccomp + cgroups). See [Performance](#performance) for
26
+ measured numbers.
27
+
28
+ ## The model: file-state persists, processes are ephemeral
29
+
30
+ - **File state persists between steps** via a `/workspace` directory on disk, shared into every box.
31
+ Write a file in one `run_code`, read it in the next.
32
+ - **Processes are ephemeral**: each call is a *fresh* box. **In-memory REPL state does NOT persist** —
33
+ a `x = 40` set in one call is gone in the next. Write to disk if you need continuity (agents should
34
+ anyway: it survives crashes and is inspectable).
35
+
36
+ This is deliberate. It keeps the cold-start/density win (hundreds of ephemeral boxes, not hundreds of
37
+ resident interpreters holding RAM) instead of a cloud-session model. If you need in-memory Jupyter-style
38
+ state, this isn't that — and that's the point.
39
+
40
+ ## Why this and not a cloud sandbox
41
+
42
+ E2B / Modal / Daytona run code in cloud microVMs — control plane, API key, KVM, network latency.
43
+ **kern-sandbox runs on your own machine, in CI, on an edge box** — no daemon, no cloud, no account,
44
+ no KVM. The sandbox for an agent's dev loop, a CI step, or an air-gapped host.
45
+
46
+ ## Performance
47
+
48
+ Measured on one x86_64 laptop (kern 0.6.3, `python:3.12-slim`), not aspirational. Your hardware will
49
+ differ — measure and claim your own number.
50
+
51
+ **Single call, sequential** (p50):
52
+
53
+ | call (p50) | `enforce_limits=False` | default (`enforce_limits=True`) |
54
+ | --- | --- | --- |
55
+ | `run(["true"])` (bare box) | ~3.5 ms | ~7.5 ms |
56
+ | `run_code("print(1)")` (+ Python interpreter start) | ~16 ms | ~32 ms |
57
+ | `docker run python:3.12-slim python3 -c` | — | ~344 ms |
58
+
59
+ For reference, `kern box` **natively** (no Python wrapper) is ~1.9 ms — the ~3.5 ms bare-box row is that
60
+ plus the wrapper's subprocess + reader-thread overhead.
61
+
62
+ `run_code` runs *Python code*, so it pays the **CPython interpreter start** (~12 ms) on top of the box —
63
+ that's a Python cost, not kern's, and it's why `run_code` is ~16 ms, not the bare box's ~3.5 ms. Even so:
64
+ **~16 ms vs Docker's ~344 ms is about 20× faster** for the same task, and we quote the number you get from
65
+ `run_code`, never the bare-box best case dressed up as the code-execution number.
66
+
67
+ **Concurrency** — the default hard-enforces caps via a per-call systemd scope, which **contends under
68
+ heavy parallelism**. 100 concurrent `run_code` calls, 100/100 succeeded, zero leaked boxes, but:
69
+
70
+ | 100 concurrent `run_code` | wall | per-call p50 | per-call p95 |
71
+ | --- | --- | --- | --- |
72
+ | default (`enforce_limits=True`) | ~0.58 s | ~510 ms | ~550 ms |
73
+ | `enforce_limits=False` (best-effort caps) | ~0.12 s | ~59 ms | ~89 ms |
74
+
75
+ If you fire many boxes concurrently and can accept best-effort (not hard-enforced) resource caps, set
76
+ `enforce_limits=False` for the ~5× density win. The default stays hard-enforced and safe.
77
+
78
+ ## Safe by default
79
+
80
+ A bare `Sandbox()` has **no network, no host mounts, seccomp on, dangerous caps dropped, and a
81
+ mandatory finite timeout**. Every relaxation is an explicit, named argument.
82
+
83
+ ```python
84
+ Sandbox(
85
+ image="python:3.12-slim", # OCI image (default: a small Python base)
86
+ setup="pip install pandas", # the ONLY network window — a separate net-on setup box; run_code is net-off
87
+ workspace=None, # None → temp dir, deleted on __exit__; a path → persists across sessions
88
+ memory_mb=512,
89
+ cpus=None, # CPU cap in cores (e.g. 1.5); None = uncapped
90
+ pids=256, # fork-bomb ceiling
91
+ timeout_s=30, # MANDATORY per-call wall-clock limit
92
+ network=False, # RELAXES ISOLATION — True shares the host network for every run
93
+ mounts=None, # {host_src: box_target} or {src: (target, "ro")}; sensitive sources refused
94
+ max_output_bytes=64 << 20, # cap on captured stdout/stderr EACH; overflow discarded, result.truncated set
95
+ deps_readonly=False, # True → run_code can't modify setup= deps (blocks cross-run poisoning)
96
+ enforce_limits=True, # hard-enforce caps via a systemd scope; False = best-effort, faster under load
97
+ )
98
+ ```
99
+
100
+ Host mounts over sensitive sources (`/`, `/etc`, `$HOME`, the docker socket, …) are **refused even if
101
+ you ask**. Captured output is **bounded** (`max_output_bytes` each) — a flooding box can't OOM the host.
102
+
103
+ **Network policy:** the network is on **only** during `setup=` (a separate box that dies when setup
104
+ ends); every `run_code` runs network-off. There is no per-call network override — `network=True` is a
105
+ session-level, explicit choice.
106
+
107
+ **Dependencies (`setup=`)** install into `<workspace>/.deps` (on `PYTHONPATH`). By default that dir is
108
+ writable, so code run in a session *can* modify the deps a later step in the **same session** sees
109
+ (sessions are isolated from each other — distinct workspace). If you run untrusted code and need dep
110
+ integrity across steps, pass `deps_readonly=True`.
111
+
112
+ ## Results, and what a fault means
113
+
114
+ ```python
115
+ @dataclass
116
+ class ExecutionResult:
117
+ stdout: str
118
+ stderr: str
119
+ exit_code: int
120
+ duration_ms: int
121
+ fault: SandboxFault | None # set ONLY when the SANDBOX acted; None for ordinary user-code failures
122
+ files: list[FileInfo] # workspace files created/modified this step (.deps excluded)
123
+ success: bool # exit_code == 0 AND fault is None
124
+ ```
125
+
126
+ **A Python exception in your code is NOT a fault** — it's `exit_code != 0`, a traceback in `stderr`,
127
+ `fault is None`. `fault` is set only when the sandbox stopped the code:
128
+
129
+ - `timeout` — the call exceeded `timeout_s` (the binding owns and enforces this deadline).
130
+ - `escape_blocked` — a syscall was blocked by the seccomp filter (SIGSYS).
131
+ - `killed` — the box was SIGKILLed, not by our deadline (message notes it's *likely* OOM; the binding
132
+ can't read the box cgroup to confirm, so it won't claim `oom` as the type).
133
+ - `startup_failed` — kern couldn't start the box (best-effort, from kern's own diagnostics).
134
+
135
+ ## API
136
+
137
+ - `kern.run_code(code, **kwargs)` — one-shot: a throwaway `Sandbox` under the hood. Returns an `ExecutionResult`.
138
+ - `Sandbox(...).run_code(code, language="python"|"bash")` — run code on the session workspace (fresh box).
139
+ - `Sandbox(...).run(argv_list)` — run an arbitrary command (an **argv list**, never a shell string).
140
+ - `Sandbox(...).write_file(path, data)` / `.read_file(path)` / `.list_files(subdir="")` — workspace I/O,
141
+ confined to `/workspace` (symlink- and `..`-safe).
142
+
143
+ ## Threat model (honest)
144
+
145
+ kern is a **kernel-boundary** sandbox for **your own or semi-trusted** code. The seccomp filter is a
146
+ **denylist** — suitable for semi-trusted agent code, **not** a hard boundary against deliberately
147
+ hostile multi-tenant code. For that, use a microVM (Firecracker / Kata) or gVisor. A deny-by-default
148
+ allowlist mode is on the roadmap. See the project
149
+ [SECURITY.md](https://github.com/getkern/kern/blob/main/SECURITY.md).
150
+
151
+ ## Requirements
152
+
153
+ The `kern` binary on `PATH` (or set `$KERN_BIN`). Linux only.
154
+
155
+ ## License
156
+
157
+ Apache-2.0.
@@ -0,0 +1,644 @@
1
+ """kern-sandbox — run LLM/agent-generated code in a fast, local, daemonless kernel sandbox.
2
+
3
+ import kern_sandbox as kern
4
+
5
+ # one-shot (a throwaway session under the hood)
6
+ r = kern.run_code("import sys; print(sys.version)")
7
+ print(r.stdout, r.success)
8
+
9
+ # a session: FILE state persists across steps (a workspace on disk), processes are ephemeral
10
+ with kern.Sandbox(setup="pip install pandas") as sbx:
11
+ sbx.write_file("data.csv", csv_bytes)
12
+ r = sbx.run_code("import pandas as pd; print(pd.read_csv('data.csv').shape)")
13
+ png = sbx.read_file("out.png")
14
+
15
+ Design — the "middle way" (validated with review):
16
+ * FILE state persists between steps via a workspace DIRECTORY on the host, bind-mounted into each
17
+ box. PROCESSES are ephemeral: every run_code()/run() spawns a FRESH box on that shared workspace.
18
+ There is NO resident interpreter — in-memory REPL state (a `x=40` living in globals) does NOT
19
+ survive between calls; write to disk if you need continuity. This keeps the cold-start/density
20
+ win (100s of ephemeral boxes, not 100s of resident pythons) instead of chasing a cloud-session
21
+ model kern isn't built for.
22
+ * ONE class (`Sandbox`). `run_code(...)` at module level is literally a throwaway session
23
+ (`with Sandbox() as s: return s.run_code(...)`), so there is a single, tested security code path —
24
+ not two Sandbox-like surfaces that drift apart. (# DECISION, reviewer-ratified.)
25
+ * I/O is HOST-DIRECT: the workspace is a host dir and single-uid maps box-root to the host user, so
26
+ files the box creates are host-owned — write_file/read_file are plain host filesystem I/O, no
27
+ `kern cp`, no in-box shim. (`--uid-range` breaks this ownership and is OUT of v1 scope. # DECISION.)
28
+
29
+ Threat model (honest): kern is a KERNEL-BOUNDARY sandbox for YOUR OWN or SEMI-TRUSTED code. seccomp
30
+ is a DENYLIST — suitable for semi-trusted agent code, NOT a hard boundary against deliberately hostile
31
+ multi-tenant code (for that: a microVM / gVisor). A deny-by-default allowlist mode is on the roadmap.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import shutil
38
+ import signal
39
+ import stat
40
+ import subprocess
41
+ import tempfile
42
+ import threading
43
+ import time
44
+ import uuid
45
+ from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+ from typing import Literal, Mapping, Sequence
48
+
49
+ __all__ = [
50
+ "Sandbox",
51
+ "ExecutionResult",
52
+ "SandboxFault",
53
+ "FileInfo",
54
+ "SandboxError",
55
+ "MountRefused",
56
+ "run_code",
57
+ ]
58
+
59
+ __version__ = "0.1.0"
60
+
61
+ # DECISION: default image is a small Python base. Criterion "import pandas with no setup" needs a
62
+ # batteries-included image; for v1 we start from a PUBLIC image and let `setup=` bake deps, rather than
63
+ # building+hosting our own (reviewer-ratified FLAG 4). Ship a datascience default when demand justifies.
64
+ _DEFAULT_IMAGE = "python:3.12-slim"
65
+
66
+ _WORKSPACE = "/workspace" # where the persistent workspace is mounted inside every box
67
+ _DEPS_DIR = ".deps" # pip --target dir inside the workspace (added to PYTHONPATH for run_code)
68
+ _ENV_FILE = ".kern-env" # host-side 0600 env file (kept out of argv so values don't show in `ps`)
69
+
70
+ # Host paths a `-v` mount must never target — mounting the host's real root/config/secrets into a
71
+ # sandbox defeats the point; the docker socket is the classic escape. A footgun guard: refused even
72
+ # when asked. Absolute, normalized host-SOURCE paths.
73
+ _REFUSED_MOUNT_SOURCES = {
74
+ "/",
75
+ "/etc",
76
+ "/root",
77
+ "/boot",
78
+ "/proc",
79
+ "/sys",
80
+ "/dev",
81
+ "/var/run/docker.sock",
82
+ "/run/docker.sock",
83
+ }
84
+
85
+
86
+ class SandboxError(RuntimeError):
87
+ """A PROGRAMMER/config error, RAISED: bad argument, illegal mount, or `kern` not installed.
88
+
89
+ Runtime sandbox events (timeout, blocked escape, OOM-kill) are NOT raised — they are reported as
90
+ data in ``ExecutionResult.fault`` (a :class:`SandboxFault`). Raising them would force every
91
+ ``run_code`` into a try/except for what is a normal, expected outcome of running untrusted code.
92
+ """
93
+
94
+
95
+ class MountRefused(SandboxError):
96
+ """A requested host mount was refused as unsafe (sensitive source, or a relative/escaping path)."""
97
+
98
+
99
+ @dataclass
100
+ class SandboxFault:
101
+ """A SANDBOX-level event that stopped the code — reported as DATA, never raised. ``None`` in a
102
+ result means the sandbox did nothing: any non-zero exit is the user's code, not a sandbox fault."""
103
+
104
+ type: Literal["timeout", "escape_blocked", "killed", "startup_failed"]
105
+ message: str
106
+
107
+
108
+ @dataclass
109
+ class FileInfo:
110
+ """A file in the workspace and how this step touched it."""
111
+
112
+ path: str # workspace-relative path
113
+ size: int
114
+ change: Literal["created", "modified"]
115
+
116
+
117
+ @dataclass
118
+ class ExecutionResult:
119
+ """The outcome of one ``run_code``/``run``. ``fault`` is the source of truth for "did the SANDBOX
120
+ act"; ``exit_code``/``stdout`` are what the user's code did. ``success`` requires both clean."""
121
+
122
+ stdout: str
123
+ stderr: str
124
+ exit_code: int
125
+ duration_ms: int
126
+ fault: SandboxFault | None = None
127
+ files: list[FileInfo] = field(default_factory=list)
128
+ truncated: bool = False # stdout/stderr hit the capture cap and overflow was discarded
129
+
130
+ @property
131
+ def success(self) -> bool:
132
+ """True iff the code exited 0 AND no sandbox fault fired."""
133
+ return self.exit_code == 0 and self.fault is None
134
+
135
+ def __bool__(self) -> bool:
136
+ return self.success
137
+
138
+
139
+ class _CappedReader(threading.Thread):
140
+ """Drain a pipe into a bounded buffer: keep at most ``cap`` bytes but KEEP reading past it
141
+ (discarding overflow) so a flooding box never blocks on a full pipe. RAM is bounded to ``cap``."""
142
+
143
+ def __init__(self, pipe, cap: int) -> None:
144
+ super().__init__(daemon=True)
145
+ self._pipe = pipe
146
+ self._cap = cap
147
+ self.buf = bytearray()
148
+ self.truncated = False
149
+
150
+ def run(self) -> None:
151
+ try:
152
+ while True:
153
+ chunk = self._pipe.read(65536)
154
+ if not chunk:
155
+ break
156
+ room = self._cap - len(self.buf)
157
+ if room > 0:
158
+ self.buf += chunk[:room]
159
+ if len(chunk) > room:
160
+ self.truncated = True
161
+ except (ValueError, OSError):
162
+ pass
163
+ finally:
164
+ try:
165
+ self._pipe.close()
166
+ except OSError:
167
+ pass
168
+
169
+
170
+ def _find_kern() -> str:
171
+ """Locate ``kern``: ``$KERN_BIN`` if set, else the first ``kern`` on ``$PATH``."""
172
+ env = os.environ.get("KERN_BIN")
173
+ if env:
174
+ if not (Path(env).is_file() and os.access(env, os.X_OK)):
175
+ raise SandboxError(f"$KERN_BIN='{env}' is not an executable file")
176
+ return env
177
+ found = shutil.which("kern")
178
+ if not found:
179
+ raise SandboxError(
180
+ "the `kern` binary was not found on PATH — install it "
181
+ "(https://github.com/getkern/kern) or set $KERN_BIN"
182
+ )
183
+ return found
184
+
185
+
186
+ def _validate_mount(source: str, target: str) -> tuple[str, str]:
187
+ """Validate one host->box mount; refuse unsafe sources/targets. Returns (abs_real_source, target)."""
188
+ if not target.startswith("/"):
189
+ raise MountRefused(f"mount target must be an absolute path in the box, got {target!r}")
190
+ if any(c == ".." for c in target.split("/")):
191
+ raise MountRefused(f"mount target must not contain '..': {target!r}")
192
+ norm_target = "/" + "/".join(c for c in target.split("/") if c and c != ".")
193
+ if norm_target in ("/", "/proc", "/sys", "/dev"):
194
+ raise MountRefused(f"cannot mount over the box essential mount {norm_target!r}")
195
+ src = Path(source)
196
+ if not src.is_absolute():
197
+ raise MountRefused(f"mount source must be an absolute host path, got {source!r}")
198
+ real = os.path.realpath(source) # resolve symlinks BEFORE the sensitive-set check
199
+ if real in _REFUSED_MOUNT_SOURCES or real == os.path.realpath(os.path.expanduser("~")):
200
+ raise MountRefused(
201
+ f"refusing to mount the sensitive host path {real!r} into a sandbox "
202
+ "(this would defeat the isolation)"
203
+ )
204
+ if not Path(real).exists():
205
+ raise MountRefused(f"mount source does not exist: {source!r}")
206
+ return real, target
207
+
208
+
209
+ # Signal-derived exit codes (128 + signum) we classify.
210
+ _EXIT_SIGKILL = 137 # 128 + 9 — SIGKILL: timeout backstop or OOM (indistinguishable without cgroup)
211
+ _EXIT_SIGSYS = 159 # 128 + 31 — SIGSYS: a seccomp-denied syscall = a blocked escape attempt
212
+ _EXIT_SIGTERM = 143 # 128 + 15 — SIGTERM: kern's --timeout backstop reaping the box (SIGTERM→SIGKILL)
213
+
214
+
215
+ @dataclass
216
+ class Sandbox:
217
+ """A configured kernel sandbox. FILE state persists across ``run_code``/``run`` in a workspace on
218
+ disk; each call runs in a FRESH ephemeral box. Safe by default; every relaxing arg says so.
219
+
220
+ Args:
221
+ image: OCI image the box runs from. Default: a small Python image.
222
+ setup: a shell command run ONCE at ``__enter__`` in a NETWORK-ENABLED setup box (e.g.
223
+ ``"pip install pandas"``). This is the ONLY moment the network is on; every ``run_code`` is
224
+ network-off. Deps installed to ``<workspace>/.deps`` and put on ``PYTHONPATH``.
225
+ workspace: host directory to use as the persistent workspace. ``None`` (default) → a temp dir
226
+ created on ``__enter__`` and DELETED on ``__exit__`` (session-ephemeral). A given path is
227
+ validated like a mount, is NOT deleted on exit, and its contents persist across sessions.
228
+ memory_mb: RAM cap (kern ``--memory``). Default 512.
229
+ cpus: CPU cap in cores; ``None`` = uncapped (kern ``--cpus``).
230
+ pids: task/fork-bomb ceiling (kern ``--pids-limit``). Default 256.
231
+ timeout_s: MANDATORY per-call wall-clock limit. The BINDING owns this deadline (it kills the
232
+ box), so a ``timeout`` fault is a known fact, never guessed. Default 30.
233
+ network: **RELAXES ISOLATION.** ``True`` shares the host network for every ``run_code`` (kern
234
+ ``--net``). Default ``False``. There is no per-call network override — network is a
235
+ session-level, explicit choice.
236
+ mounts: extra host paths to bind, ``{host_src: box_target}`` (or ``{src: (target, "ro")}``).
237
+ Sensitive sources are refused. The workspace is mounted automatically; this is for extras.
238
+ env: extra environment variables for the workload.
239
+ max_output_bytes: cap on captured stdout/stderr EACH; a flooding box can't OOM the host.
240
+ enforce_limits: ``True`` (default) hard-enforces caps via a systemd scope (~6 ms start);
241
+ ``False`` skips it for a ~3 ms start (best-effort caps).
242
+ """
243
+
244
+ image: str = _DEFAULT_IMAGE
245
+ setup: str | None = None
246
+ workspace: str | None = None
247
+ memory_mb: int | None = 512
248
+ cpus: float | None = None
249
+ pids: int | None = 256
250
+ timeout_s: int = 30
251
+ network: bool = False
252
+ mounts: Mapping[str, "str | tuple[str, str]"] | None = None
253
+ env: Mapping[str, str] | None = None
254
+ max_output_bytes: int = 64 * 1024 * 1024
255
+ enforce_limits: bool = True
256
+ deps_readonly: bool = False # mount setup= deps read-only for run_code (block cross-run poisoning)
257
+
258
+ _kern: str = field(default="", repr=False)
259
+ _mount_args: list = field(default_factory=list, init=False, repr=False)
260
+ _ws: str = field(default="", init=False, repr=False)
261
+ _own_ws: bool = field(default=False, init=False, repr=False) # we created it → we delete it
262
+ _entered: bool = field(default=False, init=False, repr=False)
263
+
264
+ def __post_init__(self) -> None:
265
+ if self.timeout_s is None or self.timeout_s <= 0:
266
+ raise SandboxError("timeout_s must be a positive number of seconds")
267
+ if self.max_output_bytes <= 0:
268
+ raise SandboxError("max_output_bytes must be positive")
269
+ self._mount_args = []
270
+ if self.mounts:
271
+ for source, spec in self.mounts.items():
272
+ if isinstance(spec, tuple):
273
+ target, mode = spec
274
+ if mode not in ("ro", "rw"):
275
+ raise MountRefused(f"mount mode must be 'ro' or 'rw', got {mode!r}")
276
+ ro = mode == "ro"
277
+ else:
278
+ target, ro = spec, False
279
+ real, tgt = _validate_mount(source, target)
280
+ self._mount_args += ["-v", f"{real}:{tgt}:ro" if ro else f"{real}:{tgt}"]
281
+ self._kern = _find_kern()
282
+
283
+ # -- lifecycle -----------------------------------------------------------------------------------
284
+
285
+ def __enter__(self) -> "Sandbox":
286
+ if self.workspace is None:
287
+ self._ws = os.path.realpath(tempfile.mkdtemp(prefix="kern-ws-"))
288
+ self._own_ws = True
289
+ else:
290
+ # A caller-supplied workspace is host input → validate it like a mount source, and DON'T
291
+ # delete it on exit (its contents persist across sessions — documented).
292
+ _validate_mount(self.workspace, _WORKSPACE)
293
+ self._ws = os.path.realpath(self.workspace)
294
+ Path(self._ws).mkdir(parents=True, exist_ok=True)
295
+ self._own_ws = False
296
+ self._entered = True
297
+ if self.setup:
298
+ self._run_setup(self.setup)
299
+ return self
300
+
301
+ def __exit__(self, *exc: object) -> None:
302
+ if self._own_ws and self._ws:
303
+ shutil.rmtree(self._ws, ignore_errors=True)
304
+ self._entered = False
305
+
306
+ def _require_entered(self) -> None:
307
+ if not self._entered:
308
+ raise SandboxError("use the Sandbox as a context manager: `with Sandbox() as s: ...`")
309
+
310
+ # -- the box invocation --------------------------------------------------------------------------
311
+
312
+ def _base_argv(self, name: str, *, network: bool, timeout_s: int, is_setup: bool = False) -> list[str]:
313
+ argv = [self._kern, "box", name, "--image", self.image, "--ro", "-v", f"{self._ws}:{_WORKSPACE}",
314
+ "--workdir", _WORKSPACE]
315
+ # deps_readonly: mount <workspace>/.deps read-only OVER the writable workspace for run_code boxes
316
+ # (not the setup box, which must populate it). Closes the cross-run dep-poisoning window within a
317
+ # session for tighter (still semi-trusted) workloads. Default off — deps writable, documented.
318
+ if self.deps_readonly and not is_setup:
319
+ deps = os.path.join(self._ws, _DEPS_DIR)
320
+ if os.path.isdir(deps):
321
+ argv += ["-v", f"{deps}:{_WORKSPACE}/{_DEPS_DIR}:ro"]
322
+ # kern's own --timeout is a tight BACKSTOP just beyond our deadline: it is the RELIABLE killer of
323
+ # the in-PID-namespace box (a CPU-bound box survives a SIGKILL of kern's parent process, but not
324
+ # kern's own timeout teardown). OUR proc.wait deadline is the authority that LABELS a `timeout`
325
+ # fault; kern's backstop guarantees the box is actually gone a few seconds later.
326
+ argv += ["--timeout", str(timeout_s + 5)]
327
+ if self.memory_mb is not None:
328
+ argv += ["--memory", f"{self.memory_mb}m"]
329
+ if self.cpus is not None:
330
+ argv += ["--cpus", str(self.cpus)]
331
+ if self.pids is not None:
332
+ argv += ["--pids-limit", str(self.pids)]
333
+ if network:
334
+ argv += ["--net"]
335
+ argv += self._mount_args
336
+ merged_env = dict(self.env or {})
337
+ # Deps installed by `setup` live in <workspace>/.deps — put them on PYTHONPATH for run_code.
338
+ merged_env.setdefault("PYTHONPATH", f"{_WORKSPACE}/{_DEPS_DIR}")
339
+ # Pass the workload env via a private --env-file, NOT `--env K=V` on argv: an argv value is
340
+ # visible in `ps` / /proc/<pid>/cmdline to any local user for the box's lifetime, and this
341
+ # component's whole point is running untrusted code beside sensitive data (a credential in
342
+ # `env=` would leak). The file lives in our own 0700 mkdtemp workspace, written 0600, so it is
343
+ # not readable by other users; kern reads it before the box's env is set up. (Hacker-mode audit.)
344
+ if merged_env:
345
+ env_path = os.path.join(self._ws, _ENV_FILE)
346
+ fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
347
+ try:
348
+ # K=V lines; values are single-line by construction (a NUL is rejected in _spawn, and a
349
+ # newline in a value would split the record — reject it here so it can't smuggle a var).
350
+ lines = []
351
+ for k, v in merged_env.items():
352
+ if "\n" in k or "\n" in v or "\0" in k or "\0" in v:
353
+ raise SandboxError(f"env var {k!r} must not contain a newline or NUL")
354
+ lines.append(f"{k}={v}\n")
355
+ os.write(fd, "".join(lines).encode())
356
+ finally:
357
+ os.close(fd)
358
+ argv += ["--env-file", env_path]
359
+ return argv
360
+
361
+ def _spawn(self, command: Sequence[str], *, network: bool, timeout_s: int, is_setup: bool = False) -> ExecutionResult:
362
+ for part in command:
363
+ if "\0" in part:
364
+ raise SandboxError("command/code must not contain a NUL byte")
365
+ before = self._snapshot()
366
+ name = _unique_name()
367
+ argv = self._base_argv(name, network=network, timeout_s=timeout_s, is_setup=is_setup) + ["--"] + list(command)
368
+ child_env = {**os.environ, "KERN_ACCEPT_EULA": "1"}
369
+ if not self.enforce_limits:
370
+ child_env["KERN_NO_SCOPE"] = "1"
371
+ started = time.monotonic()
372
+ try:
373
+ # start_new_session so the box + kern share a process group we can signal as a unit.
374
+ proc = subprocess.Popen( # noqa: S603 — argv list, no shell
375
+ argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=child_env,
376
+ start_new_session=True,
377
+ )
378
+ except FileNotFoundError as e:
379
+ raise SandboxError(f"could not execute kern: {e}") from e
380
+ except OSError as e:
381
+ # E2BIG (argv too long) and other spawn-time OS errors → a clean typed error, not a raw
382
+ # OSError leaking out of the binding. (run_code already routes large code via a file.)
383
+ raise SandboxError(f"could not spawn the box: {e}") from e
384
+ out = _CappedReader(proc.stdout, self.max_output_bytes)
385
+ err = _CappedReader(proc.stderr, self.max_output_bytes)
386
+ out.start()
387
+ err.start()
388
+ we_timed_out = False
389
+ try:
390
+ proc.wait(timeout=timeout_s) # OUR deadline — the authority for a `timeout` fault
391
+ except subprocess.TimeoutExpired:
392
+ we_timed_out = True
393
+ self._teardown(proc, name, child_env)
394
+ # Join readers, but BOUNDED: a CPU-bound box can survive our signals and hold the pipe open
395
+ # until kern's own --timeout backstop reaps it a few seconds later; never hang the caller on it.
396
+ join_deadline = 8.0 if we_timed_out else None
397
+ out.join(join_deadline)
398
+ err.join(join_deadline)
399
+ # Reap the process so returncode is populated and no zombie lingers (bounded — the backstop has
400
+ # reaped the box by now in the timeout case).
401
+ try:
402
+ proc.wait(timeout=8.0)
403
+ except subprocess.TimeoutExpired:
404
+ pass
405
+ wall_ms = int((time.monotonic() - started) * 1000)
406
+ stdout = out.buf.decode("utf-8", "replace")
407
+ stderr = err.buf.decode("utf-8", "replace")
408
+ rc = proc.returncode if proc.returncode is not None else -1
409
+ fault = self._classify(rc, stderr, we_timed_out)
410
+ files = self._diff(before)
411
+ return ExecutionResult(
412
+ stdout=stdout,
413
+ stderr=stderr,
414
+ exit_code=rc,
415
+ duration_ms=wall_ms,
416
+ fault=fault,
417
+ files=files,
418
+ truncated=out.truncated or err.truncated,
419
+ )
420
+
421
+ def _teardown(self, proc: "subprocess.Popen", name: str, child_env: dict) -> None:
422
+ """Best-effort tear down a timed-out box. Defense in depth, because a CPU-bound box in its own
423
+ PID namespace survives a plain SIGKILL of kern's parent process: (1) `kern stop` — the intended
424
+ teardown (cgroup-kill); (2) SIGKILL the whole process group; (3) SIGKILL the parent. kern's own
425
+ --timeout backstop guarantees the box is gone shortly regardless. We never block here."""
426
+
427
+ try:
428
+ subprocess.run(
429
+ [self._kern, "stop", name], env=child_env, capture_output=True, timeout=5
430
+ )
431
+ except (OSError, subprocess.SubprocessError):
432
+ pass
433
+ try:
434
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
435
+ except (OSError, ProcessLookupError):
436
+ pass
437
+ try:
438
+ proc.kill()
439
+ except OSError:
440
+ pass
441
+
442
+ def _classify(self, rc: int, stderr: str, we_timed_out: bool) -> SandboxFault | None:
443
+ # ORDER IS A SECURITY PROPERTY. The classes that are DETERMINISTIC by exit code are decided
444
+ # FIRST, BEFORE we ever look at stderr — because stderr is a channel the workload controls, and
445
+ # `startup_failed` is recognised by a pattern on it. If we checked the stderr marker first, a
446
+ # workload could print "error: sandbox:" and exit with SIGSYS and we'd mislabel a blocked escape
447
+ # as a mere startup failure — hiding a security event behind a benign one. So: our-deadline →
448
+ # SIGSYS → SIGKILL, all by exit code, THEN the stderr-marker heuristic as the LAST resort.
449
+ # (Same discipline as the tar vetter: never make a security decision by parsing an
450
+ # adversary-influenceable channel.)
451
+ if we_timed_out:
452
+ # OUR deadline fired and we killed the box — a known fact, never guessed.
453
+ return SandboxFault("timeout", f"exceeded the {self.timeout_s}s time limit (killed by the binding)")
454
+ if rc == _EXIT_SIGSYS:
455
+ # A seccomp-denied syscall. Decided by exit code, so no stderr content can mask it.
456
+ return SandboxFault("escape_blocked", "a syscall was blocked by the seccomp filter (SIGSYS)")
457
+ if rc == _EXIT_SIGKILL:
458
+ # SIGKILL not from our deadline: almost always the cgroup OOM-killer, but the binding can't
459
+ # read the box's memory.events (kern doesn't expose the cgroup path — verified), so we do
460
+ # NOT claim "oom" as the type. Honest: type=killed, message carries the likely cause.
461
+ return SandboxFault("killed", "the box was killed (SIGKILL) — likely out of memory (exit 137)")
462
+ if rc in (_EXIT_SIGTERM, -signal.SIGTERM):
463
+ # SIGTERM without our deadline firing = kern's OWN --timeout backstop reaped the box (it
464
+ # SIGTERMs, then SIGKILLs after a grace). The box exceeded its time limit; label it timeout,
465
+ # noting the backstop caught it rather than our own wait.
466
+ return SandboxFault("timeout", "the box exceeded its time limit (reaped by kern's timeout backstop)")
467
+ if rc == -signal.SIGKILL:
468
+ # Negative == killed by signal N (subprocess convention) — SIGKILL surfaced as -9.
469
+ return SandboxFault("killed", "the box was killed (SIGKILL) — likely out of memory")
470
+ # LAST resort, heuristic: a non-zero exit that is none of the deterministic classes above, whose
471
+ # stderr carries kern's OWN setup-diagnostic markers (printed by the parent before the box runs).
472
+ # Heuristic because stderr is workload-influenceable — but it can only ever mislabel an ordinary
473
+ # non-zero user exit as startup_failed, never mask an escape/timeout/kill (those were decided
474
+ # above by exit code). Documented as best-effort.
475
+ if rc != 0 and _looks_like_startup_failure(stderr):
476
+ return SandboxFault("startup_failed", stderr.strip()[:500])
477
+ # exit 139 (SIGSEGV) and any other non-zero exit are the USER's code failing — a normal Result.
478
+ return None
479
+
480
+ # -- workspace file I/O (host-direct; single-uid → box files are host-owned) ---------------------
481
+
482
+ def _ws_path(self, rel: str) -> str:
483
+ """Resolve a workspace-relative path for host-side I/O, refusing any escape out of the workspace.
484
+
485
+ Containment is checked on the requested path LEXICALLY (normalize `..`/`.`), NOT by resolving
486
+ symlinks in it — a symlink the box created can point at a box-absolute target like
487
+ `/workspace/x` that doesn't exist on the host, and `realpath`-ing it would both false-positive
488
+ (a legitimate INTERNAL symlink) and, worse, could be steered to follow a link out of the tree.
489
+ So: lexically contain the requested name here, then open the final component with O_NOFOLLOW
490
+ (in read/write) so a symlinked LAST component can't redirect the host I/O outside the workspace.
491
+ """
492
+ base = self._ws # canonical since enter — no per-walk re-resolution
493
+ # Lexical containment: join + normpath collapses `..`, then require it stays under base.
494
+ full = os.path.normpath(os.path.join(base, rel))
495
+ if full != base and not full.startswith(base + os.sep):
496
+ raise SandboxError(f"path escapes the workspace: {rel!r}")
497
+ return full
498
+
499
+ def write_file(self, path: str, data: bytes | str) -> None:
500
+ """Write ``data`` to ``path`` (workspace-relative) — host-direct, so the box sees it next run.
501
+ The final component is opened O_NOFOLLOW: a symlink the box planted there can't redirect the
502
+ write outside the workspace (it fails instead)."""
503
+ self._require_entered()
504
+ full = self._ws_path(path)
505
+ Path(full).parent.mkdir(parents=True, exist_ok=True)
506
+ payload = data.encode() if isinstance(data, str) else data
507
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
508
+ try:
509
+ fd = os.open(full, flags, 0o644)
510
+ except OSError as e:
511
+ raise SandboxError(f"cannot write {path!r}: {e}") from e
512
+ with os.fdopen(fd, "wb") as f:
513
+ f.write(payload)
514
+
515
+ def read_file(self, path: str) -> bytes:
516
+ """Read ``path`` (workspace-relative) from the workspace — host-direct. The final component is
517
+ opened O_NOFOLLOW: a symlink there can't redirect the read outside the workspace."""
518
+ self._require_entered()
519
+ full = self._ws_path(path)
520
+ flags = os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
521
+ try:
522
+ fd = os.open(full, flags)
523
+ except OSError as e:
524
+ raise SandboxError(f"cannot read {path!r}: {e}") from e
525
+ with os.fdopen(fd, "rb") as f:
526
+ return f.read()
527
+
528
+ def list_files(self, subdir: str = "") -> list[FileInfo]:
529
+ """List files under the workspace (excluding the ``.deps`` install dir)."""
530
+ self._require_entered()
531
+ root = self._ws_path(subdir) if subdir else self._ws # _ws is canonical (set at enter)
532
+ return [FileInfo(path=p, size=s, change="created") for p, (_, s) in self._walk(root).items()]
533
+
534
+ # -- setup (the only network window) -------------------------------------------------------------
535
+
536
+ def _run_setup(self, cmd: str) -> None:
537
+ # DECISION (reviewer-ratified C): the network is ON only here, in a SEPARATE setup box that
538
+ # dies at the end. It installs into <workspace>/.deps; every run_code box is network-off.
539
+ install = f"pip install --target {_WORKSPACE}/{_DEPS_DIR} --no-cache-dir --disable-pip-version-check"
540
+ # If the caller gave a bare `pip install X`, route it to the deps dir; else run as-is (net-on).
541
+ shell_cmd = cmd
542
+ if cmd.strip().startswith("pip install "):
543
+ shell_cmd = install + " " + cmd.strip()[len("pip install ") :]
544
+ r = self._spawn(["sh", "-c", shell_cmd], network=True, timeout_s=max(self.timeout_s, 120), is_setup=True)
545
+ if not r.success:
546
+ raise SandboxError(f"setup failed (exit {r.exit_code}): {(r.stderr or r.stdout).strip()[:400]}")
547
+
548
+ # -- files diff (created/modified; excludes .deps) -----------------------------------------------
549
+
550
+ def _snapshot(self) -> dict[str, tuple[int, int]]:
551
+ return self._walk(self._ws) # _ws is canonical (set at enter)
552
+
553
+ def _walk(self, root: str) -> dict[str, tuple[int, int]]:
554
+ """Map WORKSPACE-relative path -> (mtime_ns, size), skipping .deps, our env-file, and symlinks.
555
+ `root` is where to walk (the workspace, or a subdir for `list_files(subdir)`); paths are ALWAYS
556
+ made relative to the workspace root so `list_files("sub")` returns `sub/a.txt`, composable with
557
+ `read_file` (that was a regression when `root` doubled as the base). One lstat per file: S_ISREG
558
+ excludes non-regular files AND symlinks in a single syscall (a symlink's lstat mode is never
559
+ S_ISREG) — no extra isfile()/islink() stats."""
560
+ base = os.path.realpath(self._ws)
561
+ out: dict[str, tuple[int, int]] = {}
562
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
563
+ dirnames[:] = [d for d in dirnames if d != _DEPS_DIR] # exclude deps from the diff
564
+ for fn in filenames:
565
+ fp = os.path.join(dirpath, fn)
566
+ try:
567
+ st = os.lstat(fp)
568
+ except OSError:
569
+ continue
570
+ if not stat.S_ISREG(st.st_mode):
571
+ continue
572
+ rel = os.path.relpath(fp, base)
573
+ if rel == _ENV_FILE:
574
+ continue # our private host-side --env-file, not a user artifact
575
+ out[rel] = (st.st_mtime_ns, st.st_size)
576
+ return out
577
+
578
+ def _diff(self, before: dict[str, tuple[int, int]]) -> list[FileInfo]:
579
+ after = self._snapshot()
580
+ files: list[FileInfo] = []
581
+ for rel, (mtime, size) in after.items():
582
+ if rel not in before:
583
+ files.append(FileInfo(path=rel, size=size, change="created"))
584
+ elif before[rel] != (mtime, size):
585
+ files.append(FileInfo(path=rel, size=size, change="modified"))
586
+ return files
587
+
588
+ # -- the two ways to run code --------------------------------------------------------------------
589
+
590
+ # Above this size, pass code via a file in the workspace instead of `-c <code>` on the argv, so a
591
+ # large agent-generated script can't blow ARG_MAX (~2 MB) with a raw OSError. Well under the limit.
592
+ _INLINE_CODE_MAX = 128 * 1024
593
+
594
+ def run_code(self, code: str, *, language: Literal["python", "bash"] = "python") -> ExecutionResult:
595
+ """Run a snippet of ``code`` on the workspace in a fresh, network-off box. File state written to
596
+ the workspace persists to the next call; in-memory state does NOT (fresh process each time).
597
+ Large code is written to a workspace file and executed from there (transparent to the caller),
598
+ so an arbitrarily large script works instead of hitting the argv length limit."""
599
+ self._require_entered()
600
+ if language not in ("python", "bash"):
601
+ raise SandboxError(f"unsupported language {language!r} (v1: 'python' | 'bash')")
602
+ runner = "python3" if language == "python" else "sh"
603
+ if len(code.encode()) > self._INLINE_CODE_MAX:
604
+ # Write to a per-call cell file in the workspace and run it by path (no argv-size limit).
605
+ cell = f".cell-{uuid.uuid4().hex[:8]}.{'py' if language == 'python' else 'sh'}"
606
+ self.write_file(cell, code)
607
+ command: list[str] = [runner, f"{_WORKSPACE}/{cell}"]
608
+ else:
609
+ command = [runner, "-c", code]
610
+ return self._spawn(command, network=self.network, timeout_s=self.timeout_s)
611
+
612
+ def run(self, command: Sequence[str]) -> ExecutionResult:
613
+ """Run an arbitrary ``command`` (an argv LIST, never a shell string) in a fresh box."""
614
+ self._require_entered()
615
+ if isinstance(command, str):
616
+ raise SandboxError('run() takes an argv LIST, not a string. Use run(["sh","-c","..."]).')
617
+ if not command:
618
+ raise SandboxError("run() needs a non-empty command")
619
+ return self._spawn(command, network=self.network, timeout_s=self.timeout_s)
620
+
621
+
622
+ def _unique_name() -> str:
623
+ return "pysbx-" + uuid.uuid4().hex[:12]
624
+
625
+
626
+ def _looks_like_startup_failure(stderr: str) -> bool:
627
+ """True iff kern (the PARENT, before the box exists) failed to start the box. Anchored on kern's own
628
+ diagnostic prefixes — printed by kern, not by the workload — so the workload can't forge them by
629
+ writing the marker to its own stderr. (Same discipline as the tar vetter: don't trust text the
630
+ adversary controls; kern's setup errors precede any workload output and carry kern's prefixes.)"""
631
+ markers = ("kern:", "error: pull:", "error: sandbox:", "error: box:", "error: oci:", "error: image:")
632
+ for line in stderr.splitlines():
633
+ s = line.lstrip()
634
+ if "sandbox setup failed" in s or any(s.startswith(m) for m in markers):
635
+ return True
636
+ return False
637
+
638
+
639
+ def run_code(code: str, *, language: Literal["python", "bash"] = "python", **kwargs: object) -> ExecutionResult:
640
+ """One-shot convenience: run ``code`` in a throwaway session (workspace created and deleted). This is
641
+ literally ``with Sandbox(**kwargs) as s: return s.run_code(code)`` — one tested code path, no state
642
+ persists. For multi-step work (write a file, then read it), use ``Sandbox`` as a context manager."""
643
+ with Sandbox(**kwargs) as s: # type: ignore[arg-type]
644
+ return s.run_code(code, language=language)
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "kern-sandbox"
7
+ version = "0.1.0"
8
+ description = "Run code in a fast, local, daemonless kernel sandbox — no cloud, no account, no KVM."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "getkern" }]
13
+ keywords = ["sandbox", "isolation", "namespaces", "seccomp", "agent", "code-execution", "edge", "ci"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Security",
19
+ "Topic :: Software Development :: Testing",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/getkern/kern"
24
+ Source = "https://github.com/getkern/kern/tree/main/bindings/python"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["kern_sandbox"]
@@ -0,0 +1,297 @@
1
+ """Tests for kern_sandbox (v1 — the middle-way session model).
2
+
3
+ * UNIT tests (always run): fail-closed defaults, mount/workspace guards, taxonomy plumbing. No kern.
4
+ * INTEGRATION tests (skipped unless a runnable `kern` is present): the brief's acceptance criteria
5
+ against real ephemeral boxes on a persistent workspace.
6
+
7
+ Run: `pytest` (integration auto-skips without a real kern; set `KERN_BIN=/path/to/kern`).
8
+ """
9
+
10
+ import os
11
+ import shutil
12
+ import time
13
+
14
+ import pytest
15
+
16
+ import kern_sandbox as kern
17
+ from kern_sandbox import ExecutionResult, MountRefused, Sandbox, SandboxError
18
+
19
+ _FAKE_KERN = shutil.which("true") or "/bin/true"
20
+
21
+
22
+ def _cfg(**kw):
23
+ """Construct a Sandbox with a fake kern, restoring the real $KERN_BIN so integration tests still
24
+ see the real binary (the leak that once made every box shell out to /bin/true)."""
25
+ prev = os.environ.get("KERN_BIN")
26
+ os.environ["KERN_BIN"] = _FAKE_KERN
27
+ try:
28
+ return Sandbox(**kw)
29
+ finally:
30
+ if prev is None:
31
+ os.environ.pop("KERN_BIN", None)
32
+ else:
33
+ os.environ["KERN_BIN"] = prev
34
+
35
+
36
+ # ---------------------------------------------------------------------------
37
+ # UNIT
38
+ # ---------------------------------------------------------------------------
39
+
40
+
41
+ def test_defaults_are_fail_closed():
42
+ s = _cfg()
43
+ assert s.network is False and s.timeout_s > 0
44
+ assert s.memory_mb is not None and s.pids is not None
45
+ argv = s._base_argv("n", network=False, timeout_s=s.timeout_s)
46
+ assert "--net" not in argv and "--timeout" in argv and "--ro" in argv
47
+
48
+
49
+ def test_timeout_is_mandatory():
50
+ for bad in (None, 0, -5):
51
+ with pytest.raises(SandboxError):
52
+ _cfg(timeout_s=bad)
53
+
54
+
55
+ def test_network_is_opt_in_and_session_level():
56
+ assert "--net" not in _cfg()._base_argv("n", network=False, timeout_s=30)
57
+ assert "--net" in _cfg(network=True)._base_argv("n", network=True, timeout_s=30)
58
+
59
+
60
+ @pytest.mark.parametrize(
61
+ "mounts",
62
+ [
63
+ {"/": "/host"},
64
+ {"/etc": "/etc-host"},
65
+ {"/var/run/docker.sock": "/sock"},
66
+ {"/tmp": "/"},
67
+ {"/tmp": "/proc"},
68
+ {"/tmp": "/foo/../bar"},
69
+ {"relative/x": "/x"},
70
+ {"/definitely-not-here-xyz": "/x"},
71
+ ],
72
+ )
73
+ def test_dangerous_mounts_refused(mounts):
74
+ with pytest.raises(MountRefused):
75
+ _cfg(mounts=mounts)
76
+
77
+
78
+ def test_home_mount_refused():
79
+ with pytest.raises(MountRefused):
80
+ _cfg(mounts={os.path.expanduser("~"): "/home-x"})
81
+
82
+
83
+ def test_run_requires_context_manager():
84
+ with pytest.raises(SandboxError):
85
+ _cfg().run_code("print(1)") # not entered
86
+
87
+
88
+ def test_run_rejects_shell_string():
89
+ s = _cfg()
90
+ s._entered = True # bypass ctx for the pure-guard check
91
+ with pytest.raises(SandboxError):
92
+ s.run("echo hi")
93
+
94
+
95
+ def test_result_success_semantics():
96
+ from kern_sandbox import SandboxFault
97
+
98
+ assert ExecutionResult("", "", 0, 1).success is True
99
+ assert ExecutionResult("", "", 1, 1).success is False
100
+ assert ExecutionResult("", "", 0, 1, fault=SandboxFault("timeout", "x")).success is False
101
+
102
+
103
+ def test_classify_order_escape_not_masked_by_stderr_marker():
104
+ # SECURITY REGRESSION: a workload can print kern's setup marker ("error: sandbox:") to its own
105
+ # stderr and exit with SIGSYS. The classifier MUST still call it escape_blocked (decided by exit
106
+ # code), NOT startup_failed (the stderr-marker heuristic) — else a blocked escape hides behind a
107
+ # benign "startup failed" label. Deterministic classes are checked before the stderr marker.
108
+ s = _cfg()
109
+ forged = "error: sandbox: totally not a real kern setup error\n"
110
+ assert s._classify(159, forged, False).type == "escape_blocked" # SIGSYS wins over the marker
111
+ assert s._classify(137, forged, False).type == "killed" # SIGKILL wins over the marker
112
+ assert s._classify(1, forged, False).type == "startup_failed" # plain non-zero: marker heuristic
113
+ assert s._classify(1, "boom\n", False) is None # non-zero, no marker: user code, no fault
114
+
115
+
116
+ def test_classify_signal_exit_codes():
117
+ # Every signal-derived exit maps to the right fault (or None for user crashes).
118
+ s = _cfg()
119
+ assert s._classify(143, "", False).type == "timeout" # SIGTERM = kern backstop reap
120
+ assert s._classify(-15, "", False).type == "timeout"
121
+ assert s._classify(137, "", False).type == "killed" # SIGKILL = likely OOM
122
+ assert s._classify(-9, "", False).type == "killed"
123
+ assert s._classify(159, "", False).type == "escape_blocked" # SIGSYS
124
+ assert s._classify(139, "", False) is None # SIGSEGV = user code crash, not a fault
125
+ assert s._classify(1, "", False) is None # ordinary non-zero user exit
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # INTEGRATION — the brief's acceptance criteria
130
+ # ---------------------------------------------------------------------------
131
+
132
+
133
+ def _kern_runnable() -> bool:
134
+ k = os.environ.get("KERN_BIN") or shutil.which("kern")
135
+ return bool(k) and k != _FAKE_KERN and os.access(k, os.X_OK)
136
+
137
+
138
+ integration = pytest.mark.skipif(not _kern_runnable(), reason="no runnable kern (set KERN_BIN)")
139
+
140
+
141
+ @pytest.fixture(autouse=True)
142
+ def _eula():
143
+ os.environ.setdefault("KERN_ACCEPT_EULA", "1")
144
+
145
+
146
+ @integration
147
+ def test_c2_file_state_persists_between_steps():
148
+ with Sandbox(timeout_s=30) as s:
149
+ s.run_code("open('/workspace/x.txt','w').write('40')")
150
+ r = s.run_code("print(int(open('/workspace/x.txt').read()) + 2)")
151
+ assert r.stdout.strip() == "42" and r.success
152
+
153
+
154
+ @integration
155
+ def test_c3_write_file_then_read_csv():
156
+ with Sandbox(setup="pip install pandas", timeout_s=60) as s:
157
+ s.write_file("data.csv", "a,b\n1,2\n3,4\n")
158
+ r = s.run_code("import pandas as pd; print(pd.read_csv('/workspace/data.csv').shape)")
159
+ assert "(2, 2)" in r.stdout and r.success and r.fault is None
160
+
161
+
162
+ @integration
163
+ def test_c4_infinite_loop_times_out():
164
+ with Sandbox(timeout_s=4) as s:
165
+ t = time.monotonic()
166
+ r = s.run_code("while True: pass")
167
+ dt = time.monotonic() - t
168
+ assert r.fault is not None and r.fault.type == "timeout"
169
+ assert not r.success and dt < 16 # our deadline labels it; not the 21s backstop-only path
170
+
171
+
172
+ @integration
173
+ def test_c5a_write_outside_workspace_blocked_not_crash():
174
+ with Sandbox(timeout_s=20) as s:
175
+ r = s.run_code("open('/evil','w').write('x')")
176
+ # blocked in fact (read-only root), surfaced as the user's non-zero exit — NOT a sandbox crash,
177
+ # NOT a silent success. (Filesystem denial is indistinguishable from a normal PermissionError, so
178
+ # it is not labelled escape_blocked — that label is reserved for SIGSYS; see c5b.)
179
+ assert not r.success and "Read-only" in r.stderr
180
+
181
+
182
+ @integration
183
+ def test_c5b_blocked_syscall_is_escape_blocked():
184
+ with Sandbox(timeout_s=20) as s:
185
+ r = s.run_code("import ctypes; ctypes.CDLL(None).mount(0, 0, 0, 0, 0)")
186
+ assert r.fault is not None and r.fault.type == "escape_blocked"
187
+
188
+
189
+ @integration
190
+ def test_result_files_created_then_modified():
191
+ with Sandbox(timeout_s=20) as s:
192
+ r1 = s.run_code("open('/workspace/f.txt','w').write('aaa')")
193
+ r2 = s.run_code("open('/workspace/f.txt','w').write('bbbb')")
194
+ assert any(f.change == "created" for f in r1.files)
195
+ assert any(f.change == "modified" for f in r2.files)
196
+
197
+
198
+ @integration
199
+ def test_deps_excluded_from_files_diff():
200
+ # A pip-installed tree lives in .deps and must NOT flood result.files.
201
+ with Sandbox(setup="pip install beautifulsoup4", timeout_s=90) as s:
202
+ r = s.run_code("import bs4; print(bs4.__name__)")
203
+ assert r.success and all(".deps" not in f.path for f in r.files)
204
+
205
+
206
+ @integration
207
+ def test_deps_readonly_blocks_cross_run_poisoning():
208
+ # With deps_readonly, run_code cannot write into the setup= deps dir (RO submount).
209
+ with Sandbox(setup="pip install beautifulsoup4", deps_readonly=True, timeout_s=90) as s:
210
+ r = s.run_code("open('/workspace/.deps/poison.py', 'w').write('x')")
211
+ assert not r.success # write into .deps refused (read-only)
212
+
213
+
214
+ @integration
215
+ def test_read_write_are_symlink_and_traversal_safe():
216
+ # SECURITY: host-direct I/O must not follow a symlink the box planted (O_NOFOLLOW) nor a `..`
217
+ # traversal (lexical containment), while normal files and nested subdirs still work.
218
+ with Sandbox(timeout_s=20) as s:
219
+ s.write_file("real.txt", "dati")
220
+ assert s.read_file("real.txt") == b"dati"
221
+ s.write_file("a/b/c.txt", "nested")
222
+ assert s.read_file("a/b/c.txt") == b"nested"
223
+ s.run_code('import os; os.symlink("/etc/passwd", "/workspace/bad")')
224
+ with pytest.raises(SandboxError):
225
+ s.read_file("bad") # O_NOFOLLOW blocks a symlinked final component
226
+ with pytest.raises(SandboxError):
227
+ s.read_file("../../../etc/passwd") # lexical `..` containment
228
+
229
+
230
+ @integration
231
+ def test_box_cannot_read_host_file_by_absolute_path(tmp_path):
232
+ secret = tmp_path / "host-secret.txt"
233
+ secret.write_text("TOP-SECRET-HOST")
234
+ with Sandbox(timeout_s=20) as s:
235
+ r = s.run_code(f"print(open({str(secret)!r}).read())")
236
+ assert not r.success and "TOP-SECRET" not in r.stdout
237
+
238
+
239
+ @integration
240
+ def test_fork_bomb_contained_by_pids_limit():
241
+ with Sandbox(pids=32, timeout_s=20) as s:
242
+ r = s.run_code(
243
+ "import os\nn=0\nwhile n<10000:\n"
244
+ " try:\n pid=os.fork()\n (os._exit(0) if pid==0 else None); n+=1\n"
245
+ " except OSError:\n print('blocked', n); break"
246
+ )
247
+ assert "blocked" in r.stdout # pids.max stopped the fork bomb before the timeout
248
+
249
+
250
+ @integration
251
+ def test_large_code_runs_via_file_not_argv():
252
+ # A big generated script must not hit ARG_MAX — run_code routes >128 KiB via a workspace file.
253
+ code = "# " + "padding " * 20000 + "\nprint('big-ok')" # ~156 KiB, fast to execute
254
+ with Sandbox(timeout_s=20) as s:
255
+ r = s.run_code(code)
256
+ assert r.success and r.stdout.strip() == "big-ok"
257
+
258
+
259
+ @integration
260
+ def test_user_exception_is_not_a_fault():
261
+ with Sandbox(timeout_s=20) as s:
262
+ r = s.run_code("raise ValueError('boom')")
263
+ assert r.fault is None and r.exit_code != 0 and "ValueError" in r.stderr
264
+
265
+
266
+ @integration
267
+ def test_default_box_is_network_isolated():
268
+ with Sandbox(timeout_s=20) as s:
269
+ r = s.run_code(
270
+ "import socket; socket.setdefaulttimeout(4); "
271
+ "socket.socket().connect(('1.1.1.1', 53)); print('CONNECTED')"
272
+ )
273
+ assert not r.success and "CONNECTED" not in r.stdout
274
+
275
+
276
+ @integration
277
+ def test_host_secret_env_does_not_leak(monkeypatch):
278
+ monkeypatch.setenv("HOST_SECRET", "super-secret-token-xyz")
279
+ with Sandbox(timeout_s=20) as s:
280
+ r = s.run_code("import os; print(os.environ.get('HOST_SECRET', 'ABSENT'))")
281
+ assert "super-secret" not in r.stdout and "ABSENT" in r.stdout
282
+
283
+
284
+ @integration
285
+ def test_one_shot_run_code_helper():
286
+ r = kern.run_code("print(6 * 7)", timeout_s=20)
287
+ assert r.stdout.strip() == "42" and r.success
288
+
289
+
290
+ @integration
291
+ def test_workspace_is_deleted_on_exit_when_owned():
292
+ holder = {}
293
+ with Sandbox(timeout_s=20) as s:
294
+ holder["ws"] = s._ws
295
+ s.write_file("a.txt", "x")
296
+ assert os.path.exists(os.path.join(s._ws, "a.txt"))
297
+ assert not os.path.exists(holder["ws"]) # temp workspace cleaned up