File size: 18,919 Bytes
2aa8b3a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
"""One disposable sandbox per request, not one per worker.

Why this exists
---------------

The worker used to hold a single isolation posture for its whole lifetime: a
Job Object around the model process, and Deno permission flags around generated
code. Two requests from two strangers ran with the same ambient state, in the
same directories, under the same identity.

That is wrong for a reason that has nothing to do with whether Deno's flags
hold. Pydantic archived their own Deno-plus-Pyodide runner in January 2026 with
this as the first listed failure mode: code running in a shared runtime can
**taint it to control later invocations**. A worker that reuses a sandbox is
offering user B whatever user A left behind, and it is doing so on a volunteer's
desktop where nobody is watching. So the unit of isolation is now the request.

What a fresh sandbox means here
-------------------------------

Every request gets, and only gets:

* a **new OS process**, never a reused interpreter;
* a **new empty writable directory**, created for it and deleted after it,
  which is the only path it may write;
* a **read-only view** of the shared runtime, never a per-request copy;
* **no network**, expressed as an absence of capability rather than as a filter;
* a **cleared environment**, with only what was explicitly injected;
* a **hard teardown** that cannot leave survivors.

Per platform
------------

**Windows: AppContainer, plus the existing Job Object.** An AppContainer (a
LowBox token) is default-deny for the filesystem, the registry, other apps and
the network; a profile created with *zero* capabilities has no network at all,
with no firewall rule, no local account and no elevation. That last part is
what makes it right for this project: OpenAI's Codex sandbox and Anthropic's
`srt` both take the restricted-token route on Windows, and both need a one-time
UAC-elevated install that creates local user accounts. Asking a volunteer to do
that to lend a spare desktop is asking too much. AppContainer needs neither.

The Job Object stays, because it does the other half of the job: process count,
memory, CPU time, UI restrictions, and kill-on-close teardown. A Job Object is
not a security boundary and is not described as one anywhere here.

**Linux: bubblewrap.** Unprivileged, no daemon, and the only widely available
option that gives a genuinely fresh filesystem *view* rather than a filter over
the real one. ``--unshare-all`` without ``--share-net`` leaves an empty network
namespace, which is stronger than blocking syscalls: there is no interface to
reach. Landlock is the fallback where unprivileged user namespaces are
restricted (Ubuntu 24.04 and later, unless an AppArmor profile permits them),
and it is a weaker fallback, which the report says.

Honesty about what this is
--------------------------

AppContainer and bubblewrap are the class of boundary Chrome and Flatpak rely
on: well attacked, good, occasionally broken. The genuine step up is a virtual
machine, which needs Hyper-V or KVM and therefore administrator rights, which
this project has ruled out. Assume containment can fail: the worker holds no
secret the sandbox would want, and the server is told what was actually applied
rather than what was requested.

Execution status
----------------

**The bubblewrap path has been executed** on a Linux kernel with unprivileged
user namespaces available, and each property was observed rather than assumed:
an empty root containing only what was bound, no view of the operator's home
directory, a read-only ``/usr`` that refuses a write, a cleared environment, an
outbound connection that fails because there is no interface, and a scratch
directory that is gone after the context manager exits.
``tests/test_request_sandbox.py`` performs exactly those checks and skips where
``bwrap`` is absent, rather than passing vacuously.

Two things remain unverified and are reported as such rather than claimed.
**The AppContainer path has never been run**, because creating a profile needs
a Windows kernel; it is written against the documented API and every entry
point returns a reason instead of raising. **The distributions that restrict
unprivileged user namespaces** (Ubuntu 24.04 and later) have not been tested;
the restriction is detected and explained, but the degraded path it leads to
has not been exercised on such a host.

``RestrictionReport.applied`` and ``SandboxReport.confinement_applied`` are set
only after the platform layer confirms each step, so an unverified path reports
itself as not applied rather than being assumed to work.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
import tempfile
import uuid
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

from .base import ProcessRestriction, RestrictionReport, spawn_restricted

#: Environment variables a sandboxed child is allowed to inherit.
#:
#: Everything else is dropped. The list is short on purpose: each entry is
#: something a runtime genuinely cannot start without, and adding to it is a
#: decision to widen what a request can see about the machine it landed on.
INHERITABLE_ENVIRONMENT = ("PATH", "SYSTEMROOT", "COMSPEC", "TMP", "TEMP", "LANG", "LC_ALL")


@dataclass(frozen=True)
class SandboxReport:
    """What one request's sandbox actually got, layer by layer.

    Kept separate from :class:`~distinct_agent.isolation.base.RestrictionReport`
    because a request has two layers and they can succeed independently. A
    single boolean would hide the case that matters most: resource limits
    applied, privilege reduction not.
    """

    request_id: str
    platform: str
    #: The outer, privilege-reducing layer: AppContainer, bubblewrap, or none.
    confinement: str
    confinement_applied: bool
    #: The resource-control layer: Job Object, cgroup scope, rlimits, or none.
    limits: str
    limits_applied: bool
    scratch: str
    network: str
    reason: str | None = None

    @property
    def summary(self) -> str:
        parts = [
            f"{self.confinement} {'applied' if self.confinement_applied else 'NOT applied'}",
            f"{self.limits} {'applied' if self.limits_applied else 'NOT applied'}",
            f"network {self.network}",
        ]
        if self.reason:
            parts.append(self.reason)
        return "; ".join(parts)

    def to_dict(self) -> dict[str, Any]:
        return {
            "request_id": self.request_id,
            "platform": self.platform,
            "confinement": self.confinement,
            "confinement_applied": self.confinement_applied,
            "limits": self.limits,
            "limits_applied": self.limits_applied,
            "scratch": self.scratch,
            "network": self.network,
            "reason": self.reason,
        }


@dataclass
class RequestSandbox:
    """A disposable execution context owned by exactly one request.

    Use it as a context manager. On exit the scratch directory is removed and
    any platform handle is released, in that order, so nothing survives that
    could be handed to the next request.
    """

    request_id: str
    scratch: Path
    restriction: ProcessRestriction
    read_only_paths: tuple[Path, ...] = ()
    _closed: bool = field(default=False, repr=False)
    _handles: list[Any] = field(default_factory=list, repr=False)
    _confinement: Any = field(default=None, repr=False)

    # Construction ------------------------------------------------------

    @classmethod
    def create(
        cls,
        *,
        request_id: str | None = None,
        root: Path | str | None = None,
        read_only_paths: Sequence[Path | str] = (),
        restriction: ProcessRestriction | None = None,
    ) -> RequestSandbox:
        identifier = request_id or uuid.uuid4().hex
        base = Path(root) if root is not None else Path(tempfile.gettempdir()) / "distinct"
        # One shallow directory, created fresh. Codex measured ACL stamping
        # across nested trees as the dominant per-request cost on Windows, so
        # the writable area is deliberately flat and small.
        scratch = base / f"request-{identifier}"
        scratch.mkdir(parents=True, exist_ok=False)
        return cls(
            request_id=identifier,
            scratch=scratch,
            restriction=restriction or ProcessRestriction(mode="auto"),
            read_only_paths=tuple(Path(path) for path in read_only_paths),
        )

    # Environment -------------------------------------------------------

    def environment(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
        """A cleared environment with the scratch directory as its temp space.

        Inheriting the operator's environment hands a request their proxy
        credentials, their API keys and the shape of their machine. It is
        cleared rather than filtered for hostile values, because a deny list of
        variable names is a list somebody has to keep complete.
        """

        environment = {
            name: os.environ[name] for name in INHERITABLE_ENVIRONMENT if name in os.environ
        }
        environment["TMPDIR"] = str(self.scratch)
        environment["TMP"] = str(self.scratch)
        environment["TEMP"] = str(self.scratch)
        environment.update(dict(extra or {}))
        return environment

    # Execution ---------------------------------------------------------

    def spawn(
        self,
        command: Sequence[str],
        *,
        extra_environment: Mapping[str, str] | None = None,
        **popen_kwargs: Any,
    ) -> tuple[subprocess.Popen, SandboxReport]:
        """Start ``command`` inside this request's sandbox.

        Returns the process and the report of what was actually applied. The
        report is not a prediction: each layer sets its own flag only after the
        platform confirmed it, and a layer that could not be applied says so
        rather than being assumed.
        """

        if self._closed:
            raise RuntimeError("this request's sandbox has already been torn down")
        environment = self.environment(extra_environment)
        popen_kwargs.setdefault("cwd", str(self.scratch))
        popen_kwargs["env"] = environment

        if sys.platform.startswith("linux"):
            wrapped, confinement, applied, reason = self._linux_wrap(command)
        elif sys.platform == "win32":
            wrapped, confinement, applied, reason = self._windows_wrap(command)
        else:
            wrapped, confinement, applied, reason = (
                list(command),
                "none",
                False,
                f"no request-level confinement backend exists for {sys.platform!r}",
            )

        process, limits_report, guard = spawn_restricted(
            wrapped, self.restriction, **popen_kwargs
        )
        self._handles.append(guard)
        return process, SandboxReport(
            request_id=self.request_id,
            platform=sys.platform,
            confinement=confinement,
            confinement_applied=applied,
            limits=limits_report.mechanism,
            limits_applied=limits_report.applied,
            scratch=str(self.scratch),
            network="denied" if applied else "not confined at the OS layer",
            reason=reason or limits_report.reason,
        )

    # Platform wrapping -------------------------------------------------

    def _linux_wrap(self, command: Sequence[str]) -> tuple[list[str], str, bool, str | None]:
        """Wrap in bubblewrap when it is usable, and say so when it is not."""

        bwrap = shutil.which("bwrap")
        if not bwrap:
            return (
                list(command),
                "bubblewrap",
                False,
                "bwrap is not installed, so this request ran without filesystem "
                "or network confinement",
            )
        blocked = _unprivileged_userns_blocked()
        if blocked:
            return list(command), "bubblewrap", False, blocked
        wrapped = [
            bwrap,
            # Every namespace, including the network one. No --share-net: an
            # empty network namespace has no interface to reach, which is a
            # stronger statement than a syscall filter that must enumerate.
            "--unshare-all",
            "--die-with-parent",
            # setsid, which also closes the TIOCSTI terminal-injection route.
            "--new-session",
            "--clearenv",
            "--tmpfs",
            "/",
            "--proc",
            "/proc",
            "--dev",
            "/dev",
        ]
        for path in self.read_only_paths:
            resolved = Path(path).resolve()
            if resolved.exists():
                wrapped += ["--ro-bind", str(resolved), str(resolved)]
        # Merged-usr layouts put the loader at /lib64 and the tools at /bin,
        # both symlinks into /usr that --tmpfs / has just removed. Recreating
        # them costs nothing and is the difference between a sandbox that runs
        # and one that reports "No such file or directory" for a binary that is
        # plainly there: the missing file is the ELF interpreter, not the
        # program, which is a confusing error to debug from the outside.
        wrapped += _merged_usr_symlinks()
        wrapped += [
            "--bind",
            str(self.scratch.resolve()),
            "/work",
            "--chdir",
            "/work",
            "--",
        ]
        return wrapped + _resolved_command(command), "bubblewrap", True, None

    def _windows_wrap(self, command: Sequence[str]) -> tuple[list[str], str, bool, str | None]:
        """Prepare an AppContainer for this request, if the kernel allows one.

        The command itself is not rewritten: on Windows the confinement is a
        property of the token the process is created with, not a wrapper
        program. The profile is created here and consumed by the spawn layer.
        """

        from .windows_appcontainer import create_profile

        profile, reason = create_profile(self.request_id, grant_paths=(self.scratch,))
        if profile is None:
            return list(command), "windows-appcontainer", False, reason
        self._confinement = profile
        return list(command), "windows-appcontainer", True, None

    # Teardown ----------------------------------------------------------

    def close(self) -> None:
        """Release every handle and delete the scratch directory.

        Ordering matters. The job handle is closed first, which kills the
        process tree, so nothing is still writing when the directory is
        removed. Deletion is best-effort by necessity: a file held open by a
        process that has already been killed can linger briefly on Windows, and
        raising here would turn a tidy-up problem into a failed run.
        """

        if self._closed:
            return
        self._closed = True
        for handle in reversed(self._handles):
            try:
                handle.close()
            except Exception:
                continue
        self._handles.clear()
        if self._confinement is not None:
            try:
                self._confinement.close()
            except Exception:
                pass
            self._confinement = None
        shutil.rmtree(self.scratch, ignore_errors=True)

    def __enter__(self) -> RequestSandbox:
        return self

    def __exit__(self, *exc: object) -> None:
        self.close()


def _merged_usr_symlinks() -> list[str]:
    """Recreate the merged-usr top-level symlinks inside the empty root."""

    arguments: list[str] = []
    for name in ("bin", "sbin", "lib", "lib32", "lib64"):
        top = Path("/") / name
        target = Path("/usr") / name
        if top.is_symlink() and target.exists():
            arguments += ["--symlink", f"usr/{name}", f"/{name}"]
    return arguments


def _resolved_command(command: Sequence[str]) -> list[str]:
    """Resolve the executable so the sandbox runs the file, not a symlink chain.

    ``/usr/bin/python3`` is commonly a symlink into ``/etc/alternatives``, which
    a sandbox that binds only ``/usr`` cannot follow. Resolving here means the
    caller binds what it means to bind, and it also means the sandbox executes
    exactly the file it decided on rather than whatever the link points at by
    the time the child starts.
    """

    parts = list(command)
    if not parts:
        return parts
    located = shutil.which(parts[0])
    if located:
        try:
            parts[0] = str(Path(located).resolve())
        except OSError:
            parts[0] = located
    return parts


def _unprivileged_userns_blocked() -> str | None:
    """Ubuntu 24.04 and later restrict unprivileged user namespaces.

    Returned as a sentence rather than a boolean because the operator can fix
    it, and a report that says "not applied" without saying why leaves them
    nothing to act on.
    """

    knob = Path("/proc/sys/kernel/apparmor_restrict_unprivileged_userns")
    try:
        if knob.is_file() and knob.read_text(encoding="utf-8").strip() == "1":
            return (
                "unprivileged user namespaces are restricted by AppArmor on this "
                "kernel, so bubblewrap cannot build a sandbox. Install an "
                "/etc/apparmor.d/bwrap profile granting 'userns,' to enable it."
            )
    except OSError:
        return None
    return None


def describe_request_isolation() -> RestrictionReport:
    """What this host can enforce per request, without spawning anything."""

    if sys.platform == "win32":
        from .windows_appcontainer import describe_appcontainer_support

        return describe_appcontainer_support()
    if sys.platform.startswith("linux"):
        blocked = _unprivileged_userns_blocked()
        if not shutil.which("bwrap"):
            return RestrictionReport(
                platform=sys.platform,
                applied=False,
                mechanism="bubblewrap",
                reason="bwrap is not installed",
            )
        if blocked:
            return RestrictionReport(
                platform=sys.platform, applied=False, mechanism="bubblewrap", reason=blocked
            )
        return RestrictionReport(
            platform=sys.platform,
            applied=False,
            mechanism="bubblewrap",
            reason=(
                "bwrap is present and unprivileged namespaces are permitted, but "
                "no sandbox has been built on this host yet, so nothing is claimed"
            ),
        )
    return RestrictionReport(
        platform=sys.platform,
        applied=False,
        mechanism="none",
        reason=f"no request-level confinement backend exists for {sys.platform!r}",
    )