Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
c51c4b6
docs: ADR for a three-state sandbox network policy
KB-syntheticsciences Aug 10, 2026
a887206
docs: mark ADR 0002's forward decisions apart from its measurements
KB-syntheticsciences Aug 10, 2026
74d9388
feat(sandbox): allowlist egress proxy and loopback shim
KB-syntheticsciences Aug 10, 2026
e55ce20
feat(sandbox): three-state network policy with a socket egress route
KB-syntheticsciences Aug 10, 2026
6d8d9a3
fix(sandbox): close the egress-as-write-escape gap, correct stale com…
KB-syntheticsciences Aug 10, 2026
c602246
fix(sandbox): normalize egress through dedupe before the over-broad c…
KB-syntheticsciences Aug 10, 2026
05cb7db
feat(sandbox): bridge loopback to the egress socket inside the namespace
KB-syntheticsciences Aug 10, 2026
d2633b6
fix(sandbox): shim must not touch CLI middleware, resolve entry struc…
KB-syntheticsciences Aug 10, 2026
4ebae67
fix(sandbox): bind the shim's launcher explicitly, don't rely on its …
KB-syntheticsciences Aug 10, 2026
efc101e
fix(sandbox): bind the interpreter too, and stop shadowing writable o…
KB-syntheticsciences Aug 10, 2026
412cc8d
fix(sandbox): bundle the dev egress shim so it resolves nothing from …
KB-syntheticsciences Aug 10, 2026
312dccd
fix(sandbox): classify the shim bundle's imports against the real bui…
KB-syntheticsciences Aug 10, 2026
d368520
feat(sandbox): egress proxy lifecycle and allowlist config
KB-syntheticsciences Aug 10, 2026
f048be8
fix(sandbox): wire the egress proxy into every wrapArgv/plan caller
KB-syntheticsciences Aug 10, 2026
ca6edf8
fix(sandbox): honour write backpressure, unlatch proxy start, cut spa…
KB-syntheticsciences Aug 10, 2026
3bfc983
fix(sandbox): one upstream per client, and none left behind
KB-syntheticsciences Aug 10, 2026
de016f5
fix(sandbox): bound what one client can make the host allocate
KB-syntheticsciences Aug 10, 2026
e509136
fix(sandbox): wire allowHosts, widen the CLI network enum, unify egre…
KB-syntheticsciences Aug 10, 2026
fce582b
test(sandbox): prove egress is bounded and is the only route
KB-syntheticsciences Aug 10, 2026
00159ab
fix(sandbox): give curl headroom under the outer test timeout
KB-syntheticsciences Aug 10, 2026
30934e8
fix(sandbox): ro-bind the egress socket so a sandboxed process can't …
KB-syntheticsciences Aug 10, 2026
c0a7b15
fix(sandbox): let the settings GUI represent allowlist, add allowHost…
KB-syntheticsciences Aug 10, 2026
c7f1dd0
docs(sandbox): correct macOS/allowlist claims across ADR, docs, and f…
KB-syntheticsciences Aug 10, 2026
dfbe20e
feat(sandbox): macOS seatbelt support for network:"allowlist"
KB-syntheticsciences Aug 11, 2026
ec76353
fix(sandbox): darwin proxy must listen on TCP directly, not bridge to…
KB-syntheticsciences Aug 11, 2026
4ed60ed
fix(sandbox): fix round 1 — match the ADR's seatbelt reference shape,…
KB-syntheticsciences Aug 11, 2026
e584696
docs(sandbox): record seatbeltProfile's network-bind/inbound residual…
KB-syntheticsciences Aug 11, 2026
8d0f5f4
docs: Windows sandbox design — AppContainer plus a broker
KB-syntheticsciences Aug 11, 2026
6758a29
test(sandbox): first-ever macOS CI leg for seatbelt network:"allowlist"
KB-syntheticsciences Aug 11, 2026
3ae4849
fix: make the shim readiness cap a deadline, and pin platform in tests
KB-syntheticsciences Aug 11, 2026
17ba33a
Merge branch 'main' into feat/sandbox-network-policy
KB-syntheticsciences Aug 11, 2026
d0527a6
test: poll on the asserted predicate in the allowHosts round-trip
KB-syntheticsciences Aug 11, 2026
96cf4e2
test: pip install under network "allowlist", live, on both backends
KB-syntheticsciences Aug 11, 2026
2cfdec4
docs: carry the execution spec onto the install branch, corrected
KB-syntheticsciences Aug 11, 2026
5f004d1
feat(package): refuse shell installers in the bash tool
KB-syntheticsciences Aug 11, 2026
25a5270
feat(package): inject the package capability contract into every request
KB-syntheticsciences Aug 11, 2026
89e6a37
feat(package): environment manifest, derived directory, and per-env lock
KB-syntheticsciences Aug 11, 2026
8dbbb5d
feat(package): PEP 508 subset parser, canonical pattern, credential r…
KB-syntheticsciences Aug 11, 2026
4f27830
fix(test): accept both npm pack --json shapes
KB-syntheticsciences Aug 12, 2026
6818dfe
feat(package): installer ladder, sandboxed install, failure translation
KB-syntheticsciences Aug 12, 2026
933a882
feat(package): the package_install tool and its approval card
KB-syntheticsciences Aug 12, 2026
3d698e6
feat(package): bind kernels to environments, restart on non-additive …
KB-syntheticsciences Aug 12, 2026
b4ea2cf
feat(package): report the real environment inventory to the agent
KB-syntheticsciences Aug 12, 2026
28701e2
feat(package): async dispatch with reconcile-on-restart
KB-syntheticsciences Aug 12, 2026
e0bf9dc
feat(package): R environment support via install.packages
KB-syntheticsciences Aug 12, 2026
1e27421
test(package): the merge gate, stated as an executable assertion
KB-syntheticsciences Aug 12, 2026
c41e02d
ci: configure a git identity for the sandbox job
KB-syntheticsciences Aug 12, 2026
5dabeef
test(package): wait for a spawned pid to actually be gone before clai…
KB-syntheticsciences Aug 12, 2026
4c16818
test(package): spawn a portable binary for the dead-pid claim
KB-syntheticsciences Aug 12, 2026
3f129f0
docs: add a Windows AppContainer probe for the four unverified claims
KB-syntheticsciences Aug 12, 2026
be3eb94
docs: measure the Windows AppContainer design — the core claim was wrong
KB-syntheticsciences Aug 12, 2026
882cbd6
refactor(package): name the CRAN index, and assert it is allowlisted
KB-syntheticsciences Aug 12, 2026
9247098
fix(package): the first install must not strip the host's scientific …
KB-syntheticsciences Aug 12, 2026
00de92f
feat(package): show what the installer is doing while it does it
KB-syntheticsciences Aug 12, 2026
a403c2a
perf(package): share the wheel cache across environments
KB-syntheticsciences Aug 12, 2026
53b6895
test(package): exercise source builds and a real interrupt
KB-syntheticsciences Aug 12, 2026
6417b78
test(package): verify R for the first time, in CI
KB-syntheticsciences Aug 12, 2026
448c2b7
fix(package): give the async install path a way to report its outcome
KB-syntheticsciences Aug 12, 2026
23aae97
fix(package): decide restarts from what the kernel sees, not what the…
KB-syntheticsciences Aug 12, 2026
8eebad5
fix(compute): SSH remote jobs work again under the allowlist default
KB-syntheticsciences Aug 12, 2026
46d5f60
feat(sandbox): Windows AppContainer composition, without claiming it …
KB-syntheticsciences Aug 12, 2026
449b64f
docs: probe writes an untruncatable report, plus a Windows runbook
KB-syntheticsciences Aug 12, 2026
a0a40c8
fix(package,cli): three bugs a real Windows run found
KB-syntheticsciences Aug 12, 2026
6df1a89
feat(sandbox): the Windows AppContainer launcher
KB-syntheticsciences Aug 12, 2026
e568cf7
fix(sandbox): report the Windows backend, and prove containment befor…
KB-syntheticsciences Aug 13, 2026
fedc76b
fix(package): pick a native Windows interpreter, and stop guessing at…
KB-syntheticsciences Aug 13, 2026
f1ec918
fix(sandbox): let the AppContainer child inherit stdio, and report wh…
KB-syntheticsciences Aug 13, 2026
d377f0a
fix(sandbox,package): give cmd.exe /c, and stop stripping Windows env…
KB-syntheticsciences Aug 13, 2026
b53f6f4
fix(sandbox): grant the AppContainer read access, not just write
KB-syntheticsciences Aug 13, 2026
962d8d3
fix(sandbox): hold the FFI bindings, and make the launch dumpable
KB-syntheticsciences Aug 13, 2026
0c44d7e
fix(sandbox): judge containment by integrity, not only by a package SID
KB-syntheticsciences Aug 13, 2026
efa787b
fix(sandbox): probe with commands the shell actually has
KB-syntheticsciences Aug 13, 2026
38279be
fix(sandbox): hand cmd.exe its command verbatim, not CommandLineToArg…
KB-syntheticsciences Aug 13, 2026
e489e31
fix(package): compare environment paths through symlinks, not just re…
KB-syntheticsciences Aug 13, 2026
b7f71b5
fix(package): assert environment identity through symlinks too
KB-syntheticsciences Aug 13, 2026
bcd3c60
ci(sandbox): run the AppContainer against a real Windows kernel
KB-syntheticsciences Aug 13, 2026
3999290
fix(sandbox): re-enter the right executable when launching from source
KB-syntheticsciences Aug 13, 2026
e347e49
ci(sandbox): report what happens between CreateProcess and exit
KB-syntheticsciences Aug 13, 2026
2a99b47
fix(sandbox): label writable paths Low, and ask the kernel about cont…
KB-syntheticsciences Aug 13, 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
113 changes: 113 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,119 @@ 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
- 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
shell: bash
working-directory: backend/cli
env:
OPENSCIENCE_SANDBOX_DEBUG: "1"

test:
name: Test
runs-on: ubuntu-latest
Expand Down
47 changes: 38 additions & 9 deletions backend/cli/src/cli/cmd/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,44 @@ 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${enabled ? " are confined to the workspace" : " run with full user authority"})${S.TEXT_NORMAL}`,
`${S.TEXT_DIM} (agent shell commands ${effect})${S.TEXT_NORMAL}`,
)
UI.println(` platform ${d.platform}`)
UI.println(
` 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"}`)
UI.println(` network ${config?.network ?? "allowlist"}`)
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 - ` +
`commands run per "${config?.onUnavailable ?? "error"}". It takes effect on machines with a backend.`,
)
}
Expand Down Expand Up @@ -67,14 +84,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, allowlist (default), 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 @@ -84,10 +106,12 @@ 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"
const allow = args.allow as string[] | undefined
if (allow?.length) patch.allowWrite = allow
const allowHosts = args["allow-host"] as string[] | undefined
if (allowHosts?.length) patch.allowHosts = allowHosts
await Config.setSandbox(patch)
UI.empty()
UI.println(`${S.TEXT_SUCCESS_BOLD}Sandbox enabled${S.TEXT_NORMAL} ${S.TEXT_DIM}(global config)${S.TEXT_NORMAL}`)
Expand Down Expand Up @@ -125,22 +149,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