Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
9cf66df
feat(sandbox,package): governed package installs under a network-poli…
KB-syntheticsciences Aug 15, 2026
e7f16a2
fix(sandbox): re-seat the egress shim on main's empty-root namespace
KB-syntheticsciences Aug 15, 2026
8dbbe6f
fix(sandbox): stop readBind re-exposing masked paths, align the netwo…
KB-syntheticsciences Aug 15, 2026
3fd0eb7
feat(package): resolve managed environments through main's kernel res…
KB-syntheticsciences Aug 15, 2026
80b0ffe
fix(package,sandbox): grant the base prefix, and rebind kernels after…
KB-syntheticsciences Aug 15, 2026
88f8853
fix(sandbox): say that "allow" reaches nothing, and rebuild two files…
KB-syntheticsciences Aug 15, 2026
96d8acc
fix(ci): restore the macos-runtime job body the conflict resolution s…
KB-syntheticsciences Aug 15, 2026
f33105b
fix(sandbox): grant Windows exactly what the caller named, and nothin…
KB-syntheticsciences Aug 15, 2026
2e9ea43
fix(package): let the sandbox supply pip's scratch directory
KB-syntheticsciences Aug 15, 2026
37ba194
fix(sandbox): grant Windows the name the caller used, not just what i…
KB-syntheticsciences Aug 15, 2026
241e157
test(sandbox): stop the reconstruction drifting, and correct a wrong …
KB-syntheticsciences Aug 15, 2026
7d8dd0d
fix(package): install through uv on Windows, and stop install() hangi…
KB-syntheticsciences Aug 15, 2026
0a6da5e
test(sandbox): ask uv what it could not open, instead of guessing again
KB-syntheticsciences Aug 15, 2026
6712751
test(sandbox): does a process INSIDE the container execute the interp…
KB-syntheticsciences Aug 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,167 @@ jobs:
shell: bash
working-directory: backend/cli

# Task 7 gave macOS a seatbelt profile for network:"allowlist" — an SBPL
# profile plus an authenticated loopback proxy, built and unit-tested
# entirely from Linux with the platform injected, because no Mac exists on
# this project. `sandbox-exec` (macOS) and `bwrap --unshare-net` (Linux)
# are unrelated OS-level mechanisms underneath the same `Sandbox` API, so a
# green Linux run says nothing about whether seatbelt actually confines a
# real process the way the profile text claims — only this leg's macOS run
# does. See test/sandbox/egress-live-seatbelt.test.ts's doc comment for
# exactly what a red run here would mean.
sandbox:
name: Sandbox (${{ matrix.os }})
runs-on: ${{ matrix.os }}
# Raised from 20: test/package/ now runs real pip installs through the
# sandbox against real pypi, which the sandbox suite alone never did.
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-bun
# Same step the `test` job already has. The sandbox job never needed it
# until test/package/ joined it: those tests create a real project with
# `tmpdir({ git: true })`, and `git commit` exits 128 on a runner with no
# global identity configured.
- name: Configure git for tests
run: |
git config --global user.email "ci@openscience.dev"
git config --global user.name "OpenScience CI"
git config --global init.defaultBranch main
- name: Install and verify Linux sandbox
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install --yes bubblewrap
# Ubuntu 24.04's host-wide AppArmor policy blocks unprivileged user
# namespaces on the hosted runner before bubblewrap can apply our
# stricter per-process profile. This runner is disposable; enable
# user namespaces for the job, then prove the sandbox can start.
if [[ -e /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]]; then
echo 0 | sudo tee /proc/sys/kernel/apparmor_restrict_unprivileged_userns
fi
bwrap --ro-bind / / --dev /dev --proc /proc --unshare-pid --die-with-parent -- true
# R is the one backend with no verification anywhere: no runner has
# Rscript by default, and neither does any development machine on this
# project, so its two live tests skip everywhere and it ships on faith.
# r-base-core is the minimal package that provides 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.
- name: Install R so the R installer tests actually run
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get install --yes --no-install-recommends r-base-core
Rscript -e 'cat("Rscript", as.character(getRversion()), "\n")'
# test/package/ carries the merge gate: a governed install under
# network "allowlist", plus the assertion that the shell route to the
# same install is refused. Both legs run it, so the gate is a fact on
# Linux and macOS rather than a claim about one of them.
- run: bun test test/sandbox/ test/package/
shell: bash
working-directory: backend/cli

# Windows is deliberately NOT in the matrix above. That job's `test/package/`
# leg is the merge gate — a governed install under network "allowlist" — and
# allowlist egress does not exist on Windows yet: the container holds zero
# capabilities, so it has no network by construction, and nothing serves the
# broker pipe the spec carries. Adding windows-latest there would be red for a
# feature that was never built, which teaches a reader nothing.
#
# What this job DOES cover is the part that was only ever verified by hand:
# `test/sandbox/appcontainer-live.test.ts` runs a real CreateProcessW with real
# SECURITY_CAPABILITIES and asserts the child is confined. Everything else in
# test/sandbox/ exercises the Windows branch from Linux with the platform
# injected, which proves what we compose and nothing about what Windows does
# with it. That gap cost roughly ten manual round trips on a contributor's own
# machine, one command at a time, for bugs that were not exotic: `-c` where cmd
# wanted `/c`, `printf` in a shell with no printf, CommandLineToArgvW quoting
# handed to the one program that does not parse it that way. Each would have
# been red here within minutes.
#
# Widen this to test/package/ once the named-pipe broker lands.
sandbox-windows:
name: Sandbox (windows-latest)
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-bun
- run: >-
bun test
test/process/darwin-responsibility.test.ts
test/credentials/process-ledger.test.ts
test/project/authority-process-ledger.test.ts
shell: bash
working-directory: backend/cli

- name: Configure git for tests
run: |
git config --global user.email "ci@openscience.dev"
git config --global user.name "OpenScience CI"
git config --global init.defaultBranch main
shell: bash
# OPENSCIENCE_SANDBOX_DEBUG makes the launcher dump what it hands the
# kernel — the SID, the attribute list, the whole STARTUPINFOEX with cb and
# lpAttributeList broken out. On a machine no one can log into, a failure
# that only says "expected true" is worth almost nothing.
# Just the live file. The rest of test/sandbox/ asserts POSIX composition
# -- seatbelt profile text, bubblewrap argv, `/tmp` paths that path.resolve
# turns into `C:\tmp` here -- and several tests read source through
# `new URL(...).pathname`, which yields `/D:/a/...` on Windows. Those are
# Linux/macOS concerns that happen to live in the same directory; running
# them here would produce 30-odd red results that say nothing about
# Windows. Widen deliberately, not by directory.
- run: bun test test/sandbox/appcontainer-live.test.ts test/sandbox/appcontainer-transport.test.ts
shell: bash
working-directory: backend/cli
env:
OPENSCIENCE_SANDBOX_DEBUG: "1"

# A base interpreter the runner's own user owns. The whole Windows
# difficulty is that an AppContainer can only be granted paths its user
# owns, and every Python preinstalled on a GitHub runner is machine-wide —
# so without this step the install test would exercise the one
# configuration that is known not to work, and prove nothing about the one
# users are told to set up.
- name: Install uv and a user-owned Python
run: |
irm https://astral.sh/uv/install.ps1 | iex
$env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
uv python install 3.12
uv python list --only-installed --output-format json
shell: pwsh

# The seven hops between "a process starts in a container" and "a package
# is importable": interpreter choice, venv creation, the base pin, the ACL
# grant, the launcher spawning its base, pip, and the pipe->broker->proxy
# chain. None of it was covered, so all of it was found one round trip at
# a time on a contributor's own machine.
# The sandbox re-enters this binary twice — as the launcher and as the
# egress shim — and in a source checkout neither is what ships. The shim
# especially: `bun <bundle>` dies inside the container with `error loading
# current directory`, while `bun --version` in the same container with the
# same working directory exits 0. That difference is a property of a dev
# artifact users never run, so the test drives the compiled binary.
- name: Compile the binary the sandbox re-enters
run: bun build --compile ./src/index.ts --outfile "$RUNNER_TEMP/openscience-self.exe"
shell: bash
working-directory: backend/cli

- name: Package install, end to end
run: |
$env:Path = "$env:USERPROFILE\.local\bin;$env:Path"
bun test test/sandbox/appcontainer-install.test.ts
shell: pwsh
working-directory: backend/cli
env:
OPENSCIENCE_SANDBOX_DEBUG: "1"
OPENSCIENCE_SELF_BINARY: ${{ runner.temp }}\openscience-self.exe

test:
name: Test
runs-on: ubuntu-latest
Expand Down
67 changes: 59 additions & 8 deletions backend/cli/src/cli/cmd/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ function printStatus(config?: Config.Sandbox) {
const enabled = config?.enabled === true

UI.println(`${S.TEXT_NORMAL_BOLD}Execution sandbox${S.TEXT_NORMAL}`)
// Three states, not two. "enabled" describes the CONFIG; whether anything is
// actually confined depends on a backend existing. Keying the sentence off
// `enabled` alone told a Windows user "agent shell commands are confined to
// the workspace" on a machine where `Sandbox.backend()` is "none" and nothing
// confines anything — a false statement about a security property, which is
// the worst kind of wrong thing for this command to print.
// Then it printed "are confined to the workspace" on a Windows run whose
// `sandbox test` failed containment in the very next command. A backend being
// AVAILABLE is not the same as it working, and this command does not run the
// commands that would tell the difference — so it now reports what it actually
// knows (which backend is applied) and names the command that can prove it.
const effect = !enabled
? "run with full user authority"
: d.available
? `are launched through ${d.tool ?? d.backend} - run 'openscience sandbox test' to verify containment`
: "are NOT confined here: no backend on this platform"
UI.println(
` status ${enabled ? `${S.TEXT_SUCCESS_BOLD}enabled` : `${S.TEXT_DIM}disabled`}${S.TEXT_NORMAL}` +
`${S.TEXT_DIM} (agent shell commands${
Expand All @@ -28,21 +44,31 @@ function printStatus(config?: Config.Sandbox) {
` backend ${
d.available
? `${S.TEXT_SUCCESS}${d.backend}${S.TEXT_NORMAL} ${S.TEXT_DIM}(${d.tool})${S.TEXT_NORMAL}`
: `${S.TEXT_WARNING}unavailable${S.TEXT_NORMAL} ${S.TEXT_DIM} ${d.reason}${S.TEXT_NORMAL}`
: `${S.TEXT_WARNING}unavailable${S.TEXT_NORMAL} ${S.TEXT_DIM}- ${d.reason}${S.TEXT_NORMAL}`
}`,
)
if (enabled) {
UI.println(` network ${config?.network ?? "deny"}`)
// "allow" is not what it says on bubblewrap or seatbelt: both deny every
// socket in every mode, because neither can grant outbound access without
// also exposing everything bound to 127.0.0.1. Printing the configured word
// alone made this command state a capability the machine does not have.
const net = config?.network ?? "deny"
const hollow = net === "allow" && (d.backend === "bubblewrap" || d.backend === "seatbelt")
UI.println(
` network ${net}` +
(hollow ? `${S.TEXT_DIM} (this backend denies all sockets - use 'allowlist')${S.TEXT_NORMAL}` : ""),
)
UI.println(
` project trust ${config?.requireProjectTrust ? "required for all execution" : "routine sandboxed work allowed"}`,
)
UI.println(` on missing backend ${config?.onUnavailable ?? "error"}`)
if (config?.allowWrite?.length) UI.println(` extra writable ${config.allowWrite.join(", ")}`)
if (config?.allowHosts?.length) UI.println(` extra hosts ${config.allowHosts.join(", ")}`)
}
if (enabled && !d.available) {
UI.println("")
UI.println(
` ${S.TEXT_WARNING_BOLD}Note:${S.TEXT_NORMAL} sandbox is on but no backend exists here ` +
` ${S.TEXT_WARNING_BOLD}Note:${S.TEXT_NORMAL} sandbox is on but no backend exists here - ` +
`execution follows the "${config?.onUnavailable ?? "error"}" fallback policy. It takes effect on machines with a backend.`,
)
}
Expand All @@ -53,6 +79,21 @@ async function showStatus() {
directory: process.cwd(),
async fn() {
printStatus(await effectiveSandbox())
// A prerequisite the user cannot discover from anything else. On Windows
// an AppContainer can only be granted access to paths its user owns, so a
// machine-wide Python is unusable by a sandboxed process however healthy
// it is — and the only symptom otherwise is an install failing much later
// with an error about the interpreter rather than about ownership.
//
// Printed only when it applies, and worded so nobody reads it as
// "containment is broken": it is not, and a user who turns the sandbox
// off over this would lose confinement they still have.
const { Installer } = await import("../../package/installer")
const blocked = await Installer.blocked().catch(() => undefined)
if (blocked) {
UI.empty()
for (const line of blocked.split("\n")) UI.println(line ? ` ${S.TEXT_WARNING}${line}${S.TEXT_NORMAL}` : "")
}
},
})
}
Expand All @@ -72,14 +113,19 @@ const EnableCommand = cmd({
builder: (yargs: Argv) =>
yargs
.option("network", {
choices: ["allow", "deny"] as const,
describe: "allow or deny network egress from sandboxed commands (default: deny)",
choices: ["deny", "allowlist", "allow"] as const,
describe: "network egress from sandboxed commands: deny (default), allowlist, or allow",
})
.option("allow", {
type: "string",
array: true,
describe: "extra absolute path the sandbox may write to (repeatable)",
})
.option("allow-host", {
type: "string",
array: true,
describe: "extra host the sandbox may reach when network is 'allowlist' (repeatable)",
})
.option("on-unavailable", {
choices: ["warn", "error", "allow"] as const,
describe: "what to do when no backend exists on a machine (default: error)",
Expand All @@ -93,7 +139,7 @@ const EnableCommand = cmd({
directory: process.cwd(),
async fn() {
const patch: Partial<Config.Sandbox> = { enabled: true }
if (args.network) patch.network = args.network as "allow" | "deny"
if (args.network) patch.network = args.network
if (args["on-unavailable"]) patch.onUnavailable = args["on-unavailable"] as "warn" | "error" | "allow"
if (typeof args["require-project-trust"] === "boolean") {
patch.requireProjectTrust = args["require-project-trust"]
Expand Down Expand Up @@ -143,22 +189,27 @@ const TestCommand = cmd({
const result = await Sandbox.selfTest()
if (!result.available) {
const d = Sandbox.describe()
UI.println(`${S.TEXT_WARNING}No sandbox backend available${S.TEXT_NORMAL} ${d.reason}.`)
UI.println(`${S.TEXT_WARNING}No sandbox backend available${S.TEXT_NORMAL} - ${d.reason}.`)
UI.println(`${S.TEXT_DIM}Nothing to test here.${S.TEXT_NORMAL}`)
return
}
UI.println(
`${S.TEXT_NORMAL_BOLD}Sandbox self-test${S.TEXT_NORMAL} ${S.TEXT_DIM}(${result.backend})${S.TEXT_NORMAL}`,
)
for (const c of result.checks) {
// The glyphs below are only reachable when a backend EXISTS, so they
// cannot print on Windows today, where the command exits above. Anything
// printed on a backend-less machine must stay ASCII: a Windows console
// decodes our UTF-8 as its OEM code page, and an em dash arrived as
// "\u0393\u00c7\u00f6" in a real run. Keep that rule if a Windows backend lands.
const mark = c.skipped ? `${S.TEXT_DIM}– skip` : c.pass ? `${S.TEXT_SUCCESS}✓ pass` : `${S.TEXT_DANGER}✗ FAIL`
UI.println(` ${mark}${S.TEXT_NORMAL} ${c.name}${c.detail ? ` ${S.TEXT_DIM}(${c.detail})${S.TEXT_NORMAL}` : ""}`)
}
UI.empty()
UI.println(
result.ok
? `${S.TEXT_SUCCESS_BOLD}Containment verified.${S.TEXT_NORMAL}`
: `${S.TEXT_DANGER_BOLD}Containment FAILED do not rely on the sandbox until this passes.${S.TEXT_NORMAL}`,
: `${S.TEXT_DANGER_BOLD}Containment FAILED - do not rely on the sandbox until this passes.${S.TEXT_NORMAL}`,
)
},
})
Expand Down
Loading
Loading