Skip to content

Governed package installation for kernels, on bounded network egress - #281

Draft
KB (KB-syntheticsciences) wants to merge 71 commits into
mainfrom
feat/kernel-package-install
Draft

Governed package installation for kernels, on bounded network egress#281
KB (KB-syntheticsciences) wants to merge 71 commits into
mainfrom
feat/kernel-package-install

Conversation

@KB-syntheticsciences

@KB-syntheticsciences KB (KB-syntheticsciences) commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Gives the agent a governed way to install packages — and closes the only ungoverned way.

A new package_install tool installs into named, language-scoped environments behind an approval
card. Kernels bind to an environment through an environment parameter beside kernel, and an
install restarts them only when the change is not purely additive. Shell installers are refused
before they run.

Supersedes and closes #280. This branch contains every one of #280's 33 commits plus 16 more, so
merging this ships the complete feature — bounded network egress and governed installation — in one
step. #280 is closed rather than merged; its review record is summarised below so nothing is lost.

What a user gets

before after
Agent installs a package no mechanism at all package_install, one approval card
Approval scope install tqdm → default [pypi.org/simple], with a standing install* grant
pip install in the agent shell silently succeeds, ungoverned refused, naming the tool
Where packages land host site-packages (read-only) or nowhere a managed per-project environment
Kernel after an additive install keeps its variables
Kernel after an upgrade/removal restarted, because a loaded module would be silently stale
R no mechanism install.packages into R_LIBS_USER

Why the refusal is part of this PR, not a follow-up

#280 made shell installs work. Measured on that branch, inside the agent's own sandbox, with no
tool and no card:

python3 -m venv <workspace>/venv && <workspace>/venv/bin/pip install tqdm   →  4.70.0

The workspace is writable and pypi is allowlisted, so read-only system site-packages stops nothing.
Before the proxy this died at DNS and the install contract held by accident; the proxy removed the
accident, not the intent. An approval card that an agent can walk around is decorative, so the
refusal and the tool ship together and are asserted in the same test.

It is a contract boundary, not a security boundary — the same egress can fetch a wheel by hand —
and the code says so rather than claiming more.

How did you verify your code works?

By running it. Six defects were found by tests the plan did not call for, every one invisible to the
suite as planned:

  • uv venv does not install pip. The ladder prefers uv, but install() shells out to
    python -m pip, so on any machine with uv it produced an environment the installer could not use.
    Every other test passed, because the one asserting "a venv has pip" forces the venv branch. Fixed
    with uv venv --seed.
  • A manifest that could never be read back. execute is reachable without zod defaults applied,
    so language was undefined, JSON.stringify dropped the key, and read's parse rejected the
    file. The environment existed on disk with packages in it and was invisible to the inventory.
    Environment.write now validates before writing.
  • The kernel could not see its environment. "No such file or directory" for an interpreter that
    exists. Production would have worked by luck (~/.cache is under --ro-bind / /); --tmpfs /tmp
    masks it wherever the cache root lives under /tmp.
  • wrapArgv discarded policy.readBind at two call sites, replacing it wholesale with the
    egress shim's paths. The environment bind was dropped on the floor with no error.
  • six==1.17.0 over an installed 1.16.0 reported "already installed". The skip check compared
    names only, so an upgrade became a silent no-op that also reported the change as additive —
    leaving the old version in place while telling the agent it had the new one.
  • token() is undefined on Windows. The plan's reconcile rule would have marked every Windows
    install unknown forever and every environment permanently suspect.

Also fixed npm pack --json, which returns an object on npm 12 and an array before it — the test
indexed [0], so it failed on any current npm while passing on CI's older one.

  • bun run typecheck passes
  • bun test (in backend/cli) — 2111 pass / 6 skip / 0 fail
  • bunx prettier --check . clean
  • Linked an issue
  • Screenshots — the approval card is new UI worth a look

Follow-ups inherited from #280, none blocking

  • Pre-existing, on main today: --ro-bind / / exposes every host unix socket, so a sandboxed
    process reaches docker.sock and can obtain host root — in every network mode including deny.
    This work bounds network egress; local IPC to host daemons is a separate and wider hole.
  • Uncapped concurrent proxy connections: ~64.7 KB each with no ceiling.
  • SSH remote jobs fail closed under allowlistssh ignores HTTP_PROXY and needs
    ProxyCommand/SOCKS. This is the one regression a user could notice.
  • egress-shim-dev-* artifacts accumulate in Global.Path.bin, never collected.
  • The release shim path has no CI guard. In a compiled binary the binary is its own shim; every
    test runs under bun run, which takes the dev-bundle branch. Verified once by hand against a
    bun run build --single build — a real pip install succeeded inside --unshare-net with the
    boundary intact — but nothing keeps it that way.
  • Windows migration flake, pre-existing. test/global/data-dir.test.ts intermittently fails with
    EBUSY cascading into ENOENT. Observed twice here, green on re-run of the same commit both times.
  • An unidentified intermittent test failure: one genuine two-failure run against roughly a dozen clean
    ones.

Merge gate

test/package/install-live.test.ts states the condition as an assertion, and the CI sandbox job runs
test/package/ on both legs so it is a fact on each platform rather than a claim about one:

pip under allowlist user approval
Linux ✅ live in CI ✅ live in CI
macOS ✅ (pending this PR's first run) ✅ (pending this PR's first run)
Windows ❌ not built (design now measured viable) ❌ not built

Windows still blocks the gate — nothing is built there, kernels do not run at all, and the suite
skips rather than fails. But it is no longer blocked on an open question.

docs/specs/windows-appcontainer-probe.ps1 was run on Windows 11 (10.0.26200), unelevated, with
MpsSvc running and all three firewall profiles enabled. It falsified the design's central claim,
in the useful direction. The spec said an AppContainer with no network capability has no loopback
either, so no shim could exist and Windows had to be capability-mediated. Measured: loopback works
inside the container, including across two processes sharing the package SID — 8192 bytes echoed
and content-verified, with a real peer endpoint recorded. The named pipe to a broker carries 64 KiB
each way, content-verified, host end agreeing on the byte count.

So the full chain holds and Windows can be socket-transparent like the other two platforms:

pip --(loopback, cross-process)--> shim --(named pipe)--> broker --> network

Isolation still holds where it must — outbound denied, host-loopback denied, DNS denied, user profile
unreadable and unwritable — and those denials are trustworthy because the run carried controls: a
default-DACL pipe that was refused, System32 still readable, host DNS working, and a check that the
machine's two pre-existing loopback exemptions belonged to other software.

One consequence for whoever builds it: the named pipe is load-bearing on Windows in a way it is not
elsewhere
, because the container cannot reach a host loopback listener at all. And the pipe must be
created with a custom DACL — a default-DACL pipe is refused — which net.createServer cannot express.

Spec updated in docs/specs/windows-sandbox-design.md; the compatibility table listing what Windows
would lose is deleted rather than corrected, since every row followed from the false premise.

What came from #280: bounded network egress

sandbox.network became three-state — deny | allowlist | allow, defaulting to allowlist. In that
mode the sandbox keeps --unshare-net, which blocks every host including the host's own loopback, and
a bind-mounted unix socket is the only route out. A shim inside the namespace bridges loopback TCP to
it; a proxy on the host resolves names, checks the authority against an allowlist, and pipes bytes.
macOS has no namespace, so there the proxy listens on 127.0.0.1:<port> and the seatbelt profile
narrows network-outbound to that one address, with a per-start secret in the proxy URL because every
process on the machine shares one loopback.

Before it, deny locked kernels out of PyPI, NCBI, UniProt, PDB and EBI — most of what a research tool
is for — and allow was unrestricted egress. Design recorded in docs/adr/0002-sandbox-network-policy.md.

Nine defects were found across fourteen review rounds, every one invisible to a passing suite.
Notable ones:

  • Silent truncation. The proxy discarded Socket.write's return value with no drain handler, so
    every transfer above a few KB lost bytes. 40 MB arrived as 2.6 MB; pip download numpy died with an
    SSL error. Now pinned by a live test that pulls an 18 MB wheel through a real sandbox and checks its
    sha256.
  • Host-process kill. An unbounded pre-dial buffer let one sandboxed process take the CLI to ~2 GB
    and RangeError: Out of memory in four seconds.
  • +1.00 s on every sandboxed spawn from whole-second readiness polling. Now 26 ms.
  • A confidentiality bug found in passing — a read-only bind of a directory silently un-masked
    unreadable entries, which carry kernelSensitivePaths().
  • An iteration cap masquerading as a time cap. shimScript polled 150 times and called it 3s; a
    macOS runner measured 17.1s for the same loop. Now a real date +%s deadline.

macOS was verified against a real sandbox-exec in CI, answering the three ways the seatbelt
profile could have shipped silently non-functional: network-bind/network-inbound as emitted are
sufficient, the tcp filter spelling works, and DNS does not slip past (deny network*) via
mDNSResponder.

Known gaps, stated rather than hidden

  • R's CRAN round trip is unverified. Rscript is not installed on the development machine, so
    the two live R tests skip. Everything else about the R path — the explicit lib, the
    warning-not-exit-code failure check, the message translation — is tested.
  • pip's cache is disabled inside the sandbox, so every install re-downloads. Correct behaviour
    for a read-only /, real repeat cost across a session. Whether to bind a writable per-project
    cache is a deliberate open decision.
  • The wait: false path has no notification channel; a later call is how the agent learns the
    outcome, and the tool's output says so rather than implying otherwise.

Binary network is the wrong granularity: deny locks kernels out of PyPI
and the scientific APIs, allow is unrestricted egress. A spike
established an enforceable middle — --unshare-net blocks every host
including the host's own loopback, while a bind-mounted unix socket
still crosses the namespace, so the socket is the only route out and the
proxy decides what is reachable.

Recorded before implementation because it is a breaking change to a
documented config key, and because it deletes the separate install
sandbox rather than adding a component.
The mechanism paragraph quotes two measured values from the spike; three
other statements were the ADR's own forward decisions but read in the
same flat register, so a reader couldn't tell which was which. Rewords
those three to say plainly that they are decisions this record is
taking, and marks the seatbelt warning behaviour as unverified rather
than stating it as settled fact.

No content removed — the brief's ten required points all still appear.
Ported from the spike on proto/sandbox-allowlist-proxy. The matcher is
pure and separable so the allowlist is testable without sockets.

Both measured fixes carried across: the shim buffers writes arriving
before the upstream unix connection resolves, and the proxy rewrites
absolute-form to origin-form for plain HTTP.
allowlist keeps --unshare-net and binds a unix socket as the only route
out, so the proxy on the far end is enforcement rather than advice. The
builder refuses allowlist without a socket path instead of silently
producing an open sandbox.

Seatbelt has no namespace equivalent, so it reads allowlist as deny:
falling back to allow would grant unrestricted egress to a user who
asked for a bounded one.
…ments

buildPolicy now filters `egress` through the same tooBroadToConfine gate
as writable/unreadable. An over-broad egress (e.g. $HOME, "/") was
reaching bubblewrapArgs unfiltered and becoming a read-write --bind,
defeating write containment entirely. A rejected path is dropped, which
leaves "allowlist" without an egress socket, so bubblewrapArgs' existing
missing-egress check still throws — fails closed either way.

Also corrects two comments that asserted things that weren't true: the
--bind comment overstated the socket bind as the access-control
mechanism (unshare-net is; the bind only makes the path reachable), and
plan()'s doc comment no longer mentioned the allowlist-without-egress
throw it gained in the previous change.
…heck

The round-1 fix checked tooBroadToConfine against the raw egress string,
so a trailing slash, a double slash, or an unresolved ".." bypassed the
gate entirely (strict string equality never matched) while resolving to
the exact same over-broad path on disk. writable/unreadable were never
vulnerable to this because they already went through dedupe()'s
path.resolve() before the same check. Route egress through the same
dedupe() call instead of hand-rolling separate normalization, so the
two paths cannot drift apart again.

Test coverage widened from the one literal string to the class of
lexical variants (trailing slash, double slash, unresolved ..), plus a
control asserting a legitimate non-broad socket still gets bound.
pip, requests and curl take a host:port proxy and none speak unix
sockets, so a loopback listener inside the namespace bridges to the
bind-mounted socket. It runs from the OpenScience binary, which is
already visible under --ro-bind / /, so nothing extra ships.

Script composition is a pure function with quoting tests, because a path
with a space or a quote in agent-authored code would otherwise split the
command.
…turally

Two live-verified breaks in the composed egress shim:

The shim went through yargs' global middleware before reaching its
handler, which opens a log file (EROFS under the sandbox's read-only
root) and fetches over HTTP (hangs under --unshare-net). shimScript
redirected the shim's own output to /dev/null, so this failed
completely silently: network "allowlist" behaved exactly like deny.
Handle __egress-shim as a raw argv check before any yargs construction
so it can never reach the middleware, regardless of what middleware
grows there later.

The dev-mode launcher resolved its target script from Bun.main, which
is whatever launched the current process. Under bun test that's the
test file, not the CLI entry - the exact context Task 6's live test
runs under. Resolve the entry from sandbox.ts's own location instead,
a structural relationship unaffected by what invoked the process.

Fixing that surfaced a second issue: the launcher's content-addressed
cache location under Global.Path.state resolved under the OS tmp dir
during bun test (test isolation redirects every XDG dir there), and
bubblewrapArgs unconditionally mounts a fresh tmpfs over /tmp inside
the sandbox - so the launcher silently didn't exist from inside and
the shim never started. Anchored the launcher to the repo checkout
itself instead, which neither test nor a real user's env can redirect.

Also added a bounded marker-file readiness wait before exec, since the
real command was exec'd immediately and anything doing network I/O in
the shim's ~600ms startup window got connection-refused.
…location

Location-based fixes for --tmpfs /tmp masking (Global.Path.state, then the
launcher's own checkout directory) both re-anchored the same bug instead of
removing it: a real checkout under /tmp (git worktree add /tmp/..., a CI
mktemp -d clone, a container build) masks the launcher exactly like an
XDG dir redirected under os.tmpdir() during bun test does. There is no
location immune to both. Bind the launcher back in explicitly after
--tmpfs /tmp instead, the same way the egress socket already is
(--ro-bind-try, read-only: it's executed, never written to, from inside).
Policy gains readBind for this; the launcher lives in Global.Path.bin
again, matching ensureAtlasBinDir, purely for tidiness now that its
location no longer needs to be "safe."

Split the dev-mode launcher's target off of src/index.ts into a new
minimal entry (egress-shim-entry.ts) that imports only Egress.serveShim.
index.ts's full graph pulls in Global's unguarded top-level cache-version
write (EROFS under a read-only tree) and a live models.dev fetch, both
reachable before any argv check could skip them - live-reproduced with a
stale/read-only cache dir. A compiled binary has no separate entry to
redirect to, so it still evaluates that graph; documented as a residual
in the Task 4 report rather than restructuring index.ts's ~30 command
imports into dynamic ones.

Also: the readiness wait now sleeps in whole seconds, since fractional
sleep is a coreutils extension busybox doesn't reliably support and a
rejected sleep would skip the wait entirely instead of slowing it down;
and the live sandbox-execution test resolves bash from the same
Bun.which() gate it skips on, instead of hardcoding /usr/bin/bash, so it
skips rather than fails on Alpine/non-usrmerge Debian.
…verlaps

Two more instances of the /tmp-masking class, both live-reproduced:

The dev bind list covered the launcher and the package root but not the
interpreter the launcher's exec line names (process.execPath) - it can
itself live under /tmp (a portable bun install, $HOME under /tmp),
independent of where the launcher or checkout live. Reproduced with a
bun binary staged under /tmp: byte-identical failure signature to the
original bug. Now bound alongside the other two.

bubblewrapArgs emits readBind after the writable --bind-try loop, so a
later read-only mount at (or inside) an already-writable path shadows
it. With a workspace that is or contains this package's own checkout -
the self-hosting case Task 5 will dogfood - binding the package root
read-only turned src/sandbox back read-only despite being nominally
writable. Fixed by excluding any readBind path already covered by an
actually-bound writable root before emitting it. "/tmp" itself needed
special handling: it is always nominally writable (tempDirs() adds it
unconditionally) but is deliberately never bound - the fresh tmpfs
already provides it - so treating it as "covers everything under it"
reintroduced the original masking bug for a launcher that resolves
under /tmp during bun test. Caught by the existing live test before
this ever left the working tree.

Only one containment direction is guarded (a readBind path inside a
writable root); the reverse has no realistic trigger given today's
three readBind candidates and would need reordering these mounts to
handle, risking the same shadowing bug in the other direction.

Consolidated the readiness-marker literal, previously duplicated across
three files and kept in sync by comment only, into one exported
constant all three import.

Added regression coverage for both: the live test parametrized over an
interpreter staged under /tmp (stages a real bun copy, drives a
standalone script through it - bun:test itself isn't the thing under
test), and a write-probe under a workspace that overlaps the package
root. Both have negative controls confirming they fail without their
respective fix.
…disk

The shim's launcher exec'd bun against egress-shim-entry.ts, so every path
bun touched resolving that source had to be bound back past --tmpfs /tmp.
Four revisions bound the paths their author thought of and each missed one.
The last: in this bun workspace an npm import resolves through
backend/cli/node_modules/<pkg>, a symlink into the monorepo-root store one
level above the package root, so binding the package root bound the link and
not its target. Reproduced against a /tmp-relocated checkout with a real
hoisted store — one added import turned the shim into ENOENT, connection
refused, silent.

Build the entry into a self-contained bundle instead and exec that: at run
time bun opens one file, so the bound set is closed by construction — the
launcher, the bundle, the interpreter — rather than a list that has to keep
pace with an import graph. bun build costs 3ms, once per process, on the
allowlist path only, and fails loudly at wrapArgv time. The artifacts are
content-addressed and renamed into place, so a rebuild cannot overwrite one
another process is executing and a stale launcher/bundle pair cannot form.

That drops the package root from readBind, which also removes the shadowing
hazard the writable-overlap exclusion guards: every remaining path is a
regular file, so nothing can nest inside one. The old justification for
guarding only that direction claimed no workspace would sit under those
locations, which is not true — write roots also come from session grants and
allowWrite — so it now gives the reason that holds.

Two tests, neither needing the relocated-checkout fixture: the generated
bundle carries no import specifier but builtins, and the shim still bridges
with its own source entry masked to /dev/null.
…ltin list

bun build strips the node: prefix, so a bundled `import "node:net"` reaches
the guard as `from "net"` and the prefix filter flagged it — a false alarm on
the next honest edit, aimed at the one check that closes the npm-import class.
The same filter excused any package named bun-something, and its regex never
matched a bare side-effect `import "x"` at all. Ask builtinModules instead,
after stripping node:; it already carries bun's own entries. Measured on real
bundles: net passes, diff / bun-pty / fuzzysort are all flagged.

Two doc corrections alongside it. The bundle digest also varies with the
process cwd, since bun build writes cwd-relative module banners — that is the
dimension that actually varies per invocation. And the residual list was
missing a fourth entry: a dependency loading a native binding bundles cleanly
and still dlopens a .so at run time, which is not an import specifier and so
is invisible to both the reasoning and the guard.
One proxy per process, held in a lazily-created singleton with a
disposer (not Instance.state: a global config write disposes every open
instance, and the proxy must outlive that or every kernel bound to its
socket loses its only route out). Socket lives under the state
directory.

Rules are read per connection rather than captured at start: refreshed
on every ensure() and reactively on global config changes via GlobalBus,
so editing the allowlist takes effect without tearing down kernels -
which is why proxy policy stays out of the generation hash.

sandbox.network defaults to allowlist. Both cases of the proxy
variables reach kernels: curl reads lowercase http_proxy and ignores
the uppercase form for HTTP. Widened three downstream network enums
(execution authority, sandbox settings route, persisted job records)
plus a fourth (KernelEnvironment) surfaced by typecheck. Exported
Sandbox.SHIM_PORT so EgressRuntime.ensure() returns the same port
sandbox.ts already uses, instead of a second constant that could drift.
network defaulting to allowlist exposed that no caller of Sandbox.wrapArgv
supplied the egress socket bubblewrapArgs requires for that policy, so
every sandboxed kernel, terminal, and compute job spawn threw instead of
running. Sandbox.plan() (the bash tool's path) never composed a shim at
all, so pip/curl/uv - the feature's motivating case - had zero network
under allowlist regardless.

EgressRuntime.egressFor(policy) is the single decision point: it starts
the proxy only when it would actually be used (bubblewrap backend,
network "allowlist"), so a terminal on macOS or under network "deny"
never pays for a proxy it has no way to reach. Every wrapArgv caller
(notebook/rkernel/biology kernels, compute jobs x4, pty terminals) and
bash.ts's plan() call now route through it and merge the returned
Wrapped/Plan.env into the spawned process's environment, which had been
an unconsumed seam since Task 4.

Sandbox.plan() now composes the same loopback shim wrapArgv does, for a
shell -c command instead of a file/args pair, by feeding shimScript the
shell invocation as its argv - one composition, not two.

Widened two test files' stale "deny"-only assertions to allow the new
allowlist default, widened five notebook-test/command-runtime polling
budgets that were tuned for pre-shim startup latency (the shim's own
wait can take up to ~3s), and made one shell.test.ts assertion filter
by pid instead of assuming absolute call order on a process-wide
process.kill mock - a race that was always latent but only became
probable once real sandboxed spawns became this common in the suite.

Full suite: 74 failures -> 1 (an npm-pack environment flake confirmed
present on the pre-Task-5 baseline too, unrelated to this branch).
…wn latency

Three live-verified Criticals on the egress path.

Backpressure. Both bridges called Socket.write and discarded the byte count
it returns, with no drain handler on either side, so every transfer past one
kernel send buffer lost its tail: 40 MB arrived as 2.8 MB through the proxy
and 11.6 MB through shim + proxy, and pypi.org/simple/ (44,841,256 bytes)
came back as 4.7/10.8/5.6 MB with "bad record mac" from curl. A pump per
direction now queues what the destination refused, flushes from that
destination's drain, pauses the source while a backlog exists, and defers
end() until the queue has gone out. All three sizes now arrive byte-exact.

Latched start. EgressRuntime cached the start promise unconditionally, so one
transient failure was replayed to every later caller for the process
lifetime, and stop() re-raised it instead of clearing it. Under the allowlist
default that is every bash command, terminal, kernel and compute job broken
until restart. A rejected start now un-caches itself, stop() is safe after
one, and the bind failure carries a message naming what depends on it.
Callers still throw rather than degrade: silently downgrading to no-proxy is
the failure this feature keeps producing.

Spawn latency. shimScript polled for readiness at whole-second granularity
while the bundled shim binds in ~12 ms, so every sandboxed spawn paid a flat
second whether or not it touched the network -- n=8, 1006 ms against 3 ms for
network "deny". The interval is now settled once by a single fractional sleep
whose stderr is discarded, falling back to whole seconds where busybox
rejects it; same 3s cap either way. Same measurement: 26 ms.

Regression tests move real volume (8 MB, both directions, byte-exact), drive
a real start failure, and time the composed script through a real /bin/sh.
Each fails against the code it fixes.
Both bridges dial from inside an async handler, and Bun does not serialize
those handlers -- a second chunk, or a client's FIN, re-enters while the first
call is parked on await Bun.connect. Two defects lived in that window.

An aborted connection stranded a socket at both ends. A client whose FIN
lands before Bun.connect(unix) resolves has its close handler run while there
is no link yet, so it tears down nothing, and the socket the dial then
produces is owned by nobody -- which also pins the host proxy's accepted
connection. Measured across three processes, 300 connect-then-close
connections leaked 0.897 fd/conn in the shim and 0.897 in the host proxy;
now 0.000 in both, with completed connections unchanged at 0.000 throughout.
Bounded by the sandbox's lifetime, which for a kernel or terminal is hours.

One client produced several upstream dials. A body arriving after its head
re-entered data, found no link, re-parsed the same buffered head and dialled
again: 2 upstream connections against a local origin, 4 against a real
remote one, both carrying a duplicate of a non-idempotent request with the
body split between them. Reachable in practice -- wrapArgv sets HTTP_PROXY
too, and "a request with a body" is the shape NCBI E-utilities recommends
for large id lists, against a host in DEFAULT_RULES.

Both close on one four-state phase claimed synchronously before the await.
The body needs no second queue: everything before the link stays in the one
buffer already there, and rest is sliced after the dial rather than before,
so bytes that arrive during it go upstream in order by construction.

Three regression tests count sockets opened against sockets closed at a
stand-in upstream, so they measure the invariant without /proc. All three
fail against the code they fix, three runs out of three.
Both pre-link phases of the host proxy buffered without limit, and the
proxy runs in the CLI's own process — so a single sandboxed process could
exhaust, and then kill, its own supervisor.

  head     no dial is ever attempted on this path, so nothing bounded it.
           93 MiB of never-terminated head took the host process from
           36.0 MB to 1344.9 MB of RSS in 8 s, still climbing.
  dialing  a complete CONNECT to an allowlisted host that black-holes SYNs.
           2048.6 MiB blasted in 8 s took it from 36.0 MB to 2120.2 MB and
           then killed it with RangeError: Out of memory, dial still in
           flight and ~2 minutes of kernel SYN retries left to go.

The head gets a 64 KiB cap (Squid's request_header_max_size default, the
most generous of the conventional caps) and a 431. There is no
backpressure to apply there: the terminator is what the parse waits for,
so declining to read would deadlock rather than end the connection.

The dial window gets real backpressure instead of a cap — client.pause()
for as long as the dial is in flight, so the bytes stay in the client's
own buffer and there is no limit to tune. The previous round declined
this on the grounds that pausing would suppress the FIN that reports the
client leaving. Measured, it does not: with delivery demonstrably stopped
(0.21 MiB through a paused socket against 256 MiB unpaused), the peer's
end() still produced close while the pause was in force, for FIN and RST
alike. serveShim's identical window is paused for the same reason.

Dials also now time out at 30 s rather than riding the kernel's ~130 s
SYN-retry budget, answering 504 instead of hanging undiagnosably.

After, same measurements: head 36.0 -> 38.3 MB (+2.3), dial 36.0 -> 37.5
MB (+1.5) with the process alive and the client's own writes stalled.

Three regression tests, each failing against the parent commit: no
response at all for the head, 2,147,690,880 bytes accepted for the dial,
and no answer within 20 s for the timeout. Re-verified unchanged: 8 MB
byte-exact both directions through both bridges, flat RSS under a stalled
reader (origin stopped at 6.1 MiB, proxy +2.9 MB over 10 s), 0.000 fd/conn
across 600 aborts, one upstream dial per client, and a real bwrap fetch of
pypi.org/simple/ at 44,841,256 bytes matching the host's sha256 3/3 with
the deny control still returning 403.
…ss defaults

Four held-back findings from the task-5 review, fixed together:

- allowHosts was inert: the settings route PatchSchema silently dropped it
  (zod strips unknown keys) and the CLI never exposed it. Both now accept it,
  reaching the already-working reactive proxy reload.
- cli/cmd/sandbox.ts's `network` choices still only listed "allow"/"deny",
  so `--network allowlist` was rejected and a user on "deny" had no CLI path
  back to the new default; the `as "allow" | "deny"` cast that hid this from
  tsgo is gone along with it. Status/help text updated to match.
- egressFor() and decide()/buildPolicy() answered "what does a missing
  enabled/network mean" differently in both directions, invisible from
  today's five fully-resolved production callers but live for any other.
  Sandbox.resolved() is now the one place both read from, with a regression
  test pinning each direction.
- ExecutionAuthority.Decision.sandbox.network is a second copy of the
  persisted enum Job.sandbox.network carries (via Job.authority) — recorded
  where both schemas live, including that ComputeJobs.read() fails the whole
  job history file on one unparseable record, not just that record.

Verified past the type system: PUT to the settings route and `sandbox enable
--network allowlist --allow-host` each drive a real sandboxed curl through
to an allowed host and a 403 off a disallowed one, and each is shown to have
been impossible before this change (silently dropped field / rejected CLI
choice) in the same isolated run.
Asserts allowlisted hosts reach 200, denied hosts do not, and — the
load-bearing pair — that direct egress with the proxy unset fails and
getent resolves nothing. Without those two the test would prove the
proxy works, not that it is the only way out.

Skipped where bubblewrap or curl is absent rather than failing.
curl's own -m budget on the volume test matched the outer bun:test
timeout exactly, with bwrap spawn and shim-readiness overhead layered
on top before curl even starts. A slow-but-working download would hit
the outer timeout first, trading curl's own diagnostic for a generic
one and deferring the proxy's cleanup until the abandoned promise
resolves. Also strips ALL_PROXY/NO_PROXY (and lowercase) alongside the
existing HTTP(S)_PROXY vars in the direct-egress subshell, so a host
exporting ALL_PROXY can't route that check through an unrelated proxy.
…chmod it shut

The egress socket was bind-mounted read-write, and the bind shares the host
inode: a sandboxed process could discover the path via /proc/self/mountinfo
and `chmod 000` it, which persists on the host and disables egress for
every kernel/terminal/job sharing this one process-lifetime socket.
--ro-bind blocks chmod (EROFS) while still permitting connect() — verified
live: a real bwrap run shows chmod failing with "Read-only file system"
while a plain client still gets a reply over the same bind, and (for
contrast) the same run with --bind reproduces the original vulnerability
end to end (chmod succeeds, the host-side connect then fails with
EPERM). Added a live regression test proving both properties together.
…s editor

The panel's network type, options, and default fallback only knew
allow|deny. Since allowlist is now the shipped default, every user landed
on a dropdown showing no current selection and offering only Allow/Deny —
picking Allow silently replaced the default bounded policy with
unrestricted egress, with no way back through the GUI. Widens the type,
the option list (labelled to make bounded-vs-unrestricted explicit), and
every allowlist|deny fallback to match the backend/CLI contract, and adds
an "Extra allowed hosts" editor (mirrors the existing writable-paths
pattern) since the backend and CLI both already accept allowHosts.

Verified with a real render: a Vite SSR-load + happy-dom harness mounts
the panel through the app's actual context stack (PlatformProvider,
ServerProvider, GlobalSDKProvider) against a real in-process HTTP server
implementing the GET/PUT /settings/sandbox contract, confirming an
allowlist config renders as "Allowlist" (not blank) and that opening the
dropdown and picking Allow round-trips a real PATCH and re-renders the
new selection.
…rontend types

- ADR-0002 said macOS "has no namespace equivalent, so this enforcement
  argument does not transfer," implying the bounded-egress outcome itself
  is unreachable. Seatbelt reaches the same outcome via a different
  mechanism — (allow network-outbound (remote tcp "localhost:PORT")),
  which anthropic-experimental/sandbox-runtime ships — so it's achievable
  but unimplemented here, not impossible. Rewrote the paragraph
  accordingly.
- frontend/docs/.../sandbox.mdx never mentioned allowlist: claimed network
  is "allowed by default", documented only allow|deny for --network and
  the config key, and showed a "network": "deny" example. All contradicted
  the shipped allowlist default; updated the quick-start prose, the flag
  table (added --allow-host), and the config example/field list.
- frontend/workspace/src/notebook/runtime.ts labelled anything not
  === "deny" as "Network allowed", so an allowlist kernel read as fully
  open. kernelNetworkLabel/kernelNetworkTone now distinguish all three
  states (allowlist gets its own "Network bounded" label and a middle
  tone; unrestricted "allow" escalates to the danger tone). Also widened
  the stale KernelEnvironment.sandbox.network type to match the backend's
  three-state contract (science/kernel/types.ts).
- frontend/workspace/src/atlas/execution-authority.ts still typed
  sandbox.network as allow|deny, mismatched with the backend Decision type.
Seatbelt has no network namespace to sever the way bubblewrap's
--unshare-net does, so the host-side proxy listens on a loopback TCP
port instead of a unix socket, and seatbeltProfile narrows the
profile to exactly that port: (deny network*) always precedes a
single (allow network-outbound (remote ip "localhost:PORT")), and a
missing or invalid port throws rather than silently falling back to
a plain deny (which would read as network:"deny", not "allowlist")
or, worse, an unfiltered allow.

backend()/decide()/plan()/wrapArgv() and EgressRuntime.ensure() take
an injectable platform (default process.platform) since no Mac
exists on this project to run sandbox-exec on — the darwin branches
are only exercisable from Linux by overriding it. EgressRuntime
starts an Egress.serveShim bridge (TCP loopback -> the existing unix
socket) when the resolved backend is seatbelt, and egressFor returns
that bridged port, stringified, instead of the socket path.

No shim, launcher, or bundle is composed on darwin — the sandboxed
process dials the loopback proxy directly, so none of bubblewrap's
namespace-bridging machinery applies.
… the unix socket, and must require Proxy-Authorization

The prior commit on this branch (macOS seatbelt support) implemented
Task 7's egress path in a way that violates both design decisions the
brief marked as already made:

1. It added a host-side `Egress.serveShim` bridge (TCP loopback -> the
   existing unix socket) running in the CLI's own process for seatbelt,
   instead of having `serveProxy` listen on TCP directly. The brief is
   explicit that this extra hop must not exist.
2. It shipped no authentication at all on that loopback TCP port. A
   unix socket's access control is its filesystem permissions; a
   loopback port has none, so every process on the machine could reach
   the allowlist proxy. The brief requires a `Proxy-Authorization`
   secret, generated per proxy start, checked before anything else
   about a request (even whether it's malformed) is inspected, with a
   407 and no forwarding on a missing or wrong one.

This replaces the bridge with a real fix: `Egress.serveProxy` is now
overloaded to listen directly on either a unix socket or a
`hostname`/`port`, with a `secret` required (and enforced) only on the
TCP form — so each call site still gets back the concrete
`UnixSocketListener`/`TCPSocketListener` its own input implies.
`EgressRuntime` generates a `crypto.randomUUID()` secret once per
seatbelt proxy start and returns `"<port>:<secret>"` as the darwin
`egressFor()` value (bubblewrap keeps returning the unix socket path,
unaffected). `Sandbox.buildPolicy` splits that back into
`Policy.port`/`Policy.secret`, and `plan()`/`wrapArgv()` embed the
secret as userinfo in the proxy URL
(`http://os:<secret>@127.0.0.1:<port>`), which pip, curl and requests
all parse into a `Proxy-Authorization` header.

Test coverage: egress.test.ts gets direct unit tests against the TCP
listener (binds 127.0.0.1 only; a correctly-authenticated request
reaches the dial; a missing or wrong secret gets 407 and never reaches
the allowlist check or the dial; the unix-socket listener is
unaffected, no auth required there). egress-runtime.test.ts's darwin
tests are rewritten for the new shape (no more socket/bridge fields;
hostname+port+secret; the same both-directions auth assertions one
layer up, through the real lifecycle). sandbox.test.ts's darwin
plan()/wrapArgv() tests now use a `"port:secret"` egress value and
assert the authenticated proxy URL, plus a new case for a port with no
secret (must throw, not silently compose an unauthenticated URL).

Linux/bubblewrap paths are unchanged: bubblewrapArgs, serveShim (the
in-namespace bridge that already existed for bubblewrap), and the
unix-socket half of serveProxy are untouched logic, confirmed by diff
against this branch's pre-Task-7 tip and by the full live egress and
bwrap-shim suites passing unchanged (test/sandbox/: 97 pass, 0 fail;
full suite: 1944 pass / 1 skip / 1 pre-existing unrelated fail).
… fail closed on a cached-wrong-platform proxy, fix two tests, pin the auth seam with real clients

Task 7 review, four Important findings:

I3 (the one that mattered most): seatbeltProfile emitted only
network-outbound, spelled (remote ip ...) — narrower than, and a different
filter type than, the reference implementation
docs/adr/0002-sandbox-network-policy.md:56-59 already cites
(anthropic-experimental/sandbox-runtime: network-bind/network-inbound/
network-outbound, spelled tcp, all on the proxy's loopback port). An
independently-guessed narrower profile that has never been measured against
a real sandbox-exec is exactly how "allowlist" ships silently unreachable on
every Mac. Now emits all three operations, tcp-spelled, matching the ADR's
literal quote; the doc comment states plainly that whether network-bind/
network-inbound are even needed, and whether local/remote is the right
filter pairing for them, are still open questions only a Mac can answer.

I1: egressFor's seatbelt branch interpolated running.secret with no guard —
over a proxy already cached under a different injected platform, this
composed the literal string "3128:undefined" (truthy, so a bare
toBeTruthy() check on the secret half couldn't catch it). Now throws,
naming which listener is actually running; added a regression test that
forces the ordering and a stronger UUID-shaped assertion on the happy path.

I2: a test asserted the opposite of what its own comment claimed to prove,
passing for the wrong reason (127.0.0.1 isn't in DEFAULT_RULES, so the
request was denied at the allowlist check, never reaching the dial the test
claimed to exercise). Fixed by allowlisting 127.0.0.1 before starting the
proxy, so the request now genuinely clears auth and the allowlist check.

I4: every auth test hand-built the Proxy-Authorization header, leaving the
seam between proxyUrl()'s format and serveProxy's parser unpinned. Three
new tests drive real curl (absolute-form and --proxytunnel) and Python
urllib at the exact URL Sandbox.plan() composes.

Also: M1 (stop logging the secret half on an invalid egress warning), M5
(reject ports above 65535), M6 (update the ADR's now-stale "seatbelt falls
back to deny" line — the one docs/ line authorized for this round), M7
(the report's Mac-owner verification commands referenced an unassigned
shell variable and instructed running them after the proxy that backed them
had already been stopped; folded into one runnable script). M2/M3/M4/M8
left as instructed, with a note on each in the report.

Hit the same Bun.spawnSync-blocks-the-proxy's-own-event-loop deadlock this
codebase already has a comment about (sandbox.test.ts's "Bun.spawn, not
spawnSync") while writing the I4 tests; fixed by switching to async
Bun.spawn before it shipped.

test/sandbox/: 97 -> 104 pass, 0 fail. Full suite: 1944 -> 1951 pass / 1
skip / 1 fail, the fail pre-existing and unrelated.
… (fix round 2)

Fix round 2's only code change: writes the one item from that round meant
to be recorded in a doc comment rather than only in the Task 7 report — if
the host proxy dies while a seatbelt-sandboxed child is still alive,
network-bind/network-inbound on that same ephemeral port would let the
child itself bind or listen there. Confined to the one port the profile
names, not a broader grant; a real, specific consequence of matching the
ADR's reference shape, not a hypothetical one, so it belongs next to the
other open Mac-only questions already in this comment.

Round 2's other finding (M7 — the report's own verification script failed
on line 1 because Bun resolves a relative import against the importing
file's location, not cwd, and the report told a Mac owner to save the
script somewhere that broke that) is a report-only fix: corrected save
location, then run verbatim from a clean shell on this machine as far as
it allows. Both recorded in the report under "Fix round 2 of 5"
(.superpowers/, gitignored, not part of this commit).

test/sandbox/: 104 pass, 0 fail, unchanged by this round. Full suite: 1951
pass / 1 skip / 1 fail, the fail pre-existing and unrelated.
Windows has no sandbox backend today, so kernels are denied there
outright. Every network-filtering option for it needs administrator
rights, and asking for elevation to create a local account and load
kernel network filters — in order to run AI-authored code — is
indistinguishable at the UAC prompt from malware.

This inverts the model instead. An AppContainer without a network
capability has no network at all, kernel-enforced and unprivileged; a
named pipe ACL'd to its package SID gives it one channel to a broker
that performs approved requests on its behalf. No firewall mutation, no
elevation.

The cost is that Windows becomes capability-mediated rather than
socket-transparent: no network capability means no loopback either, so
the shim that lets unmodified pip and requests work on Linux cannot
exist. A notebook cell cannot fetch a scientific API directly. That is
recorded as a decision rather than discovered later.

Nothing here has been executed — there is no Windows machine on this
project. Four things a Windows owner must confirm first are listed, and
one of them would change the design if it came back the other way.
Task 7 built seatbelt support (profile text, authenticated loopback
proxy) entirely from Linux, with the platform injected on every
assertion, since no Mac exists on this project. Add the live test and
CI job that let a real sandbox-exec finally run it:

- test/sandbox/egress-live-seatbelt.test.ts: real sandbox-exec, real
  TCP-loopback Egress.serveProxy, real remote hosts, wired the way
  Sandbox.plan composes them in production — no platform override, so
  Sandbox.backend()/decide() resolve for real. Asserts an allowlisted
  host reaches 200 through the proxy, a denied host does not, direct
  egress with the proxy env unset fails, DNS resolves nothing inside
  the sandbox, and an 18MB wheel survives byte-for-byte. Gated on
  Sandbox.backend() === "seatbelt": skips on Linux, must fail rather
  than skip on the one machine that can run it.
- .github/workflows/ci.yml: new `sandbox` job, matrixed over
  ubuntu-latest/macos-latest (no windows-latest — Sandbox.backend()
  is "none" there), mirroring `migration`'s shape and reusing `test`'s
  bubblewrap install/apparmor workaround for the Linux leg.
The macOS CI leg found five failures, four of them in tests and one real.

Real: `shimScript`'s readiness cap was an iteration count (150 polls at
0.02s), which only equals the documented 3s where forking `sleep` is
nearly free. A macOS runner measured 17.1s for the same loop — ~114ms per
iteration, ~94ms of it fork/exec — a 5.7x overshoot. Any host with
expensive process creation drifts the same way. The loop now carries a
`date +%s` deadline alongside the count, probed exactly like fractional
`sleep` so a build without `%s` degrades to today's count-only behaviour
rather than skipping the wait. Measured with a fork-dominated `sleep`:
18.4s before, 3.3s after.

Tests: four assumed the ambient platform was bubblewrap, an assumption
written before the darwin branch existed and false on a macOS runner.
Three called `EgressRuntime.ensure()` and got a TCP listener where they
wanted a unix socket; the fourth composed bwrap argv through
`Sandbox.plan()` and hit `seatbeltProfile` instead. All four now inject
the platform through the seam that already exists for it. The over-broad
egress cases were passing on darwin for the wrong reason — they threw
"requires an egress port", not "requires an egress socket path" — so they
now assert the message too.

Also: prettier on docs/specs/windows-sandbox-design.md.
Two paths that had never run anywhere — not in tests, not in the product.

**Source builds.** `source: true` was untouched while `explain()` actively
tells users "Retry with source builds enabled if a compiler and headers are
available". A user following our own error message would have been the first
to execute that path. Now covered with sgmllib3k, which is published as an
sdist with no wheel: it is refused under the default wheels-only policy —
with the translation saying a wheel is missing rather than pip's "no such
package" — and installs when source builds are allowed. A real sdist, built
inside the sandbox, through the allowlist proxy. The flag's effect is
observable rather than asserted from argv, which is why an sdist-only package
was worth hunting for.

**A real interrupt.** Every reconcile test used a process that exited
normally or a synthetic pid. Now one SIGKILLs a live process and watches the
same claim flip from running to unknown, which is the actual scenario the
claim/token machinery exists for: the CLI killed mid-install, and on restart a
claim pointing at a pid that is gone. It also asserts the claim is cleared, so
a later boot does not re-report a resolved one.

And the half nobody had checked at all: that an environment is still usable
after an interrupted install. A real scipy install is aborted mid-flight and a
subsequent install into the same environment is asserted to succeed. A tree
left wedged by an interruption would be worse than the interruption.

161 package tests, typecheck clean.
R has shipped entirely unverified. No CI runner has Rscript and neither does
any development machine on this project, so its live tests skipped
everywhere — and the two that existed asserted only FAILURE paths (an empty
library, a package CRAN does not have). Nothing anywhere proved an R install
could succeed at all.

The ubuntu sandbox leg now installs r-base-core, the minimal package
providing Rscript. Linux only: `brew install r` on the macOS leg costs
several minutes for a backend whose only platform-specific surface, the
sandbox wrapper, is already covered there by the Python tests.

Two tests added for the path that mattered. A real CRAN install of `praise`
— pure R, a few kilobytes, no dependencies, since CRAN serves Linux packages
as source and anything compiled would be testing a toolchain rather than
this installer — asserting the version comes back AND that it landed in the
environment's own library rather than a system or user one, which is the
whole reason `lib` is passed explicitly instead of trusting .libPaths()
ordering. And an additivity check mirroring the Python one, since the tool
decides whether to restart kernels from freeze() before and after.

I cannot run these locally; CI is their first execution, which is the point.
Two holes, the same missing piece at both ends.

**reconcile() had no production caller.** Built in the dispatch task, tested
thoroughly, and reached only by its own tests — so on a real restart after an
interrupted install, nothing checked. The claim/pid/start-token machinery was
dead code. It now runs in PackagePrompt.system(), which is the right place
rather than a convenient one: that is injected on every request, so the first
request after a restart resolves whatever the previous process left behind,
and the result lands where it can act — the agent's own capability block.
Cheap, because the directory is empty except while an install is in flight or
after one ended badly, and it self-clears.

**A failed detached install was swallowed.** `wait: false` returns immediately
and nothing awaits the promise, so `running.catch(() => undefined)` discarded
the error entirely: no manifest written, the claim released cleanly in the
`finally`, no trace anywhere. The agent had been told "started installing"
and could never learn otherwise — the exact failure the contract's "do not
claim an install succeeded" rule is trying to prevent, made unavoidable by
the implementation.

A failure now replaces the claim rather than deleting it, so one file remains
the single place an unfinished install is described whatever became of it,
and reconcile reports `failed` with the cause alongside `unknown` and
`running`. Both surface at the top of the capability block, above the
inventory, because an environment that only ever existed as a failed install
has no manifest — attaching the warning to an inventory row would put the one
case worth reporting in the one place with nowhere to report it.

164 package tests, typecheck clean.
… env owns

CI caught this, and it is the serious kind: `six==1.16.0` then `six==1.17.0`
reported as ADDITIVE, so kernels holding a stale six in memory were never
restarted. That is exactly the silent staleness the restart rule exists to
prevent, and it would have shipped.

I caused it with `--system-site-packages`. `freeze()` uses `--local`, which is
correct for the manifest — that should describe what the environment OWNS, not
the machine. But the tool was also using it for the additivity comparison, and
those are different questions. Requesting the version the host already
provides installs nothing locally, so the package is absent from the "before"
snapshot and the NEXT version reads as an addition rather than a change.
CI's runner ships six 1.16.0; this machine ships 1.17.0, which is why it
passed locally and failed there.

`resolved()` is added to both backends: every package the interpreter can
import, inherited ones included — what a kernel bound to the environment
actually sees. The tool now takes its before/after snapshots from that, and
keeps `freeze()` for the manifest and the reported total. Two questions, two
sources, neither doing the other's job.

The regression test discovers the host's own six version rather than
hardcoding one, since the bug only appears when the requested version MATCHES
the host's. It asserts both directions: the seen-set comparison says not
additive, AND the owned-set comparison still says additive — so the contrast
that produced the bug is pinned rather than described.

166 package tests, typecheck clean.
The one user-visible regression from the egress work, and it broke a real
feature rather than an edge case. When a job targets an SSH host, the ssh
CLIENT is wrapped by the sandbox — and under "allowlist" that means a severed
network namespace whose only exit is an HTTP proxy socket. ssh does not read
HTTP_PROXY and cannot use one; it needs ProxyCommand or SOCKS. So on the
shipped default policy, remote jobs did not fail closed with a useful
message, they failed with an opaque connection error.

Applying an HTTP allowlist to ssh is not bounded egress, it is denial. And it
buys nothing: the job's command runs on the remote machine, so the process
being confined here is a transport, not the workload. Filesystem containment
still matters and is kept — ssh reads keys and can write locally — so only
the network dimension is relaxed.

An explicit "deny" is deliberately honoured rather than relaxed. That is a
user saying no network, not a default they never chose; the difference
between relaxing a default and overriding an instruction is the whole reason
this is a two-line function rather than a boolean.

The job record now reports the policy actually applied, with a note saying
why. Reporting "allowlist" for a process running unconfined would be worse
than the original bug. The local-job branch still reports the requested
policy, correctly — nothing is relaxed there.

The decision is extracted as `transportNetwork` because `launch` is private
and reaching it needs a live SSH host; the logic is where a mistake would
hide, and it is now covered exhaustively.

229 compute + package tests, typecheck clean.
…works yet

Layer 1 of the Windows backend: everything that can be built and tested from
a machine that is not Windows. The same seam that let the seatbelt paths be
written from Linux — an injected `platform` — now resolves win32 to a new
"appcontainer" backend, so the composition is reachable and asserted.

`detected()` deliberately still answers "none" on a real Windows machine.
Nothing here can apply containment yet, and flipping the live probe first
would make `available()` true and have the product claim a sandbox it cannot
enforce — strictly worse than today's honest refusal to run kernels there. A
test pins that, by reading the probe rather than trusting the intent.

The structural problem this solves: Linux and macOS both have a wrapper
executable, so `wrapArgv` can return a plain spawnable argv. Windows has none
— AppContainer confinement is applied AT CreateProcess through
SECURITY_CAPABILITIES attributes, which no argv can express. Rather than
branching every caller's spawn path, the binary becomes its own launcher:
`openscience __appcontainer-launch <spec> -- <cmd>`, exactly the pattern
`__egress-shim` already uses at index.ts:54, needing no extra shipped
artifact per architecture.

The policy travels as one base64 blob rather than flags. Windows re-parses
command lines with CommandLineToArgvW rules that differ from every shell, and
paths there routinely carry spaces, quotes and backslashes; a blob with no
shell-significant characters cannot be mangled by them. The real argv still
follows a `--`, so the tail stays readable as on the other backends.

The profile name is derived from the workspace and hashed. It has to be
stable across runs, because the package SID comes from it and filesystem ACEs
and the broker pipe's DACL refer to that SID — a fresh name per launch would
strand every ACE the previous one granted. It has to be distinct per project,
or one project could read another's granted paths. And it has to be hashed,
because a profile name is length- and charset-limited while a workspace path
carries separators, drive letters and spaces.

Writing the tests found a real bug before any of this ran. buildPolicy pushes
`egress` through `dedupe()`, which path.resolve()s it — turning the pipe NAME
`openscience-broker-abc` into an absolute path under the current directory,
so the launcher would have asked for a pipe nobody serves. AppContainer now
takes its own branch, for exactly the reason seatbelt already has one: its
egress identifier is not a filesystem path.

Backend is a four-state enum now, widened at the five places that narrowed it
— two zod schemas and three frontend types.

684 backend tests, 79 frontend, typecheck clean. Layers 2 (the FFI launcher)
and 3 (the named-pipe transport) cannot be verified anywhere but a Windows
machine, and are not attempted here.
The probe was already current and already measured the open question — the
environment-survival canaries have been in it since the corrected version.
What failed last time was the reporting: the run was relayed by pasting
console output, the terminal had truncated it from the top, and the block
that scrolled away was envSurvival — the one thing that run was needed for.

So it now writes the full bundle to probe-report.json beside the script,
deliberately NOT into the temp directory cleanup removes, and says so. The
failure branch writes one too: a run that produces no usable result is
exactly when the launch stage, Win32 error and marker state matter most, and
that path previously left nothing behind at all.

The runbook is on the Desktop rather than only in the repo, because it is
read on the machine being tested and that machine has no checkout. It states
what to run, what each answer would mean, and — as importantly — what is
already settled and must not be re-tested, so a second run spends its time on
the two things still unmeasured rather than re-confirming ten that are not.

Written with CRLF: it will be opened in Notepad as often as anything else.
Both files pure ASCII, since PowerShell 5.1 reads a BOM-less .ps1 as ANSI and
a single typographic dash breaks parsing hundreds of lines from the cause.
**The installer trusted `Bun.which` without verifying the interpreter runs.**
A default Windows install carries python3.exe and python.exe in WindowsApps as
App Execution Aliases: zero-byte reparse points that open the Microsoft Store.
`which` finds one, `python -m venv <dir>` exits cleanly having created
nothing, and every subsequent install then failed with "Executable not found
in $PATH" naming ...\envs\<project>\default\Scripts\python.exe, with nothing
in the message explaining why the environment was empty. The agent read that
as a platform outage and told the user to get their OpenScience install
repaired.

`findPython` in the notebook tool has verified with `--version` all along;
this path had drifted from it. Same check now, plus `create()` asserts the
interpreter actually exists afterwards rather than trusting exit code 0 — and
on Windows the message names the alias and how to turn it off, because a path
that does not exist explains nothing on its own.

**`sandbox status` claimed confinement that did not exist.** The sentence
keyed off `enabled`, which describes the CONFIG, so a Windows machine with
`Sandbox.backend()` of "none" was told "agent shell commands are confined to
the workspace" while nothing confined anything. Three states now, not two: the
claim requires a backend to exist. A false statement about a security property
is the worst thing this command can print.

**Windows consoles mojibake our em dashes.** "unavailable — no sandbox
backend" arrived as "unavailable ΓÇö ...", a UTF-8 em dash decoded as the OEM
code page. Everything reachable on a backend-less machine — exactly the
Windows path — is now ASCII, with a test enforcing it and a comment marking
where the boundary is, since the tick and cross glyphs further down only print
when a backend exists.

419 package + tool + sandbox tests, typecheck clean.
Layer 2. Windows applies confinement AT process creation, through
SECURITY_CAPABILITIES passed to CreateProcessW, so there is no wrapper
executable to exec — the binary launches itself and this module does the Win32
work. Every call mirrors the probe that already ran this sequence on a real
Windows 11 machine, unelevated, and measured it working.

The FFI patterns were verified before being written. `bun:ffi` was exercised
against libc on Linux to confirm an out-parameter pointer reads back through
`read.ptr` and that bytes at a returned pointer come back through
`toArrayBuffer` — the mechanism is identical, only the library differs. My
first draft used API that does not exist; checking rather than assuming caught
it before it shipped to a machine I cannot debug interactively.

`detected()` now resolves win32 through `AppContainer.usable()`, which loads
the DLLs and derives a SID. Side-effect free, so probing a machine we end up
not sandboxing leaves nothing behind, and it fails closed to "none" — the
behaviour Windows had before this existed. It deliberately does NOT prove the
launch works; that is verified at first use, where `launch` throws with the
Win32 error rather than degrading silently. Returning "appcontainer" merely
because the platform says win32 is how a product claims a sandbox it never
applies.

Command-line quoting is the part most likely to be silently wrong, so it is
the part most heavily tested. Windows has no argv: CreateProcess takes one
string and CommandLineToArgvW re-splits it under rules that are neither the
shell's nor POSIX's, where backslashes are literal except before a quote. A
path like C:\Users\me\My Project\ can quietly change what the child executes
rather than failing. The test re-implements the documented parsing algorithm
and round-trips a real managed-environment interpreter path through it.

The Layer 1 test asserting the probe never says "appcontainer" was correct
while no launcher existed and is now wrong, so the invariant moves rather than
disappears: win32 must resolve THROUGH the capability probe and fall back to
"none" when it fails.

icacls rather than SetNamedSecurityInfo for the workspace grant: it ships with
Windows, takes a SID in the *S-1-... form directly, and a shelled command that
fails is far easier to diagnose than a marshalled ACL that silently grants the
wrong thing. Grant failures are reported, not thrown — the command should fail
visibly at the write it cannot make, not vanish behind a launcher error.

637 tests, typecheck clean. Nothing here has executed on Windows; that is the
next step, and the launcher is written to fail loudly rather than quietly when
it does.
…e judging it

The first Windows run of the launcher produced two contradictory outputs from
the same function. `sandbox status` said "unavailable - no sandbox backend for
platform win32"; `sandbox test`, one command later, printed "Sandbox self-test
(appcontainer)" and ran checks. Both read backend().

describe() carried a seatbelt/bubblewrap whitelist, so widening the Backend
type without widening it here dropped "appcontainer" into the "none" branch.
The probe had in fact succeeded — the FFI bindings resolve and Windows detects
a backend for the first time — and the status command was hiding it.

The self-test then reported containment FAILED: writes escaped the workspace
and egress worked under deny. Those are the symptoms of a child that never
entered the container at all, which is indistinguishable from a container with
no policy applied when all you can see is exit codes. The probe already
measured that a real AppContainer child cannot do either.

So the self-test now reads the child's own token first, via `whoami /groups`:
an AppContainer token carries its package SID. That separates "CreateProcess
succeeded but SECURITY_CAPABILITIES did not take effect" from "it is confined
and the policy is wrong" — different bugs, in different files, and currently
indistinguishable. It runs before every other check and short-circuits them,
because reporting "containment failed" when the real fault is upstream of the
policy sends the next hour to the wrong place.

runAsync captures stdout for it. It previously discarded stdout entirely,
which is why no check could ever have looked at what a sandboxed process
actually said.

One of the new tests failed on its first run for its own reason: it sliced the
source from selfTest to runAsync, but runAsync is defined ABOVE selfTest, so
the range was empty and the assertions were passing on nothing.
… failures

A Windows run created an environment at <env>/lib/python3.9/site-packages with
<env>/bin/python.exe, while every path in this module looks under Scripts\.
pyvenv.cfg named the cause: home = C:\msys64\mingw64\bin, version = 3.9.7.

MSYS2's MinGW Python is a native Windows build that patches sysconfig to the
POSIX scheme. It was selected because PATH had no python3.exe before
C:\msys64\mingw64\bin -- C:\Python312 ships python.exe only -- so the
`python3 ?? python` preference walked past a valid 3.12. python.org ships no
python3.exe at all, which makes that order wrong on Windows specifically:
python3 there resolves to the Store alias or to a POSIX-flavoured distribution
nearly by definition. Reversed on win32, unchanged elsewhere.

Selection now validates before creating anything. Candidates come from the py
launcher where it exists (it was absent on this machine, so it is a source and
not a requirement) and from every PATH match rather than the first -- Bun.which
answers once, so a single bad early hit hid every valid interpreter behind it.
Each candidate is probed for sysconfig platform and purelib, and rejected on
the property that actually breaks us: a non-nt layout. That disqualifies
Cygwin and any future cross-built oddity by behaviour rather than by vendor.

Creation now verifies instead of assuming. It searches both layouts, executes
what it finds, and asserts sys.prefix equals the environment root. Anything
short of that deletes the tree and raises -- a half-built environment poisoned
every retry after it, because venv and uv both short-circuit on an existing
directory and report success without replacing what is missing. The observable
was "Requirement already satisfied" for pip and setuptools on every attempt,
followed by the identical failure.

The old message asserted that Windows failures "usually mean" a Microsoft Store
alias. That was false here -- a real CPython ran and ensurepip completed -- and
reading as a finding rather than a hypothesis it cost a full debugging cycle.
It now reports only measured facts: the interpreter used and what it reports,
the exit code, where an interpreter was actually found, and what is on disk.
…at failed

The Windows self-test reported "no package SID in the child's token", which the
message read as CreateProcess having succeeded without applying
SECURITY_CAPABILITIES. The token was never read at all.

launch() passed bInheritHandles: false and set no STARTF_USESTDHANDLES. The
launcher is spawned with its stdout on a pipe, so a child that inherits nothing
has nowhere to write: `whoami /groups` produced no output, the check tested an
empty string for a package SID, and reported the container as not applied. A
launcher bug wearing a policy bug's clothes.

This was never test-only. Every sandboxed command's output crosses that
boundary -- pip's progress, a bash tool's result, a kernel's stream -- so on
Windows they were all silently empty.

The child now inherits our std handles: each is marked inheritable with
SetHandleInformation first, because inheritance is a property of the handle in
this process and the ones we were handed are not necessarily marked for it. The
flag is only set when there are handles behind it; with it set and a null
handle the child would get no stdout at all, which is the same failure by
another route. Without handles it attaches to our console, which is the right
fallback when we have one.

The check no longer asserts a cause it did not measure -- the same defect just
fixed in the installer's error message. Silence and an uncontained token are
different failures with one observable, so it now names which occurred, and
carries the child's stderr, where the launcher's own error names the Win32 call
that failed.

Whether SECURITY_CAPABILITIES takes effect is still unproven: the evidence that
was supposed to answer it never made it back. This makes the next run legible.
… keys

The Windows self-test kept reporting a containment failure. It was measuring
nothing. Sandbox.plan composed [shell, "-c", command]; the shell resolves to
cmd.exe on that machine, and cmd does not reject -c, it starts an INTERACTIVE
shell. Every sandboxed command printed the cmd banner and a prompt, ran nothing,
and exited 0 -- so `whoami /groups` never ran either, and the token the check
read was the banner. Whether SECURITY_CAPABILITIES works is still unknown.

session/prompt.ts has always known cmd takes /c, but its table is declared
inside a function and closes over the command, so it could not be reused and
the sandbox kept its own wrong copy. Shell.invocation is now the single source
of truth. It splits on both separators and drops .exe unconditionally rather
than branching on process.platform, so the Windows answer is reachable from
Linux CI -- the machine that exposes this is not one the suite can run on.

filterEnvForKernel compared env keys exactly. Windows presents Path, SystemRoot,
windir and ComSpec, so the allowlist matched none of them and every kernel and
launcher on Windows ran with no PATH and no SystemRoot. Matching is now
case-insensitive, which only widens on the platform whose env keys are already
case-insensitive, so it was never a boundary. Original casing is preserved. The
secrets test asserts the negative case: folding case must not open a hole. This
is the likely cause of `CreateProcess ... Win32 203` (ERROR_ENVVAR_NOT_FOUND) --
likely, not proven, and the next run decides it.

tempDirs added "/tmp" unconditionally, which resolves to C:\tmp on Windows and
does not exist, so icacls failed and every command carried a grant warning that
buried the real errors. It now takes a platform, threaded from plan/wrapArgv,
so the Windows policy is testable from Linux.

findPython trusted the environment interpreter on an exists() check alone. A
venv's Scripts\python.exe is a REDIRECTOR that resolves its base interpreter
from pyvenv.cfg at startup; when that fails the file still exists, so the kernel
got a binary that cannot start. `No Python at '...'` was the redirector's own
message -- absent from this repo and from the shipped binary.

Writing that test found a defect in the fix: Bun.spawn THROWS on a file that
exists but is not executable, so verification would crash rather than fall
through. Guarded there and in inspect()/which(), where the same hole would abort
the interpreter search at the first bad PATH entry instead of moving past it.

Also: `sandbox status` said "confined to the workspace" on a run whose next
command failed containment; it now reports which backend is applied and names
`sandbox test` as the proof. The CreateProcess error explains 203, the code
actually hit. CREATE_UNICODE_ENVIRONMENT is dropped -- lpEnvironment is null.
The last Windows run showed the /c fix working -- cmd executed, and `echo a; b`
echoed the whole line back, which is exactly right for cmd. The C:\tmp warning
is gone. What surfaced underneath was `dir` returning "Access is denied" and a
venv redirector reporting `No Python at '"C:\Python312\python.exe'`.

That is not a broken machine. It is the AppContainer denying reads, which is
arguably the sandbox working -- against us.

policy.readable never reached the launcher. appContainerArgs carried only
writable and unreadable, and buildPolicy's appcontainer branch dropped readable
before that. It went unnoticed because `readable` is a no-op on the other two
backends: bubblewrap binds the whole filesystem read-only and seatbelt allows
reads unless denied, so a path that only needs reading is already reachable
there. An AppContainer reaches nothing whose ACL does not name its package SID,
so the kernel could not read its own interpreter.

readable now travels in the spec, and grant() applies (OI)(CI)(RX) to it --
read AND execute, because a venv's Scripts\python.exe is a redirector that must
start the base interpreter, so plain (R) is not enough; never (F), which would
hand a sandboxed process write access to the Python installation it is confined
away from. Paths already in the write set are skipped rather than granted twice.

A venv is not a complete Python: it delegates to the installation named by
pyvenv.cfg's `home`. Installer.base reads that value rather than guessing it,
and the kernel and installer both add it to readable.

Adding it unconditionally broke three green Linux installs:
  bwrap: Can't mkdir .../uv/python/cpython-3.12-linux-x86_64-gnu/bin
Naming a path bubblewrap already provides is not redundant, it is a bind whose
destination cannot be created under a read-only root. Installer.baseReadable
gates it to win32, with a test pinning both halves.

Still unmeasured: whether SECURITY_CAPABILITIES takes effect. `sandbox test` has
not been run since the shell fix, so the token check has still never read a real
token.
`sandbox test` finally measured the thing six rounds of fixes were clearing the
way to: the child RAN, produced output, and its token carries no package SID,
with no launcher error. CreateProcess succeeds and SECURITY_CAPABILITIES does
not take effect. Every earlier "containment failed" was a different bug wearing
this one's clothes; this is the real one.

Reading our sequence against the probe's line by line, the constants and the
layouts agree: 0x00020009, cb = 112, lpAttributeList at 104, the 24-byte
SECURITY_CAPABILITIES with a null capability array. What differs is memory
ownership. C# allocates with AllocHGlobal; we hand the kernel pointers into
JS-owned buffers, and UpdateProcThreadAttribute stores a POINTER to the
capabilities struct inside the attribute list rather than copying it.

Two things that could each produce exactly this silent no-op, both closed:

dlopen returns a library object that owns the handle, and only .symbols was
kept, so the object was garbage immediately -- Bun closes a library when it is
collected. main() bound three times over one launch and launch() opened
advapi32 a fourth. Now bound once and cached for the process.

The attribute list, the capabilities struct and the command line are held in an
explicit keep-alive until after CreateProcess, rather than relying on being in
scope.

Neither is confirmed as the cause, so the launcher can now dump what it hands
the kernel: OPENSCIENCE_SANDBOX_DEBUG=1 prints the SID pointer, the attribute
list size and address, the capabilities bytes, the whole STARTUPINFOEX with cb
and lpAttributeList called out, the command line, and the CreateProcess result.
`sandbox test` keeps the full dump instead of its first line under the same
variable. One run now discriminates, instead of one rebuild per hypothesis.
The debug dump settled it: the launch is correct. From a real machine, decoded
field by field -- cb 112, dwFlags 0x100 (STARTF_USESTDHANDLES), lpAttributeList
0x22e2f58c160 matching the allocated list, and a 24-byte SECURITY_CAPABILITIES
whose AppContainerSid is exactly the pointer ConvertStringSidToSid returned,
with Capabilities NULL and CapabilityCount 0. UpdateProcThreadAttribute
returned true, so the kernel ACCEPTED attribute 0x00020009 and that struct.
CreateProcess returned true.

Nothing we hand Windows is wrong.

And the container is evidently live: `dir` returns "Access is denied" and the
venv redirector cannot read C:\Python312. An unconfined child of the user's own
shell would do neither.

So the check was wrong, not the launcher. The package SID lives in the token's
TokenAppContainerSid, which is NOT a group -- `whoami /groups` need not print
it, and testing for that substring can report "unconfined" for a container that
is working. Integrity is the reliable half: every AppContainer runs at Low
(S-1-16-4096), where a plain child of this process inherits Medium
(S-1-16-8192). Either signal now proves containment, and a pass reports which
one proved it rather than asserting the conclusion bare.

Under OPENSCIENCE_SANDBOX_DEBUG the check also prints the child's token instead
of only a verdict about it. Reporting a judgement on output nobody can see is
what made a working container read as broken for two rounds -- the same mistake
as the Store-alias message and the SECURITY_CAPABILITIES message before it, in
a third place.

This does not yet prove containment holds; it removes a detector that could not
have observed it. The write/network checks behind it now get to run.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants