Skip to content

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

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

Governed package installation for kernels, on bounded network egress#281
KB (KB-syntheticsciences) wants to merge 14 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.

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openscience Ready Ready Preview Aug 15, 2026 5:56pm

Request Review

Comment thread backend/cli/test/package/installer-r.test.ts Fixed
KB (KB-syntheticsciences) added a commit that referenced this pull request Aug 12, 2026
CodeQL flagged `source.includes("cran.r-project.org")` as
js/incomplete-url-substring-sanitization, high severity — the only check
blocking #281. A false positive: `source` is this repo's TypeScript, not a
URL, and nothing sanitizes anything.

Fixed by removing the reason to grep for a domain at all. The index is now an
exported constant, so the test asserts it by equality. That is the better
design regardless: one named value decides where R packages come from,
instead of a literal buried inside a generated R script.

Added the check that was actually missing — that the index host is in
Egress.DEFAULT_RULES. Changing REPO without updating the allowlist would make
every R install fail closed against the proxy, and nothing caught that
before.
@KB-syntheticsciences KB (KB-syntheticsciences) changed the title Governed package installation for kernels Governed package installation for kernels, on bounded network egress Aug 12, 2026
…cy sandbox

Squashed from 127 commits on feat/kernel-package-install. The individual
messages are preserved on backup/pre-rebase-20260815-1404; what follows is what
a reader of main needs.

A three-state sandbox network policy — `deny`, `allowlist`, `allow` — and a
`package_install` tool that provisions a managed Python or R environment, asks
the user, and installs into it with egress bounded to the package indexes.

Egress under `allowlist` goes through a host proxy that enforces the host
allowlist and a `Proxy-Authorization` secret. How the sandboxed process reaches
that proxy differs per platform, and each route was measured before it was
written:

  bubblewrap  a bind-mounted unix socket, with a loopback shim inside the
              namespace, bundled so it resolves nothing from disk
  seatbelt    the proxy's TCP port directly; the secret travels as userinfo
  appcontainer  a named pipe whose DACL names the container's package SID,
              relayed by a host broker into the same proxy

Linux and macOS are complete and proven in CI, including a live `pip install`
under `allowlist`.

Windows is a new backend built for this branch. Proven on a real kernel in CI:
`TokenIsAppContainer=1`, writes inside the workspace succeed, writes outside are
refused, `deny` severs the network, `allow`'s capability SIDs work, the broker
pipe is openable only by its own container, and egress carries real traffic —
pip resolves against pypi.org and follows the index to files.pythonhosted.org.
The install itself is NOT yet green; see "known open" below.

  - MIC is evaluated before the DACL. Every AppContainer runs at Low, so a
    granted workspace is unwritable until it is labelled Low as well. Several
    rounds of shell-quoting fixes were chasing this.
  - An ACE on a reparse point is not an ACE on its target. uv keeps a
    patch-versioned interpreter directory and a stable name beside it, and
    writes the stable name into pyvenv.cfg — so the grant landed, icacls exited
    0, and the container still could not start Python.
  - Windows can only grant paths the user owns. A machine-wide Python can never
    be read by a sandboxed process without elevation, which is out of scope, so
    the prerequisite is surfaced in settings and to the agent rather than
    failing later as a broken interpreter.
  - AppContainer isolation is bidirectional: the host cannot reach a listener
    inside the container either, which is what makes the pipe necessary rather
    than merely convenient.
  - `launch` blocks the event loop on WaitForSingleObject through FFI, so any
    relay living in the launcher process stops for the payload's lifetime.

  - The Windows install is red: pip downloads, then cannot write into a
    pre-created subdirectory of the granted workspace. Under investigation as
    MIC label propagation to existing children.
  - `tempDirs()` grants the container the shared host temp and labels it Low.
    That is too broad and wants narrowing to a per-run directory.
  - No readiness wait between the Windows shim and the payload; it currently
    survives on pip's retries.
The rebase silently broke Linux egress, which was the one platform that fully
met the merge gate. Three separate causes, all the same shape - main's model
mounts nothing by accident, so anything that used to be visible has to be named.

  - `bubblewrapArgs` stopped consuming `Policy.readBind`. main's rewritten body
    never had that loop and taking it wholesale dropped it.
  - `readBind` was still filtered to /tmp paths. Correct under `--ro-bind / /`,
    where only what `--tmpfs /tmp` masked needed rebinding; fatal under an empty
    root, where it discarded the shim's launcher, its bundle and the interpreter.
  - The shim's LEXICAL path was never bound. Global.Path.bin reaches the data
    root through a symlink, so the path interpolated into the shim script is not
    the one that resolves on the host. main's mountAliases exists for exactly
    this; the shim's paths are added after buildPolicy runs, so they have to be
    aliased where they are added.

Nothing reported any of it. The shim is backgrounded to /dev/null, so the only
symptom was every allowlisted request returning "Could not connect to server".

`wrapArgv` also lost its shim composition entirely in the merge, which left
every notebook and R kernel with no egress under "allowlist" - the exact route a
package install from a kernel depends on. Restored, with the same aliasing.

egress-live is green again: a real allowlisted GET, 18MB byte-for-byte through
the proxy, and a real pip install that imports.
…rk default

Two regressions this rebase introduced, both caught by main's own tests.

`Sandbox.resolved()` still defaulted network to "allowlist" while
Config.trustedSandbox now defaults to "deny". Every caller that omitted the
value resolved to allowlist here and then threw in bubblewrapArgs for want of an
egress socket nobody had asked for. The two defaults have to agree, and failing
closed is the right one for a value that decides whether a sandboxed process
reaches the network.

Worse: dropping readBind's /tmp-only filter left it holding every readable root,
and the bind loop runs AFTER the unreadable masks - so a --ro-bind-try of a
directory shadowed the /dev/null mount inside it and re-exposed the file the
mask had just covered. main's symlink-escape test found it by reading a masked
file successfully. main already binds readable roots itself, before the masks,
so readBind now carries only what plan()/wrapArgv() add: the shim launcher, its
bundle and the interpreter. Those are individual files, so the shadowing has no
trigger rather than an unlikely one.
…olver

main brought its own `environment` concept — a NAME resolving to `.venv/<name>`,
validated by KernelEnvironmentName and resolved in `pythonEnvironment`. This
branch had its own — a managed DIRECTORY under the cache root that
`package_install` provisions. Taking main's tool schema during the rebase left
the managed side unwired: nothing bound a kernel to it, so the merge gate had no
executable assertion left.

They compose rather than compete. `pythonEnvironment` now consults the managed
environment first, because it is the only one this product creates and installs
into, then `.venv/<name>`, then the conventional fallbacks. Only the managed one
is returned as `environment`, since that is the directory the sandbox has to be
granted; a project's own .venv is already inside the workspace.

The Instance lookup is guarded: `pythonEnvironment` is reachable with no project
bound, and throwing there would turn "no managed environment" into a crash.

R lost its environment parameter entirely when the conflict took main's side.
Restored, using KernelEnvironmentName rather than the bare string this branch
had — it bounds length and refuses path separators, so a name can never address
a directory outside the two places the resolver looks.

The binding tests now assert main's mechanism. It keys the kernel identity by
environmentName, where this branch carried a separate `boundEnvironment`; both
give "a different environment gets a different kernel", and main's is the one
that is load-bearing now.
… a mutation

Two behaviour bugs the rebase exposed, plus the test updates main's changes
require.

`pyvenv.cfg`'s `home` is `<prefix>/bin` on POSIX, so granting it alone left
`<prefix>/lib` — the whole standard library — unreachable. A
python-build-standalone interpreter then falls back to its baked-in build
prefix and dies before it can say why:

    sys.path = ['/install/lib/python312.zip', '/install/lib/python3.12', ...]
    Fatal Python error: init_fs_encoding: failed to get the Python codec
    ModuleNotFoundError: No module named 'encodings'

Invisible under `--ro-bind / /`, because the tree was there whether or not
anyone asked. `Installer.baseRoots()` now names both, on the install path and
the kernel path, and adds the parent only when the leaf is the POSIX `bin` —
on Windows `home` IS the prefix.

`boundEnvironment` was left null after the merge, so `restartEnvironment`
matched no kernels and a non-additive install kept every bound kernel alive
holding stale imports — while the tool's own output said they had restarted.

The merge gate now states its policy instead of reading the ambient default and
asserting it happened to be allowlist. That assertion broke the moment the
default became main's "deny", and would have passed vacuously if the default had
drifted the other way; the gate is "an install works UNDER allowlist", so the
policy belongs in the test. Its global config write is undone afterwards,
because that write outlives the test process-wide.

The Windows capability probe is restored to selfTest, gated to the AppContainer
backend: seatbelt and bubblewrap now deny every socket in every mode, so
asserting that "allow" reaches the network would demand a capability those
platforms deliberately do not offer.
… the rebase corrupted

`network: "allow"` denies every socket on bubblewrap and seatbelt, and nothing
told anyone. `sandbox status` printed "allow", the settings panel showed it
selected, the schema said "whether sandboxed commands may reach the network" —
and every connection failed to resolve with no path from the symptom back to
the setting the user had deliberately turned on. The behaviour is right: neither
backend can grant outbound access without also exposing everything bound to
127.0.0.1, so both fail closed. A config value that reports one thing and does
another is still a bug.

Disclosed on all three surfaces, through the one-time `warning` channel the
Windows loopback gap already uses: a warning on first use, a note beside the
status line, and honest copy in the settings panel and the docs. "Allow" is no
longer labelled "unrestricted egress" — it is unrestricted only where the
platform can express that, which today is Windows alone.

Sandbox.tsx and sandbox.mdx were also rebuilt from main. Taking "ours" for their
conflicts had concatenated both sides rather than merging them: the panel was
750 lines against main's 543 and this branch's 365, with duplicated Show blocks
and a JSX tree that no longer closed, and the doc carried two copies of its
own structure under different headings. Both now start from main's version with
this branch's additions ported onto it — the three-state network control, the
allowHosts field, and the prerequisite banner, which sits above every control
because a green backend beside "installs will fail" reads as a contradiction.
…wallowed

`gh workflow run` refused the file outright:

    failed to parse workflow: No steps defined in `steps` and no workflow
    called in `uses` for the following jobs: macos-runtime

Resolving ci.yml's conflicts by concatenating both sides left main's
macos-runtime job with a `runs-on` and nothing under it — the `sandbox` job
this branch adds had taken its place. Same shortcut, same failure, third file:
Sandbox.tsx and sandbox.mdx were rebuilt for it in the previous commit.

Checked the rest rather than waiting to be told: every other file resolved that
way is genuinely merged, with no duplicated function or test names. Only these
three took a whole side without being read.
…g else

`readable` means two different things per backend, and the rebase let main's
meaning reach the one where it is dangerous.

On POSIX it is "what may be read" — derived from runtime roots, the workspace
and the writable set — and binding those into a namespace costs nothing. On
Windows every entry has an ACE written to it with `icacls`. The appcontainer
branch overrode it, but conditionally:

    ...(granted.length ? { readable: granted } : {}),

so whenever nothing explicit was named, main's derived set came through the
spread and the launcher set about rewriting the ACLs of every directory on
PATH:

    could not grant sandbox access to C:\Windows\System32: Access is denied.
    could not grant sandbox access to C:\Windows: Access is denied.

Unelevated it only failed, slowly - 117 seconds of icacls calls before a
trivial `exit 7` gave up, which is how CI found it. Elevated it would have
SUCCEEDED, and left an AppContainer holding standing access to the system
directories.

Now unconditional, so empty stays empty, with a test that builds a spec through
wrapArgv and asserts the count both ways.
The Windows install "passed" with `egress: <none>` — the test read the ambient
policy, which now defaults to disabled/deny, so it ran UNSANDBOXED and proved
nothing about the path the job exists to exercise. It states its policy now,
the same fix the merge-gate test already got.

And the real bug it was hiding is finally measured rather than inferred. A
staged check proved it directly: a directory created BEFORE the launch is
unwritable inside the container while one created inside it is fine. `icacls
/setintegritylevel (OI)(CI)L` labels the granted root and does NOT propagate to
subdirectories that already exist, and Mandatory Integrity Control is evaluated
ahead of the DACL — so `<env>/.tmp`, which install() pre-created on the host for
pip, could never be written to. pip downloaded a wheel and then failed unpacking
it, which read as a transport fault for several rounds.

So install() no longer creates or names a scratch directory. `spec.env` already
carries TMPDIR/TMP/TEMP for the sandbox's own per-spawn temp root, which is
granted and labelled as a root rather than inherited as a child. That mechanism
came from main in this rebase; using it is both the fix and the reason it exists.
…t resolves to

With the policy finally stated rather than inherited, the Windows install ran
genuinely sandboxed for the first time and failed inside pip:

    PermissionError: [WinError 5] Access is denied:
    '...\uv\python\cpython-3.12-windows-x86_64-none'

The grant log shows why: only `cpython-3.12.14-windows-x86_64-none` carried an
ACE. uv keeps a patch-versioned interpreter directory with a stable name linked
beside it, and `pyvenv.cfg` - hence `sys.base_prefix` - names the stable one.
main's `dedupe` canonicalises policy paths, so the reparse point was resolved
away before `grant()` saw it, and `grant()`'s own resolve step then had only the
target to work with. The name Python actually uses never got an ACE.

On Linux the lexical spelling is restored by a mount alias, which is how the
same defect was fixed for the egress shim earlier in this rebase. Windows has no
such indirection: a name is reachable only if that exact name carries an ACE. So
the appcontainer branch keeps the caller's spelling and lets `grant()` add the
resolved form beside it.

Both halves are now needed and both are present, which is the whole lesson of
this reparse point: it has produced three separate failures - the trampoline, the
grant, and now the stat - each looking like something else.
…conclusion

Two corrections and one diagnostic.

The pre-existing-subdirectory check now PASSES, so the conclusion drawn from its
earlier failure was wrong: the Low integrity label DOES reach a subdirectory
that already existed. What actually broke was the over-broad `readable` list
leaking main's derived read roots into the Windows grant list, which sent icacls
at every directory on PATH and failed on the system ones. That was fixed in the
previous commit; this check went green with it. The note in the test is amended
rather than deleted, because the reasoning is still worth reading and being
wrong about it once is the point.

`Installer.install` now runs with a bounded signal. It buffers stderr and prints
it only when it RETURNS, so the last run produced five minutes of silence and
not one line about why it hung — the same blindness that already cost this
feature a round.

And the streamed test no longer pre-creates a TMPDIR. install() stopped doing
that a commit ago; the test that reconstructs install() kept doing it, which is
exactly how a reconstruction stops proving anything about the thing it stands in
for. The sandbox's own per-spawn temp is granted and labelled as a root, and
both paths use it now.
…ng past its abort

Two fixes. The second is why the first took so long to find.

Windows installs now go through `uv pip install`. Not a preference — a
workaround for an upstream defect with a name. CPython 3.12.4 changed
`os.mkdir(mode=0o700)` so `tempfile.mkdtemp()` creates a directory whose DACL
does not inherit from its parent; inside an AppContainer access comes from the
package SID, so a DACL naming only the owner grants the process nothing. pip
downloads a wheel and then cannot write into the `pip-unpack-*` directory it
just created:

    [Errno 13] Permission denied: '...\pip-unpack-xxxx\six-1.17.0-...whl.metadata'

python/cpython#134587, fixed upstream but unreleased. uv is Rust and never
touches CPython's tempfile.

Windows only, deliberately. Linux and macOS install correctly today and are
verified doing so on every push; changing their installer inside a fix for
someone else's Windows bug would touch working, security-relevant code for no
reason. uv is not optional on Windows anyway - it is how a grantable interpreter
gets there at all. uv's binary joins the read set because an AppContainer can
execute nothing its SID is not granted, and UV_CACHE_DIR points at the same
writable wheel cache rather than %LOCALAPPDATA%.

And `install()` no longer hangs past an abort. `signal` kills the launcher, but
the sandboxed child inherits its stdio and is not a child of this process at all
- on Windows it is created by CreateProcessW inside the container - so it holds
the write end of both pipes open and `reader.read()` never reports done. A 150s
AbortSignal produced no return and the caller sat to its own 300s timeout,
twice, each time discarding the log that would have explained the failure. Now
it waits on the process and gives the drains a bounded moment to flush, keeping
whatever was collected.

Documented what this does NOT fix: agent-authored notebook code calling
tempfile.mkdtemp() still fails inside the Windows sandbox until upstream ships,
and Windows now resolves through uv while the others use pip, so the same
request can land different versions per platform.
The uv path reached the container and failed there:

    uv.exe pip install --python ...\env\Scripts\python.exe --only-binary :all: six
    error: Failed to inspect Python interpreter from provided path at `Scripts\python.exe`
      Caused by: Access is denied. (os error 5)

The grants are right - the environment is writable, the base interpreter is
readable under both the stable and resolved spellings, uv.exe and the shim
binary are readable. So this is more specific than a missing grant, and every
time this feature has been debugged by inference about Windows the inference has
been wrong. uv now runs with `-v` under OPENSCIENCE_SANDBOX_DEBUG, which names
the path and the operation rather than reporting a bare errno.

The streamed test also stopped reconstructing something the product no longer
does. It spawned `python -m pip`, which on Windows now fails on precisely the
upstream CPython defect (python/cpython#134587) that the uv path exists to route
around - a test red for a reason the product deliberately avoids. It runs uv
now, with uv's own cache and interpreter-download policy, matching install().
…reter?

uv's verbose output named the operation, which the bare errno never did:

    DEBUG Checking for Python interpreter at path `Scripts\python.exe`
    error: Failed to query Python interpreter
      Caused by: Access is denied. (os error 5)

"Query" means uv SPAWNS the interpreter, and uv is already inside the container
when it does. Our launcher spawning the same binary succeeds - but those are not
the same claim. CreateProcessW opens the image file in the CALLER's context, and
the launcher runs as the user and creates the process INTO the container, so its
success shows the launcher can open the binary rather than that the container
can execute it.

Which means "the environment's launcher can spawn its base inside the
container", cited as proof for several rounds, proves something weaker than it
reads. This test makes the distinction: a shell running IN the container spawns
the interpreter itself. Red here identifies execute-from-inside as the missing
right and retires the weaker claim; green rules it out and points at something
uv does that a direct spawn does not.
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