postern 0.1.0__py3-none-any.whl
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.
- postern/__init__.py +36 -0
- postern/_guest.py +32 -0
- postern/_sandbox.py +348 -0
- postern/_seccomp.bpf +0 -0
- postern/_seccomp.py +181 -0
- postern/_seccomp.spec +4 -0
- postern/grpc.py +126 -0
- postern/py.typed +0 -0
- postern-0.1.0.dist-info/METADATA +183 -0
- postern-0.1.0.dist-info/RECORD +13 -0
- postern-0.1.0.dist-info/WHEEL +5 -0
- postern-0.1.0.dist-info/licenses/LICENSE +21 -0
- postern-0.1.0.dist-info/top_level.txt +1 -0
postern/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""postern — untrusted code in a sealed sandbox with one typed doorway.
|
|
2
|
+
|
|
3
|
+
Run untrusted Python in an OS-isolated `Sandbox` (bubblewrap: empty network
|
|
4
|
+
namespace, surgical filesystem, dropped capabilities, seccomp) whose *only*
|
|
5
|
+
interface to the outside is a hatch — host-provided gRPC methods, gated by an
|
|
6
|
+
allowlist, that the guest calls with the generated stub. The security boundary
|
|
7
|
+
is that method set, not a coarse permission flag.
|
|
8
|
+
|
|
9
|
+
from postern import Sandbox, SandboxProfile
|
|
10
|
+
from postern.grpc import GrpcHatch
|
|
11
|
+
import greeter_pb2_grpc
|
|
12
|
+
|
|
13
|
+
hatch = GrpcHatch(allowlist={'/greeter.Greeter/SayHello'})
|
|
14
|
+
hatch.add_servicer(greeter_pb2_grpc.add_GreeterServicer_to_server, MyGreeter())
|
|
15
|
+
|
|
16
|
+
profile = SandboxProfile.with_venv('/opt/analysis-env') # pandas, grpcio, stubs
|
|
17
|
+
Sandbox(profile, hatch=hatch).run_python(guest_code)
|
|
18
|
+
|
|
19
|
+
The bare `Sandbox` has no third-party dependencies and no cloud dependency — it
|
|
20
|
+
is a Linux + bubblewrap primitive. The gRPC hatch lives behind the ``grpc``
|
|
21
|
+
extra; provider/runtime adapters behind their own.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib.metadata
|
|
27
|
+
|
|
28
|
+
from postern._sandbox import IsolationError, ProcResult, Sandbox, SandboxProfile, available
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = importlib.metadata.version('postern')
|
|
32
|
+
except importlib.metadata.PackageNotFoundError:
|
|
33
|
+
# Running from a source tree without an install.
|
|
34
|
+
__version__ = '0.0.0+unknown'
|
|
35
|
+
|
|
36
|
+
__all__ = ['IsolationError', 'ProcResult', 'Sandbox', 'SandboxProfile', '__version__', 'available']
|
postern/_guest.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""In-sandbox entrypoint for `Sandbox.run_python`.
|
|
2
|
+
|
|
3
|
+
Runs *inside* the bubblewrap sandbox, so it is stdlib-only. It applies the
|
|
4
|
+
process-count limit (a fork-bomb backstop set here rather than via a fork-time
|
|
5
|
+
callback in the host) and then execs the guest code.
|
|
6
|
+
|
|
7
|
+
The guest reaches the hatch by dialing the bound Unix socket with an ordinary
|
|
8
|
+
gRPC channel + the generated stub — that machinery lives in the guest's own
|
|
9
|
+
environment, not here. The socket path is exported as ``POSTERN_HATCH``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import resource
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
nproc = int(os.environ.get('POSTERN_NPROC') or 0)
|
|
19
|
+
if nproc:
|
|
20
|
+
resource.setrlimit(resource.RLIMIT_NPROC, (nproc, nproc))
|
|
21
|
+
# Address-space backstop: a partial guard against a memory bomb starving the
|
|
22
|
+
# co-located trusted worker (F3). It is per-process, not a true total-memory
|
|
23
|
+
# bound — a cgroup memory.max set by the worker/deploy is the real fix.
|
|
24
|
+
as_bytes = int(os.environ.get('POSTERN_AS') or 0)
|
|
25
|
+
if as_bytes:
|
|
26
|
+
resource.setrlimit(resource.RLIMIT_AS, (as_bytes, as_bytes))
|
|
27
|
+
code = os.environ.get('POSTERN_CODE', '')
|
|
28
|
+
exec(code, {'__name__': '__main__'}) # noqa: S102 — executing guest code is the whole point
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == '__main__':
|
|
32
|
+
sys.exit(main())
|
postern/_sandbox.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""The hardened isolation core: a bubblewrap-launched sandbox.
|
|
2
|
+
|
|
3
|
+
`Sandbox` runs a program (or a snippet of Python) under bubblewrap with the
|
|
4
|
+
hardened profile: an empty network namespace (no egress at all), a surgical
|
|
5
|
+
read-only view of the base system directories plus one writable workspace,
|
|
6
|
+
`--cap-drop ALL`, `--new-session`, a seccomp denylist, and an `RLIMIT_NPROC`
|
|
7
|
+
fork-bomb backstop. The guest's only channel to the outside is whatever
|
|
8
|
+
`Hatch` the caller binds in — nothing else is reachable.
|
|
9
|
+
|
|
10
|
+
The base system directories come from the host by default, or from a curated
|
|
11
|
+
``rootfs`` directory (a minimal base assembled at image-build time) — the latter
|
|
12
|
+
hides the host's userland entirely. The Python environment the guest runs
|
|
13
|
+
against is a read-only bind (`SandboxProfile.with_venv`), never installed at
|
|
14
|
+
run time (there is no egress to install from).
|
|
15
|
+
|
|
16
|
+
Linux + bubblewrap + unprivileged user namespaces only. :func:`available`
|
|
17
|
+
reports whether the runtime can launch here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import dataclasses
|
|
24
|
+
import os
|
|
25
|
+
import pathlib
|
|
26
|
+
import shutil
|
|
27
|
+
import subprocess
|
|
28
|
+
import tempfile
|
|
29
|
+
import typing
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
|
|
32
|
+
from postern import _seccomp
|
|
33
|
+
|
|
34
|
+
if typing.TYPE_CHECKING:
|
|
35
|
+
# `typing.Self` is 3.11+, but postern supports 3.10; the backport is
|
|
36
|
+
# type-check-only (guarded here), so the runtime stays dependency-free.
|
|
37
|
+
from typing_extensions import Self
|
|
38
|
+
|
|
39
|
+
_GUEST_DIR = '/run/postern'
|
|
40
|
+
_GUEST_SOCK = f'{_GUEST_DIR}/hatch.sock'
|
|
41
|
+
_GUEST_SHIM = f'{_GUEST_DIR}/_guest.py'
|
|
42
|
+
_GUEST_STUBS = f'{_GUEST_DIR}/stubs'
|
|
43
|
+
_GUEST_WORKSPACE = '/workspace'
|
|
44
|
+
_SHIM_SRC = str(pathlib.Path(__file__).with_name('_guest.py'))
|
|
45
|
+
_SYSTEM_DIRS = ('/usr', '/lib', '/lib64', '/bin', '/sbin')
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Hatch(typing.Protocol):
|
|
49
|
+
"""What `Sandbox` needs of a hatch: a UDS path and a serving context."""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def socket_path(self) -> str: ...
|
|
53
|
+
|
|
54
|
+
def accepting(self) -> contextlib.AbstractContextManager[typing.Any]: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def available() -> bool:
|
|
58
|
+
"""Whether a sandbox can launch here (bubblewrap present on the PATH)."""
|
|
59
|
+
return shutil.which('bwrap') is not None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class IsolationError(RuntimeError):
|
|
63
|
+
"""A boot-time isolation self-test found a load-bearing control unenforced.
|
|
64
|
+
|
|
65
|
+
Raised by :meth:`Sandbox.verify`. It exists so a worker can *fail closed* at
|
|
66
|
+
startup — refuse to serve — rather than silently run untrusted code with
|
|
67
|
+
weaker isolation than intended (the F1/F5 silent-degradation risk).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclasses.dataclass
|
|
72
|
+
class ProcResult:
|
|
73
|
+
"""The outcome of one guest run."""
|
|
74
|
+
|
|
75
|
+
returncode: int
|
|
76
|
+
stdout: str
|
|
77
|
+
stderr: str
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def ok(self) -> bool:
|
|
81
|
+
return self.returncode == 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclasses.dataclass
|
|
85
|
+
class SandboxProfile:
|
|
86
|
+
"""The hardened bubblewrap profile. Defaults are the secure baseline.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
workspace: Host directory bound read-write at ``/workspace`` (the guest's
|
|
90
|
+
cwd), persisting across calls for the Sandbox's lifetime and readable
|
|
91
|
+
from the host (e.g. to checkpoint). ``None`` makes the Sandbox create
|
|
92
|
+
a private temp dir (removed on ``close()``); pass a path to own its
|
|
93
|
+
location and lifetime.
|
|
94
|
+
rootfs: A curated base directory whose ``/usr``, ``/lib`` … are bound as
|
|
95
|
+
the guest's system dirs. ``None`` binds the *host's* system dirs —
|
|
96
|
+
convenient for dev but exposes the host userland read-only; point at
|
|
97
|
+
a minimal rootfs (assembled at build time) to hide it.
|
|
98
|
+
python: Interpreter argv0 for :meth:`Sandbox.run_python` (an absolute
|
|
99
|
+
path when it lives in a bound venv).
|
|
100
|
+
ro_binds: Extra ``(host, guest)`` read-only binds beyond the base system
|
|
101
|
+
dirs — e.g. a venv (see :meth:`with_venv`).
|
|
102
|
+
stubs: Importable modules to inject at ``/run/postern/stubs`` (added to
|
|
103
|
+
the guest's ``PYTHONPATH``) — a directory, or a list of individual
|
|
104
|
+
files. Lets one shared rootfs carry the heavy base while per-agent
|
|
105
|
+
gRPC stubs are bound in selectively (kept in lockstep with the hatch
|
|
106
|
+
allowlist).
|
|
107
|
+
env: Environment for the guest (``--clearenv`` wipes everything first).
|
|
108
|
+
seccomp: Load the syscall denylist.
|
|
109
|
+
rlimit_nproc: Per-run process-count cap (fork-bomb backstop).
|
|
110
|
+
rlimit_as: Per-process address-space cap in bytes (memory-bomb backstop),
|
|
111
|
+
applied by the guest shim. ``None`` leaves it unlimited. This is a
|
|
112
|
+
*partial* guard — it bounds one process, not the guest's total
|
|
113
|
+
memory; a cgroup ``memory.max`` set by the worker/deploy is the real
|
|
114
|
+
isolation from the co-located trusted worker (F3). Leave it unset for
|
|
115
|
+
legitimately memory-hungry workloads and rely on the cgroup.
|
|
116
|
+
guest_uid: uid the guest runs as (``--uid``). Defaults to ``65534``
|
|
117
|
+
(nobody) so the guest is **non-root inside its user namespace** —
|
|
118
|
+
defusing a seccomp-gap namespace/cap re-acquisition (F2) and, when
|
|
119
|
+
run as root, dropping to a non-root real uid even if the user
|
|
120
|
+
namespace silently fails to materialise (F1's degraded case). The
|
|
121
|
+
guest's ``/workspace`` and ``/tmp`` are made writable to suit; a
|
|
122
|
+
caller-owned ``workspace`` dir is chmod'd world-writable at launch so
|
|
123
|
+
the non-root guest can use it. ``None`` keeps the legacy uid-0-in-
|
|
124
|
+
userns behaviour.
|
|
125
|
+
guest_gid: gid the guest runs as (``--gid``). Defaults to ``65534``.
|
|
126
|
+
``None`` leaves the gid unset.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
workspace: pathlib.Path | None = None
|
|
130
|
+
rootfs: pathlib.Path | None = None
|
|
131
|
+
python: str = 'python3'
|
|
132
|
+
ro_binds: list[tuple[str, str]] = dataclasses.field(default_factory=list)
|
|
133
|
+
stubs: str | os.PathLike[str] | Sequence[str | os.PathLike[str]] | None = None
|
|
134
|
+
env: dict[str, str] = dataclasses.field(default_factory=lambda: {'PATH': '/usr/local/bin:/usr/bin:/bin'})
|
|
135
|
+
seccomp: bool = True
|
|
136
|
+
rlimit_nproc: int = 1024
|
|
137
|
+
rlimit_as: int | None = None
|
|
138
|
+
guest_uid: int | None = 65534
|
|
139
|
+
guest_gid: int | None = 65534
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def with_venv(cls, venv: str | pathlib.Path, **kwargs: typing.Any) -> SandboxProfile: # noqa: ANN401
|
|
143
|
+
"""A profile that binds ``venv`` read-only and runs its interpreter.
|
|
144
|
+
|
|
145
|
+
The venv is bound at its own path so the interpreter's `pyvenv.cfg` /
|
|
146
|
+
`site.py` resolution finds its site-packages unchanged. Pass ``rootfs``
|
|
147
|
+
through ``kwargs`` to also hide the host userland.
|
|
148
|
+
"""
|
|
149
|
+
path = pathlib.Path(venv).resolve()
|
|
150
|
+
binds = [*kwargs.pop('ro_binds', []), (str(path), str(path))]
|
|
151
|
+
return cls(python=str(path / 'bin' / 'python'), ro_binds=binds, **kwargs)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def build_base_argv(profile: SandboxProfile, seccomp_fd: int | None) -> list[str]:
|
|
155
|
+
"""The bwrap flags for ``profile`` (excluding the trailing ``-- argv``)."""
|
|
156
|
+
root = str(profile.rootfs) if profile.rootfs is not None else ''
|
|
157
|
+
# --unshare-all leaves the user and cgroup namespaces *best-effort*
|
|
158
|
+
# (--unshare-user-try / --unshare-cgroup-try): if the kernel can't provide a
|
|
159
|
+
# user namespace, bwrap silently continues WITHOUT one and the guest runs as
|
|
160
|
+
# real root (F1's silent degradation). Re-list them strict so a missing
|
|
161
|
+
# namespace is a hard launch failure instead — bwrap's own docs say to use
|
|
162
|
+
# --unshare-user if you rely on it for security. --unshare-all still supplies
|
|
163
|
+
# the strict ipc/pid/net/uts (and any namespace it gains in future versions).
|
|
164
|
+
argv = ['bwrap', '--unshare-all', '--unshare-user', '--unshare-cgroup']
|
|
165
|
+
argv += ['--new-session', '--cap-drop', 'ALL', '--die-with-parent', '--clearenv']
|
|
166
|
+
# Run the guest as a non-root uid/gid (F2): inside the userns it then holds
|
|
167
|
+
# no capabilities to re-gain namespaces through a seccomp gap, and if the
|
|
168
|
+
# userns silently fails to materialise (F1) a root host still drops to a
|
|
169
|
+
# non-root real uid rather than running the guest as real root.
|
|
170
|
+
if profile.guest_uid is not None:
|
|
171
|
+
argv += ['--uid', str(profile.guest_uid)]
|
|
172
|
+
if profile.guest_gid is not None:
|
|
173
|
+
argv += ['--gid', str(profile.guest_gid)]
|
|
174
|
+
for d in _SYSTEM_DIRS:
|
|
175
|
+
# /usr is mandatory (plain --ro-bind); the rest are ``-try`` so a path
|
|
176
|
+
# absent on this base (e.g. /lib64) is skipped, not fatal.
|
|
177
|
+
flag = '--ro-bind' if d == '/usr' else '--ro-bind-try'
|
|
178
|
+
argv += [flag, root + d, d]
|
|
179
|
+
argv += ['--ro-bind-try', root + '/etc/ld.so.cache', '/etc/ld.so.cache']
|
|
180
|
+
for host, guest in profile.ro_binds:
|
|
181
|
+
argv += ['--ro-bind-try', host, guest]
|
|
182
|
+
# '/tmp' is the guest's in-sandbox mountpoint (a fresh tmpfs), not a host
|
|
183
|
+
# path; '--perms 1777' gives it the sticky world-writable mode a non-root
|
|
184
|
+
# guest needs (and that a real /tmp has anyway).
|
|
185
|
+
argv += ['--proc', '/proc', '--dev', '/dev', '--perms', '1777', '--tmpfs', '/tmp'] # noqa: S108
|
|
186
|
+
if profile.workspace is not None:
|
|
187
|
+
argv += ['--bind', str(profile.workspace), _GUEST_WORKSPACE]
|
|
188
|
+
else:
|
|
189
|
+
argv += ['--perms', '1777', '--tmpfs', _GUEST_WORKSPACE]
|
|
190
|
+
argv += ['--chdir', _GUEST_WORKSPACE]
|
|
191
|
+
env = dict(profile.env)
|
|
192
|
+
if profile.stubs is not None:
|
|
193
|
+
argv += _stub_binds(profile.stubs)
|
|
194
|
+
prior = env.get('PYTHONPATH')
|
|
195
|
+
env['PYTHONPATH'] = _GUEST_STUBS if not prior else f'{_GUEST_STUBS}:{prior}'
|
|
196
|
+
for key, val in env.items():
|
|
197
|
+
argv += ['--setenv', key, val]
|
|
198
|
+
if seccomp_fd is not None:
|
|
199
|
+
argv += ['--seccomp', str(seccomp_fd)]
|
|
200
|
+
return argv
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _stub_binds(stubs: str | os.PathLike[str] | Sequence[str | os.PathLike[str]]) -> list[str]:
|
|
204
|
+
"""Bwrap flags injecting importable stubs at ``/run/postern/stubs``.
|
|
205
|
+
|
|
206
|
+
A directory is bound whole; a sequence of files is bound each to its
|
|
207
|
+
basename under the stubs dir (so a common rootfs can carry the base while
|
|
208
|
+
the per-service stubs are injected selectively).
|
|
209
|
+
"""
|
|
210
|
+
if isinstance(stubs, (str, os.PathLike)):
|
|
211
|
+
return ['--ro-bind', os.fspath(stubs), _GUEST_STUBS]
|
|
212
|
+
binds: list[str] = []
|
|
213
|
+
for entry in stubs:
|
|
214
|
+
path = os.fspath(entry)
|
|
215
|
+
binds += ['--ro-bind', path, f'{_GUEST_STUBS}/{pathlib.Path(path).name}']
|
|
216
|
+
return binds
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Sandbox:
|
|
220
|
+
"""A hardened bubblewrap sandbox with an optional typed :class:`Hatch`."""
|
|
221
|
+
|
|
222
|
+
def __init__(self, profile: SandboxProfile | None = None, *, hatch: Hatch | None = None) -> None:
|
|
223
|
+
self._profile = profile or SandboxProfile()
|
|
224
|
+
self._hatch = hatch
|
|
225
|
+
# The workspace persists for this Sandbox's lifetime and is bound
|
|
226
|
+
# read-write at /workspace (the guest's cwd). An explicit profile path is
|
|
227
|
+
# caller-owned; otherwise a private temp dir is created here and removed
|
|
228
|
+
# on close(). Either way the host can read it between calls (e.g. to
|
|
229
|
+
# checkpoint) via the ``workspace`` property.
|
|
230
|
+
if self._profile.workspace is not None:
|
|
231
|
+
self._workspace = pathlib.Path(self._profile.workspace)
|
|
232
|
+
self._own_workspace = False
|
|
233
|
+
self._workspace.mkdir(parents=True, exist_ok=True)
|
|
234
|
+
else:
|
|
235
|
+
self._workspace = pathlib.Path(tempfile.mkdtemp(prefix='postern-ws-'))
|
|
236
|
+
self._own_workspace = True
|
|
237
|
+
|
|
238
|
+
@property
|
|
239
|
+
def workspace(self) -> pathlib.Path:
|
|
240
|
+
"""The host directory bound read-write at ``/workspace`` (the guest cwd)."""
|
|
241
|
+
return self._workspace
|
|
242
|
+
|
|
243
|
+
def _launch(
|
|
244
|
+
self,
|
|
245
|
+
argv: list[str],
|
|
246
|
+
*,
|
|
247
|
+
timeout: float,
|
|
248
|
+
setenv: dict[str, str] | None = None,
|
|
249
|
+
extra_binds: list[str] | None = None,
|
|
250
|
+
) -> ProcResult:
|
|
251
|
+
if not available():
|
|
252
|
+
raise RuntimeError('bubblewrap (bwrap) not found on PATH; postern requires Linux + bubblewrap')
|
|
253
|
+
# A non-root guest cannot write a workspace dir owned by (and mode-locked
|
|
254
|
+
# to) the host user, so open it up. The dir is private to this single-
|
|
255
|
+
# tenant sandbox, so world-writable is immaterial (see F9).
|
|
256
|
+
if self._profile.guest_uid not in (None, 0):
|
|
257
|
+
with contextlib.suppress(OSError):
|
|
258
|
+
self._workspace.chmod(0o777)
|
|
259
|
+
seccomp = _seccomp.load_filter() if self._profile.seccomp else None
|
|
260
|
+
fd = seccomp.fileno() if seccomp is not None else None
|
|
261
|
+
try:
|
|
262
|
+
cmd = build_base_argv(dataclasses.replace(self._profile, workspace=self._workspace), fd)
|
|
263
|
+
for key, val in (setenv or {}).items():
|
|
264
|
+
cmd += ['--setenv', key, val]
|
|
265
|
+
cmd += extra_binds or []
|
|
266
|
+
cmd += ['--', *argv]
|
|
267
|
+
proc = subprocess.Popen(
|
|
268
|
+
cmd,
|
|
269
|
+
stdin=subprocess.DEVNULL,
|
|
270
|
+
stdout=subprocess.PIPE,
|
|
271
|
+
stderr=subprocess.PIPE,
|
|
272
|
+
text=True,
|
|
273
|
+
pass_fds=(fd,) if fd is not None else (),
|
|
274
|
+
)
|
|
275
|
+
try:
|
|
276
|
+
out, err = proc.communicate(timeout=timeout)
|
|
277
|
+
except subprocess.TimeoutExpired:
|
|
278
|
+
proc.kill()
|
|
279
|
+
out, err = proc.communicate()
|
|
280
|
+
return ProcResult(124, out or '', (err or '') + '\n[postern] timed out')
|
|
281
|
+
return ProcResult(proc.returncode, out or '', err or '')
|
|
282
|
+
finally:
|
|
283
|
+
if seccomp is not None:
|
|
284
|
+
seccomp.close()
|
|
285
|
+
|
|
286
|
+
def run(self, argv: list[str], *, timeout: float = 60) -> ProcResult:
|
|
287
|
+
"""Run ``argv`` inside the sandbox and return its result.
|
|
288
|
+
|
|
289
|
+
The raw primitive: it does not serve the hatch or set ``RLIMIT_NPROC``
|
|
290
|
+
(those are :meth:`run_python`'s job). Use it for a non-Python entrypoint
|
|
291
|
+
that manages its own limits.
|
|
292
|
+
"""
|
|
293
|
+
return self._launch(list(argv), timeout=timeout)
|
|
294
|
+
|
|
295
|
+
def run_python(self, code: str, *, timeout: float = 60) -> ProcResult:
|
|
296
|
+
"""Run untrusted Python ``code`` inside the sandbox.
|
|
297
|
+
|
|
298
|
+
With a :class:`Hatch`, the hatch UDS is bound in and its path exported as
|
|
299
|
+
``POSTERN_HATCH``; the guest reaches the host's allowlisted gRPC methods
|
|
300
|
+
by dialing ``unix:$POSTERN_HATCH`` with the generated stub (grpcio and
|
|
301
|
+
the stubs come from the bound environment). The guest shim applies
|
|
302
|
+
``RLIMIT_NPROC`` before running the code.
|
|
303
|
+
"""
|
|
304
|
+
binds = ['--ro-bind', _SHIM_SRC, _GUEST_SHIM]
|
|
305
|
+
env = {
|
|
306
|
+
'POSTERN_CODE': code,
|
|
307
|
+
'POSTERN_NPROC': str(self._profile.rlimit_nproc),
|
|
308
|
+
'POSTERN_AS': str(self._profile.rlimit_as or 0),
|
|
309
|
+
'POSTERN_HATCH': '',
|
|
310
|
+
}
|
|
311
|
+
argv = [self._profile.python, '-u', _GUEST_SHIM]
|
|
312
|
+
if self._hatch is None:
|
|
313
|
+
return self._launch(argv, timeout=timeout, setenv=env, extra_binds=binds)
|
|
314
|
+
binds += ['--bind', self._hatch.socket_path, _GUEST_SOCK]
|
|
315
|
+
env['POSTERN_HATCH'] = _GUEST_SOCK
|
|
316
|
+
with self._hatch.accepting():
|
|
317
|
+
return self._launch(argv, timeout=timeout, setenv=env, extra_binds=binds)
|
|
318
|
+
|
|
319
|
+
def verify(self, *, timeout: float = 30) -> None:
|
|
320
|
+
"""Fail fast at startup unless the sandbox actually launches here.
|
|
321
|
+
|
|
322
|
+
A boot-time gate: call once against the profile you will serve with, and
|
|
323
|
+
refuse to run untrusted code if it raises. Every control is already
|
|
324
|
+
fail-closed on the launch path — the strict ``--unshare-{user,net,…}``
|
|
325
|
+
flags make bwrap abort if it cannot create the namespaces, apply
|
|
326
|
+
``--uid`` or drop capabilities (F1/F2/F5), and :func:`_seccomp.load_filter`
|
|
327
|
+
refuses an architecture the filter doesn't cover (F4). So there is nothing
|
|
328
|
+
to *probe* for at runtime (a successful launch is the proof, as in
|
|
329
|
+
Chrome's sandbox): this just triggers one trivial launch so a broken
|
|
330
|
+
platform — no user namespace, gVisor, an uncovered arch — surfaces as an
|
|
331
|
+
:class:`IsolationError` at startup rather than on the first real request.
|
|
332
|
+
"""
|
|
333
|
+
if not self._profile.seccomp:
|
|
334
|
+
raise IsolationError('seccomp is disabled; refusing to treat this as a hardened sandbox')
|
|
335
|
+
result = self.run_python('pass', timeout=timeout)
|
|
336
|
+
if not result.ok:
|
|
337
|
+
raise IsolationError(f'sandbox failed to launch: {result.stderr.strip() or result.returncode}')
|
|
338
|
+
|
|
339
|
+
def close(self) -> None:
|
|
340
|
+
"""Remove the workspace if this Sandbox created it (a no-op for a caller-owned path)."""
|
|
341
|
+
if self._own_workspace:
|
|
342
|
+
shutil.rmtree(self._workspace, ignore_errors=True)
|
|
343
|
+
|
|
344
|
+
def __enter__(self) -> Self:
|
|
345
|
+
return self
|
|
346
|
+
|
|
347
|
+
def __exit__(self, *_exc: object) -> None:
|
|
348
|
+
self.close()
|
postern/_seccomp.bpf
ADDED
|
Binary file
|
postern/_seccomp.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""The seccomp-BPF denylist: a maintained, multi-arch backstop.
|
|
2
|
+
|
|
3
|
+
Defense in depth on top of the empty network namespace and dropped capabilities:
|
|
4
|
+
block the syscalls that would let guest code re-gain namespaces, mount, trace,
|
|
5
|
+
load code into the kernel, or fake terminal input. It is a denylist (default
|
|
6
|
+
allow) — a backstop, not the primary boundary.
|
|
7
|
+
|
|
8
|
+
The filter is compiled **ahead of time** by ``tools/gen_seccomp.py`` (which uses
|
|
9
|
+
libseccomp) and committed as ``_seccomp.bpf`` next to this module. The runtime
|
|
10
|
+
only *loads* that blob and hands its fd to ``bwrap --seccomp`` — so installing or
|
|
11
|
+
running postern needs no libseccomp, keeping the core dependency-free. The blob
|
|
12
|
+
is a single multi-arch program (x86_64, x86, x32, aarch64, arm); on any other
|
|
13
|
+
architecture its default-allow would make it a silent no-op, so :func:`load_filter`
|
|
14
|
+
**refuses to load it** there (fail closed) rather than run with an unenforced
|
|
15
|
+
filter — that arch check is the only thing that isn't self-evident from the
|
|
16
|
+
kernel accepting the filter, so there is no runtime probe.
|
|
17
|
+
|
|
18
|
+
The syscall lists below are the source of truth the generator consumes; they are
|
|
19
|
+
derived from Flatpak's seccomp policy (``common/flatpak-run.c``). Editing them
|
|
20
|
+
requires regenerating the blob — see ``tools/gen_seccomp.sh``.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import hashlib
|
|
26
|
+
import importlib.resources
|
|
27
|
+
import json
|
|
28
|
+
import platform
|
|
29
|
+
import tempfile
|
|
30
|
+
import typing
|
|
31
|
+
|
|
32
|
+
_BPF_RESOURCE = '_seccomp.bpf'
|
|
33
|
+
_SPEC_RESOURCE = '_seccomp.spec'
|
|
34
|
+
|
|
35
|
+
# The machine architectures the committed blob actually carries a program for
|
|
36
|
+
# (the ``uname -m`` names for ``tools/gen_seccomp._ARCHES``). On anything else
|
|
37
|
+
# the filter's default-allow makes it a silent no-op, so ``load_filter`` refuses
|
|
38
|
+
# to load it there rather than run with a filter that enforces nothing.
|
|
39
|
+
COVERED_ARCHES: frozenset[str] = frozenset(
|
|
40
|
+
{'x86_64', 'amd64', 'i386', 'i486', 'i586', 'i686', 'aarch64', 'arm64', 'armv6l', 'armv7l', 'armv8l', 'arm'}
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def arch_is_covered(machine: str | None = None) -> bool:
|
|
45
|
+
"""Whether the committed filter carries a program for ``machine``.
|
|
46
|
+
|
|
47
|
+
Defaults to the running host's ``platform.machine()``. False means the blob
|
|
48
|
+
would load but enforce nothing here (default-allow no-op).
|
|
49
|
+
"""
|
|
50
|
+
return (machine or platform.machine()).lower() in COVERED_ARCHES
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# Blocked with EPERM: escape-enabling or dangerous syscalls the guest never
|
|
54
|
+
# legitimately needs. (Flatpak's main blocklist plus its non-devel additions —
|
|
55
|
+
# ptrace and perf_event_open.)
|
|
56
|
+
BLOCKED_EPERM: tuple[str, ...] = (
|
|
57
|
+
# Re-gaining namespaces / changing the mount or root view (bwrap already set
|
|
58
|
+
# ours up before applying this filter).
|
|
59
|
+
'unshare',
|
|
60
|
+
'setns',
|
|
61
|
+
'mount',
|
|
62
|
+
'umount2',
|
|
63
|
+
'pivot_root',
|
|
64
|
+
'chroot',
|
|
65
|
+
# Kernel keyring.
|
|
66
|
+
'add_key',
|
|
67
|
+
'keyctl',
|
|
68
|
+
'request_key',
|
|
69
|
+
# Tracing / profiling other processes.
|
|
70
|
+
'ptrace',
|
|
71
|
+
'perf_event_open',
|
|
72
|
+
# Scary VM / NUMA memory ops.
|
|
73
|
+
'move_pages',
|
|
74
|
+
'mbind',
|
|
75
|
+
'get_mempolicy',
|
|
76
|
+
'set_mempolicy',
|
|
77
|
+
'migrate_pages',
|
|
78
|
+
# Misc: read the kernel log, load a shared lib by inode, toggle accounting,
|
|
79
|
+
# manipulate quotas.
|
|
80
|
+
'syslog',
|
|
81
|
+
'uselib',
|
|
82
|
+
'acct',
|
|
83
|
+
'quotactl',
|
|
84
|
+
# Kernel modules, eBPF, kexec, reboot, swap. Redundant with --cap-drop ALL
|
|
85
|
+
# (each needs a capability the guest lacks) but kept as cheap defense in
|
|
86
|
+
# depth — postern blocked these before adopting Flatpak's list.
|
|
87
|
+
'bpf',
|
|
88
|
+
'init_module',
|
|
89
|
+
'finit_module',
|
|
90
|
+
'delete_module',
|
|
91
|
+
'kexec_load',
|
|
92
|
+
'kexec_file_load',
|
|
93
|
+
'reboot',
|
|
94
|
+
'swapon',
|
|
95
|
+
'swapoff',
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Blocked with ENOSYS (not EPERM): clone3 and the new mount API. seccomp cannot
|
|
99
|
+
# inspect clone3's argument struct, so it is refused wholesale; returning ENOSYS
|
|
100
|
+
# (rather than EPERM) lets glibc fall back to the classic clone/mount paths
|
|
101
|
+
# instead of treating the call as a hard failure.
|
|
102
|
+
BLOCKED_ENOSYS: tuple[str, ...] = (
|
|
103
|
+
'clone3',
|
|
104
|
+
'open_tree',
|
|
105
|
+
'move_mount',
|
|
106
|
+
'fsopen',
|
|
107
|
+
'fsconfig',
|
|
108
|
+
'fsmount',
|
|
109
|
+
'fspick',
|
|
110
|
+
'mount_setattr',
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Argument-filtered rules (generator applies these). clone's flags are arg0 on
|
|
114
|
+
# every architecture postern targets; ioctl's request is arg1.
|
|
115
|
+
CLONE_NEWUSER = 0x10000000 # block clone(CLONE_NEWUSER, ...) — the gap unshare/setns alone leave open
|
|
116
|
+
TIOCSTI = 0x5412 # fake terminal input (CVE-2017-5226)
|
|
117
|
+
TIOCLINUX = 0x541C # ditto via the linux console ioctl
|
|
118
|
+
|
|
119
|
+
# The libseccomp Arch names the generator compiles into the blob. Kept here as
|
|
120
|
+
# the source of truth (not in the generator) so the drift digest below covers
|
|
121
|
+
# them without this module importing libseccomp.
|
|
122
|
+
GEN_ARCHES: tuple[str, ...] = ('X86_64', 'X86', 'X32', 'AARCH64', 'ARM')
|
|
123
|
+
|
|
124
|
+
# Deliberately NOT blocked: socket / socketpair. Network isolation is the empty
|
|
125
|
+
# netns's job (no interface, no route); the guest needs socket(AF_UNIX) to reach
|
|
126
|
+
# the hatch UDS, so blocking it breaks the hatch while adding nothing.
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def spec_digest() -> str:
|
|
130
|
+
"""A stable digest of the syscall spec the committed blob was built from.
|
|
131
|
+
|
|
132
|
+
Hashes the source-of-truth rule lists and the generator's arch set, so a
|
|
133
|
+
change to them that was *not* followed by regenerating ``_seccomp.bpf`` is
|
|
134
|
+
detectable with no libseccomp dependency — the generator records this digest
|
|
135
|
+
(and the blob's own hash) in ``_seccomp.spec``, and a test compares. It does
|
|
136
|
+
not prove the blob is what libseccomp would emit today (only the CI
|
|
137
|
+
regenerate-and-diff does); it catches the common drift of editing a list and
|
|
138
|
+
forgetting to regenerate.
|
|
139
|
+
"""
|
|
140
|
+
payload = json.dumps(
|
|
141
|
+
{
|
|
142
|
+
'blocked_eperm': list(BLOCKED_EPERM),
|
|
143
|
+
'blocked_enosys': list(BLOCKED_ENOSYS),
|
|
144
|
+
'clone_newuser': CLONE_NEWUSER,
|
|
145
|
+
'tiocsti': TIOCSTI,
|
|
146
|
+
'tioclinux': TIOCLINUX,
|
|
147
|
+
'gen_arches': list(GEN_ARCHES),
|
|
148
|
+
},
|
|
149
|
+
sort_keys=True,
|
|
150
|
+
).encode()
|
|
151
|
+
return hashlib.sha256(payload).hexdigest()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def manifest() -> dict[str, str]:
|
|
155
|
+
"""Build the ``_seccomp.spec`` manifest for the currently committed blob."""
|
|
156
|
+
blob = importlib.resources.files('postern').joinpath(_BPF_RESOURCE).read_bytes()
|
|
157
|
+
return {'source_digest': spec_digest(), 'bpf_sha256': hashlib.sha256(blob).hexdigest()}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_filter() -> typing.IO[bytes]:
|
|
161
|
+
"""Load the prebuilt BPF denylist into an open temp file positioned at 0.
|
|
162
|
+
|
|
163
|
+
The caller passes its fd to ``bwrap --seccomp`` and keeps it open for the
|
|
164
|
+
child's lifetime, then closes it. Fails closed rather than run with an
|
|
165
|
+
unenforced filter: raises on an architecture the blob doesn't cover (where it
|
|
166
|
+
would be a default-allow no-op), or if the blob is missing from the install
|
|
167
|
+
(a packaging error).
|
|
168
|
+
"""
|
|
169
|
+
if not arch_is_covered():
|
|
170
|
+
raise RuntimeError(
|
|
171
|
+
f'seccomp filter has no coverage for this architecture ({platform.machine()!r}); refusing to run '
|
|
172
|
+
'untrusted code with an unenforced filter (set SandboxProfile(seccomp=False) to override deliberately)'
|
|
173
|
+
)
|
|
174
|
+
data = importlib.resources.files('postern').joinpath(_BPF_RESOURCE).read_bytes()
|
|
175
|
+
if not data:
|
|
176
|
+
raise RuntimeError(f'seccomp filter {_BPF_RESOURCE!r} is missing or empty; the postern install is broken')
|
|
177
|
+
f = tempfile.TemporaryFile() # noqa: SIM115 — returned open; caller passes its fd to bwrap and closes it
|
|
178
|
+
f.write(data)
|
|
179
|
+
f.flush()
|
|
180
|
+
f.seek(0)
|
|
181
|
+
return f
|
postern/_seccomp.spec
ADDED
postern/grpc.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The gRPC escape hatch: the sandbox's typed doorway to the outside.
|
|
2
|
+
|
|
3
|
+
A `GrpcHatch` serves host-provided `grpc` servicers over the sandbox's Unix
|
|
4
|
+
domain socket, gated by a **method allowlist** — only the exact
|
|
5
|
+
``/package.Service/Method`` names you list are reachable; anything else is
|
|
6
|
+
`PERMISSION_DENIED`. The servicer runs in the trusted host process; the guest
|
|
7
|
+
calls it with the generated stub over ``unix:$POSTERN_HATCH``. The proto is the
|
|
8
|
+
typed contract, so arguments and results are typed and language-neutral, and the
|
|
9
|
+
allowlist is the capability grant — the security boundary is that method set.
|
|
10
|
+
|
|
11
|
+
Requires the ``grpc`` extra (``pip install 'postern[grpc]'``). ``import
|
|
12
|
+
postern.grpc`` only where you use it; the bare `Sandbox` stays dependency-free.
|
|
13
|
+
|
|
14
|
+
from postern import Sandbox, SandboxProfile
|
|
15
|
+
from postern.grpc import GrpcHatch
|
|
16
|
+
import greeter_pb2_grpc
|
|
17
|
+
|
|
18
|
+
hatch = GrpcHatch(allowlist={'/greeter.Greeter/SayHello'})
|
|
19
|
+
hatch.add_servicer(greeter_pb2_grpc.add_GreeterServicer_to_server, MyGreeter())
|
|
20
|
+
sandbox = Sandbox(SandboxProfile.with_venv('/opt/env'), hatch=hatch)
|
|
21
|
+
sandbox.run_python(guest_code) # guest dials unix:$POSTERN_HATCH with the stub
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import os
|
|
28
|
+
import tempfile
|
|
29
|
+
import typing
|
|
30
|
+
from collections.abc import Callable, Generator
|
|
31
|
+
from concurrent import futures
|
|
32
|
+
|
|
33
|
+
import grpc
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Allowlist(grpc.ServerInterceptor):
|
|
37
|
+
"""Reject any method whose full name is not in the allowlist."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, allowed: typing.Iterable[str]) -> None:
|
|
40
|
+
self._allowed = frozenset(allowed)
|
|
41
|
+
|
|
42
|
+
def intercept_service(
|
|
43
|
+
self,
|
|
44
|
+
continuation: Callable[[grpc.HandlerCallDetails], grpc.RpcMethodHandler | None],
|
|
45
|
+
handler_call_details: grpc.HandlerCallDetails,
|
|
46
|
+
) -> grpc.RpcMethodHandler | None:
|
|
47
|
+
method = getattr(handler_call_details, 'method', None)
|
|
48
|
+
if method in self._allowed:
|
|
49
|
+
return continuation(handler_call_details)
|
|
50
|
+
|
|
51
|
+
def deny(_request: object, context: grpc.ServicerContext) -> typing.NoReturn:
|
|
52
|
+
context.abort(grpc.StatusCode.PERMISSION_DENIED, f'{method} is not on the hatch allowlist')
|
|
53
|
+
|
|
54
|
+
# A unary deny handler aborts before any message flows. Streaming methods
|
|
55
|
+
# on the deny path may surface a cardinality mismatch client-side, but the
|
|
56
|
+
# call is still refused; allowed methods keep their real (any-cardinality)
|
|
57
|
+
# handler via ``continuation``.
|
|
58
|
+
return grpc.unary_unary_rpc_method_handler(deny)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class GrpcHatch:
|
|
62
|
+
"""Serve allowlisted `grpc` servicers over the sandbox's UDS."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
allowlist: typing.Iterable[str],
|
|
67
|
+
*,
|
|
68
|
+
socket_path: str | os.PathLike[str] | None = None,
|
|
69
|
+
max_workers: int = 8,
|
|
70
|
+
) -> None:
|
|
71
|
+
if socket_path is None:
|
|
72
|
+
self._dir = tempfile.mkdtemp(prefix='postern-')
|
|
73
|
+
self._path = os.path.join(self._dir, 'hatch.sock')
|
|
74
|
+
else:
|
|
75
|
+
self._dir = None
|
|
76
|
+
self._path = os.fspath(socket_path)
|
|
77
|
+
self._server = grpc.server(
|
|
78
|
+
futures.ThreadPoolExecutor(max_workers=max_workers),
|
|
79
|
+
interceptors=[_Allowlist(allowlist)],
|
|
80
|
+
)
|
|
81
|
+
self._server.add_insecure_port(f'unix:{self._path}')
|
|
82
|
+
self._started = False
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def socket_path(self) -> str:
|
|
86
|
+
return self._path
|
|
87
|
+
|
|
88
|
+
def add_servicer(self, register: Callable[[typing.Any, grpc.Server], None], servicer: object) -> None:
|
|
89
|
+
"""Register a servicer via its generated ``add_<Service>Servicer_to_server``."""
|
|
90
|
+
register(servicer, self._server)
|
|
91
|
+
|
|
92
|
+
def start(self) -> None:
|
|
93
|
+
"""Start serving (idempotent).
|
|
94
|
+
|
|
95
|
+
A gRPC server cannot be restarted, so the hatch serves from here until
|
|
96
|
+
:meth:`close` — reused across many calls.
|
|
97
|
+
"""
|
|
98
|
+
if not self._started:
|
|
99
|
+
self._server.start()
|
|
100
|
+
self._started = True
|
|
101
|
+
# Deterministic socket perms, not umask-dependent (F9). The guest
|
|
102
|
+
# runs as a non-root uid, so it must be able to connect; host-side
|
|
103
|
+
# isolation rests on the 0700 mkdtemp dir above, which keeps other
|
|
104
|
+
# host users from reaching the socket at all.
|
|
105
|
+
with contextlib.suppress(OSError):
|
|
106
|
+
os.chmod(self._path, 0o666) # noqa: S103 — intentional; see the comment above
|
|
107
|
+
|
|
108
|
+
@contextlib.contextmanager
|
|
109
|
+
def accepting(self) -> Generator[GrpcHatch, None, None]:
|
|
110
|
+
"""Ensure the hatch is serving for the block.
|
|
111
|
+
|
|
112
|
+
It stays up afterwards so a later call can reuse it, and is stopped only
|
|
113
|
+
by :meth:`close`.
|
|
114
|
+
"""
|
|
115
|
+
self.start()
|
|
116
|
+
yield self
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
if self._started:
|
|
120
|
+
self._server.stop(0)
|
|
121
|
+
self._started = False
|
|
122
|
+
with contextlib.suppress(OSError):
|
|
123
|
+
os.unlink(self._path)
|
|
124
|
+
if self._dir is not None:
|
|
125
|
+
with contextlib.suppress(OSError):
|
|
126
|
+
os.rmdir(self._dir)
|
postern/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: postern
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run untrusted Python in an OS-isolated sandbox whose only exit is a set of host-defined, typed calls.
|
|
5
|
+
Author: Centre for Population Genomics
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/populationgenomics/postern
|
|
8
|
+
Project-URL: Issues, https://github.com/populationgenomics/postern/issues
|
|
9
|
+
Keywords: sandbox,bubblewrap,isolation,untrusted-code,seccomp,rpc,agent,tool-use
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Software Development :: Interpreters
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Provides-Extra: grpc
|
|
25
|
+
Requires-Dist: grpcio>=1.60; extra == "grpc"
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# postern
|
|
29
|
+
|
|
30
|
+
Run untrusted Python in an OS-isolated sandbox whose **only** exit is a set of
|
|
31
|
+
host-defined, typed gRPC methods.
|
|
32
|
+
|
|
33
|
+
A postern is the small guarded gate through an otherwise sealed wall. That is the
|
|
34
|
+
model: guest code runs with no network, no filesystem beyond a workspace, no
|
|
35
|
+
capabilities — and reaches the outside world only by calling the specific gRPC
|
|
36
|
+
methods the host allowlists. The security boundary is that method set, not a
|
|
37
|
+
coarse permission flag.
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from postern import Sandbox, SandboxProfile
|
|
41
|
+
from postern.grpc import GrpcHatch
|
|
42
|
+
import greeter_pb2_grpc
|
|
43
|
+
|
|
44
|
+
hatch = GrpcHatch(allowlist={'/greeter.Greeter/SayHello'})
|
|
45
|
+
hatch.add_servicer(greeter_pb2_grpc.add_GreeterServicer_to_server, MyGreeter())
|
|
46
|
+
|
|
47
|
+
profile = SandboxProfile.with_venv('/opt/analysis-env') # pandas, grpcio, stubs
|
|
48
|
+
result = Sandbox(profile, hatch=hatch).run_python(guest_code)
|
|
49
|
+
# guest dials unix:$POSTERN_HATCH with the generated stub; a non-allowlisted
|
|
50
|
+
# method → PERMISSION_DENIED; there is no network.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Why
|
|
54
|
+
|
|
55
|
+
Coarse sandbox permissions (`--allow-net`, `--allow-read`) are the wrong grain
|
|
56
|
+
for untrusted agent/tool code: you rarely want "the network", you want "this one
|
|
57
|
+
method that fetches this one resource". postern inverts the default — the guest
|
|
58
|
+
gets **nothing** except the host methods you allowlist, each a typed proto shape.
|
|
59
|
+
Whatever a method can reach (a database, a credentialed API, a compute backend)
|
|
60
|
+
the guest reaches only through that shape, never directly.
|
|
61
|
+
|
|
62
|
+
This is the design [enclave](https://github.com/populationgenomics/enclave-py)
|
|
63
|
+
prototyped over WebAssembly (WASI-compiled CPython). postern delivers the same
|
|
64
|
+
"fine-grained function injection is the boundary" promise over a different
|
|
65
|
+
substrate — **OS isolation (bubblewrap) + a gRPC-over-UDS hatch** — which means
|
|
66
|
+
real CPython with arbitrary third-party packages (no custom toolchain), and
|
|
67
|
+
typed, language-neutral arguments/results (proto, `buf breaking`-gateable).
|
|
68
|
+
|
|
69
|
+
## Isolation
|
|
70
|
+
|
|
71
|
+
`Sandbox` launches the guest under [bubblewrap](https://github.com/containers/bubblewrap):
|
|
72
|
+
|
|
73
|
+
- **empty network namespace** — no egress of any kind (a socket can be created
|
|
74
|
+
but has no route). The user and cgroup namespaces are unshared **strictly**
|
|
75
|
+
(`--unshare-user`/`--unshare-cgroup`, not `--unshare-all`'s best-effort `-try`
|
|
76
|
+
variants), so a host that can't provide a user namespace is a hard launch
|
|
77
|
+
failure rather than a silent fall-through to a real-root guest;
|
|
78
|
+
- **surgical filesystem** — read-only base system dirs + one writable
|
|
79
|
+
`/workspace`; no `/etc`, `/home`, `/root`, or host application code;
|
|
80
|
+
- **`--cap-drop ALL`**, **`--new-session`** (anti terminal-injection),
|
|
81
|
+
**`--die-with-parent`**, **`--clearenv`**;
|
|
82
|
+
- **non-root guest** — the guest runs as uid/gid `65534` (`nobody`), so it holds
|
|
83
|
+
no capabilities inside its user namespace, and if the userns fails to
|
|
84
|
+
materialise on a root host it still drops to a non-root real uid
|
|
85
|
+
(`SandboxProfile(guest_uid=None)` restores the legacy uid-0-in-userns);
|
|
86
|
+
- a **seccomp denylist** blocking escape-enabling syscalls (`unshare`, `setns`,
|
|
87
|
+
`mount`, `ptrace`, `bpf`, `keyctl`, …). `socket` is deliberately *not* blocked
|
|
88
|
+
— network isolation is the netns's job, and the guest needs `socket(AF_UNIX)`
|
|
89
|
+
for the hatch;
|
|
90
|
+
- **`RLIMIT_NPROC`** as a fork-bomb backstop (set inside the guest), and an
|
|
91
|
+
optional **`RLIMIT_AS`** memory backstop (`SandboxProfile(rlimit_as=...)`, off
|
|
92
|
+
by default; a cgroup `memory.max` at the deploy layer is the real isolation).
|
|
93
|
+
|
|
94
|
+
The hatch UDS is bind-mounted in as the single controlled opening. Because the
|
|
95
|
+
RPC rides that socket, the guest's own stdin/stdout/stderr stay free.
|
|
96
|
+
|
|
97
|
+
**Fail-closed boot check.** Every control is enforced on the launch path: the
|
|
98
|
+
strict `--unshare-*` flags make bwrap abort if it can't create the namespaces,
|
|
99
|
+
apply `--uid`, or drop capabilities, and the seccomp loader refuses an uncovered
|
|
100
|
+
architecture — so a successful launch *is* the proof (no runtime probe, like
|
|
101
|
+
Chrome's sandbox). `Sandbox(profile).verify()` just triggers one trivial launch
|
|
102
|
+
at startup so a broken platform (no user namespace, gVisor, uncovered arch)
|
|
103
|
+
raises `IsolationError` there rather than on the first request. Call it at worker
|
|
104
|
+
startup and refuse to serve if it raises (`examples/worker.py` does this).
|
|
105
|
+
|
|
106
|
+
## The environment (getting pandas etc. in)
|
|
107
|
+
|
|
108
|
+
The sandbox has no egress, so packages are provisioned **ahead of time** and
|
|
109
|
+
mounted read-only — never `pip install`ed at run time.
|
|
110
|
+
|
|
111
|
+
- `SandboxProfile.with_venv('/opt/env')` binds a venv read-only (at its own path,
|
|
112
|
+
so its `site.py` resolution works) and runs its interpreter. The venv holds the
|
|
113
|
+
guest's libraries **and** its hatch client (grpcio + the generated stubs).
|
|
114
|
+
- `SandboxProfile(rootfs='/opt/guest-root')` binds a curated base directory as the
|
|
115
|
+
guest's system dirs *instead of the host's* — hiding the host userland
|
|
116
|
+
entirely. Build it at image-build time (build-time Docker is fine; only
|
|
117
|
+
*runtime* container engines are excluded): `docker export` a container into a
|
|
118
|
+
dir, or ship a single squashfs/erofs image file mounted read-only via FUSE
|
|
119
|
+
(`squashfuse`, unprivileged, Cloud-Run-compatible) and point `rootfs` at the
|
|
120
|
+
mountpoint. `bwrap --ro-overlay` can stack OCI layer dirs without flattening.
|
|
121
|
+
|
|
122
|
+
## Requirements
|
|
123
|
+
|
|
124
|
+
Linux with **bubblewrap** and unprivileged user namespaces (a Cloud Run gen2 Job,
|
|
125
|
+
or any such host). `postern.available()` reports whether a sandbox can launch.
|
|
126
|
+
The seccomp filter is a prebuilt multi-arch BPF blob (x86_64, x86, x32, aarch64,
|
|
127
|
+
arm); on any other architecture it would be a default-allow no-op, so `Sandbox`
|
|
128
|
+
**refuses to launch** there (fail-closed) rather than run with an unenforced
|
|
129
|
+
filter — set `SandboxProfile(seccomp=False)` to override deliberately. Not
|
|
130
|
+
runnable on macOS except against a Linux target — `import postern` works
|
|
131
|
+
anywhere, `Sandbox.run*` needs the OS.
|
|
132
|
+
|
|
133
|
+
**Ubuntu 23.10+ / 24.04** restrict unprivileged user namespaces by default
|
|
134
|
+
(`kernel.apparmor_restrict_unprivileged_userns=1`), which bubblewrap needs —
|
|
135
|
+
the symptom is `bwrap: setting up uid map: Permission denied` or `loopback:
|
|
136
|
+
Failed RTM_NEWADDR`. Lift it with `sudo sysctl -w
|
|
137
|
+
kernel.apparmor_restrict_unprivileged_userns=0`, or install an AppArmor profile
|
|
138
|
+
that grants bwrap `userns`. Cloud Run gen2 does not have this restriction.
|
|
139
|
+
|
|
140
|
+
The bare `Sandbox` has **no third-party dependencies and no cloud dependency** —
|
|
141
|
+
it is a Linux primitive. The gRPC hatch pulls `grpcio` via the `grpc` extra.
|
|
142
|
+
|
|
143
|
+
## Install
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install postern # the bare sandbox (no deps)
|
|
147
|
+
pip install 'postern[grpc]' # + the gRPC hatch
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Public API
|
|
151
|
+
|
|
152
|
+
- `Sandbox(profile=None, *, hatch=None)` — `.run(argv)`, `.run_python(code)` → `ProcResult(returncode, stdout, stderr, ok)`; `.verify()` (fail-closed boot check, raises `IsolationError`).
|
|
153
|
+
- `SandboxProfile(workspace=None, rootfs=None, python='python3', ro_binds=[], stubs=None, env=..., seccomp=True, rlimit_nproc=1024, rlimit_as=None, guest_uid=65534, guest_gid=65534)` and `SandboxProfile.with_venv(venv, **kw)`. `stubs=` injects a dir or list of files at `/run/postern/stubs` (on `PYTHONPATH`) — a shared rootfs carries the heavy base, per-agent stubs bind in selectively.
|
|
154
|
+
- `postern.grpc.GrpcHatch(allowlist, *, socket_path=None)` — `.add_servicer(register_fn, servicer)`; `with hatch.accepting(): ...`. (`grpc` extra.)
|
|
155
|
+
- `available()` — bubblewrap present?
|
|
156
|
+
|
|
157
|
+
See `examples/e2e_greeter.py` for an end-to-end run (typed hatch call + pandas,
|
|
158
|
+
verified on a Linux host).
|
|
159
|
+
|
|
160
|
+
## Deploy: bundle the rootfs into the Job image
|
|
161
|
+
|
|
162
|
+
For a Cloud Run Job you build an image anyway, so bundle the guest rootfs into
|
|
163
|
+
it and let postern bind it — no runtime container engine. `examples/Dockerfile`
|
|
164
|
+
is the recipe: a multi-stage build that (1) generates the stubs, (2) builds a
|
|
165
|
+
minimal guest rootfs (`python:slim` + grpcio + your data libs + the client
|
|
166
|
+
stubs), and (3) assembles the worker (bubblewrap + `postern[grpc]` + your
|
|
167
|
+
servicer) with the guest rootfs copied to `/opt/guest-root`. The worker
|
|
168
|
+
(`examples/worker.py`) binds it with `SandboxProfile(rootfs='/opt/guest-root')`,
|
|
169
|
+
so the guest sees only that curated image, never the worker's userland. Cloud
|
|
170
|
+
Run gen2 provides the unprivileged user namespaces bubblewrap needs.
|
|
171
|
+
|
|
172
|
+
## Roadmap
|
|
173
|
+
|
|
174
|
+
- **Checkpoint/restore** — a `Store` protocol + durable-glob workspace snapshots
|
|
175
|
+
for run-lived state continuity.
|
|
176
|
+
- **`overlay=` profile mode** — emit `bwrap --ro-overlay` to stack layers with a
|
|
177
|
+
tmpfs upper, instead of a single `--ro-bind` rootfs.
|
|
178
|
+
- **Agent-runtime adapters** — drive the sandbox from Anthropic Managed Agents,
|
|
179
|
+
Google ADK, or MCP (the same sandbox, provider-agnostic).
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
postern/__init__.py,sha256=mWBSquR1Eonz9cQ1b8vskULsz59G-jHDKR5Kac_TNcY,1512
|
|
2
|
+
postern/_guest.py,sha256=sbjFJxOEfNC8Jk7kzFIsF7x1EfIMTaU7gvcpruiUYwo,1262
|
|
3
|
+
postern/_sandbox.py,sha256=CSC3L_xQyhojzFD38PtDAWe1wEkPrvIkAaaIO2s2qQg,16445
|
|
4
|
+
postern/_seccomp.bpf,sha256=NQxvMjrWPByYP_F8J9yqpjxgdCZ-O91M6lTXwgEcTfI,1736
|
|
5
|
+
postern/_seccomp.py,sha256=62cpnI8SmmLaNWxJgTAS6vu-wX2-5ZObE6HLrNd5HUQ,7348
|
|
6
|
+
postern/_seccomp.spec,sha256=c2U7_As5LtF2p1knXkstIgx9z86W7jGZ9V5UHEx2Yr8,174
|
|
7
|
+
postern/grpc.py,sha256=ZMgLl-68_Pcx50PyJELmnQce_ieJ8W7eCS8Iv62Mof4,4967
|
|
8
|
+
postern/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
postern-0.1.0.dist-info/licenses/LICENSE,sha256=lwIanGu698z5WiBedbwEOdUJLQb_G4FOFgucqExVIS0,1087
|
|
10
|
+
postern-0.1.0.dist-info/METADATA,sha256=IGXM0MCz4VuUZGYfhDuzDTpypmyI1ec9TP2zzm-hXXQ,9766
|
|
11
|
+
postern-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
12
|
+
postern-0.1.0.dist-info/top_level.txt,sha256=jiA-kL8y47RlY1WHPrOJ-5CCc-3r3SQ3LEOmZxsYyiE,8
|
|
13
|
+
postern-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Centre for Population Genomics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
postern
|