Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
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
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@ 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 }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-bun
- 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
- run: bun test test/sandbox/
shell: bash
working-directory: backend/cli

test:
name: Test
runs-on: ubuntu-latest
Expand Down
16 changes: 12 additions & 4 deletions backend/cli/src/cli/cmd/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,10 @@ function printStatus(config?: Config.Sandbox) {
}`,
)
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("")
Expand Down Expand Up @@ -67,14 +68,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 +90,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
34 changes: 25 additions & 9 deletions backend/cli/src/compute/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OpenScience } from "../openscience"
import { Shell } from "../shell/shell"
import { Instance } from "../project/instance"
import { Sandbox } from "../sandbox/sandbox"
import { EgressRuntime } from "../sandbox/egress-runtime"
import { Filesystem } from "../util/filesystem"
import { ProvenanceEnvelope } from "../science/provenance/envelope"
import { ExecutionAuthority } from "../project/execution"
Expand Down Expand Up @@ -169,6 +170,10 @@ export namespace ComputeJobs {
recovery_attempts: z.number().int().nonnegative().optional(),
recovery_retry_at: z.string().optional(),
session_id: z.string().startsWith("ses_").optional(),
// Persists the whole Decision, including its own sandbox.network — a
// second copy of the persisted enum below `sandbox.network` carries; see
// the comment on ExecutionAuthority.Decision for the downgrade cost of
// widening either one.
authority: ExecutionAuthority.Decision.optional(),
scope: z
.object({
Expand All @@ -181,7 +186,10 @@ export namespace ComputeJobs {
requested: z.boolean(),
enforced: z.boolean(),
backend: z.enum(["seatbelt", "bubblewrap", "none"]),
network: z.enum(["allow", "deny"]),
// Persisted — widening this costs an older binary its ability to
// read a newer record. `authority.sandbox.network` above is the same
// enum persisted a second time; see ExecutionAuthority.Decision.
network: z.enum(["deny", "allowlist", "allow"]),
warning: z.string().optional(),
})
.optional(),
Expand Down Expand Up @@ -246,6 +254,9 @@ export namespace ComputeJobs {
type Launch = {
argv: string[]
sandbox?: Job["sandbox"]
/** Proxy variables `execute` must merge into the spawned process's env
* for the loopback shim to be reachable — see `Sandbox.Wrapped.env`. */
env?: Record<string, string>
}

const active = new Map<string, Runtime>()
Expand Down Expand Up @@ -683,12 +694,13 @@ export namespace ComputeJobs {
): Promise<Launch> {
const spec = command(job, host)
if (host) {
const egress = await EgressRuntime.egressFor(authority.sandbox)
const planned = Sandbox.wrapArgv({
file: spec.argv[0]!,
args: spec.argv.slice(1),
workspace: authority.writable,
unreadable: OpenScience.kernelSensitivePaths(),
options: authority.sandbox,
options: { ...authority.sandbox, egress },
})
return {
argv: [planned.file, ...planned.args],
Expand All @@ -699,19 +711,21 @@ export namespace ComputeJobs {
network: authority.sandbox.network,
warning: planned.warning,
},
env: planned.env,
}
}

await fs.mkdir(logsOf(scope.root), { recursive: true })
await fs.writeFile(exitOf(scope.root, job.id), "", { mode: 0o600 })
const wrapped = `(${job.command}\n); code=$?; printf %s "$code" > ${quote(exitOf(scope.root, job.id))}; exit "$code"`
const egress = await EgressRuntime.egressFor(authority.sandbox)
const planned = Sandbox.wrapArgv({
file: Shell.acceptable(),
args: ["-lc", wrapped],
workspace: authority.writable,
extraWritable: [exitOf(scope.root, job.id)],
unreadable: OpenScience.kernelSensitivePaths(),
options: authority.sandbox,
options: { ...authority.sandbox, egress },
})
return {
argv: [planned.file, ...planned.args],
Expand All @@ -722,6 +736,7 @@ export namespace ComputeJobs {
network: authority.sandbox.network,
warning: planned.warning,
},
env: planned.env,
}
}

Expand All @@ -730,16 +745,17 @@ export namespace ComputeJobs {
cwd: string,
authority: ExecutionAuthority.Decision,
): Promise<string | undefined> {
const egress = await EgressRuntime.egressFor(authority.sandbox)
const planned = Sandbox.wrapArgv({
file: argv[0]!,
args: argv.slice(1),
workspace: authority.writable,
unreadable: OpenScience.kernelSensitivePaths(),
options: authority.sandbox,
options: { ...authority.sandbox, egress },
})
const proc = Bun.spawn([planned.file, ...planned.args], {
cwd,
env: await OpenScience.subprocessEnv(process.env),
env: { ...(await OpenScience.subprocessEnv(process.env)), ...(planned.env ?? {}) },
stdin: "ignore",
stdout: "pipe",
stderr: "ignore",
Expand Down Expand Up @@ -1103,7 +1119,7 @@ export namespace ComputeJobs {
await fs.mkdir(logsOf(scope.root), { recursive: true })
const log = path.join(logsOf(scope.root), `${job.id}.log`)
const output = await fs.open(log, "a", 0o600)
const env = await OpenScience.subprocessEnv(process.env)
const env = { ...(await OpenScience.subprocessEnv(process.env)), ...(launch.env ?? {}) }
const queued = (await read(scope.root)).find((item) => item.id === job.id)
if (queued?.status === "cancelled") {
await output.close()
Expand Down Expand Up @@ -1828,12 +1844,12 @@ export namespace ComputeJobs {
args: spec.argv.slice(1),
workspace: job.authority.writable,
unreadable: OpenScience.kernelSensitivePaths(),
options: job.authority.sandbox,
options: { ...job.authority.sandbox, egress: await EgressRuntime.egressFor(job.authority.sandbox) },
})
: { file: spec.argv[0]!, args: spec.argv.slice(1) }
: { file: spec.argv[0]!, args: spec.argv.slice(1), env: undefined }
const proc = spawn(planned.file, planned.args, {
cwd: job.authority?.workspace,
env: await OpenScience.subprocessEnv(process.env),
env: { ...(await OpenScience.subprocessEnv(process.env)), ...(planned.env ?? {}) },
windowsHide: true,
stdio: "ignore",
})
Expand Down
13 changes: 10 additions & 3 deletions backend/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -757,9 +757,15 @@ export namespace Config {
"Run local terminals, kernels, and shell commands inside an OS sandbox (macOS Seatbelt / Linux bubblewrap) that confines writes to authorized project roots. Enabled by default.",
),
network: z
.enum(["allow", "deny"])
.enum(["deny", "allowlist", "allow"])
.optional()
.describe("Whether sandboxed commands may reach the network. Default: deny."),
.describe("Whether sandboxed commands may reach the network. Default: allowlist."),
allowHosts: z
.array(z.string())
.optional()
.describe(
"Extra hosts sandboxed processes may reach when network is 'allowlist'. A leading dot matches subdomains, e.g. '.internal.example.com'.",
),
allowWrite: z
.array(z.string())
.optional()
Expand Down Expand Up @@ -1718,7 +1724,8 @@ export namespace Config {
const policy = { ...(base ?? {}), ...(managed ?? {}) }
return {
enabled: policy.enabled ?? true,
network: policy.network ?? "deny",
network: policy.network ?? "allowlist",
allowHosts: policy.allowHosts ?? [],
allowWrite: policy.allowWrite ?? [],
onUnavailable: policy.onUnavailable ?? "error",
}
Expand Down
18 changes: 18 additions & 0 deletions backend/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ import { SandboxCommand } from "./cli/cmd/sandbox"
import { InitCommand, DoctorCommand } from "./cli/onboard"
import { OpenScience } from "./openscience"

// Handle the hidden egress shim before any other CLI machinery — yargs
// construction and its global `.middleware` below — is reached. This runs
// inside the sandboxed namespace `bubblewrapArgs` builds, where `/` is
// read-only (the middleware's `Log.init` opens a log file — EROFS) and the
// network is unshared except for the one bind-mounted socket (the
// middleware's `OpenScience.refreshIfStale` is an HTTP fetch — hangs against
// a severed network). The shim only opens a listener and forwards bytes; it
// must never reach either. This is a plain check against argv, before yargs
// (or anything yargs triggers) parses anything, so it cannot regress if the
// middleware grows later — there is no yargs command path to keep in sync.
if (process.argv[2] === "__egress-shim") {
const { Egress } = await import("./sandbox/egress")
const { SHIM_READY_MARKER } = await import("./sandbox/egress-shim-marker")
Egress.serveShim({ port: Number(process.argv[3]), socket: process.argv[4] as string })
await Bun.write(SHIM_READY_MARKER, "").catch(() => {})
await new Promise(() => {})
}

process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
e: e instanceof Error ? e.message : e,
Expand Down
6 changes: 6 additions & 0 deletions backend/cli/src/openscience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ const KERNEL_RUNTIME_KEYS = new Set([
"WINDIR",
"PATHEXT",
"COMSPEC",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
])
const SAFE_SYNCED_KEYS = new Set([
...BYOK_LLM_ENV_KEYS,
Expand Down
16 changes: 15 additions & 1 deletion backend/cli/src/project/execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ export namespace ExecutionAuthority {
])
export type Capability = z.infer<typeof Capability>

// Not pure in-memory state: `compute/jobs.ts`'s `Job.authority` field
// stores a `Decision` verbatim in the on-disk job history (`jobs.json`), so
// `sandbox.network` below is a *second* copy of the persisted enum that
// `Job.sandbox.network` also carries — widening it (e.g. adding
// "allowlist") has the same one-directional compatibility cost: a job
// record this binary writes with the new value is rejected by an older
// binary reading the same history. The stakes are higher than "that one
// record" though — `ComputeJobs`'s `read()` runs `Job.array().safeParse()`
// over the *whole file* and throws `ComputeJobsCorruptError` for all of it
// on any single unparseable record (`compute/jobs.ts`'s `read()`), moving
// the file aside as `.corrupt-<pid>` rather than skipping the bad one. So
// one job with `sandbox.network: "allowlist"` written by a newer binary
// makes an older binary reject its entire compute job history, not just
// fail to display that job.
export const Decision = z.object({
allowed: z.boolean(),
reason: z.enum(["allowed", "project_untrusted", "sandbox_unavailable"]),
Expand All @@ -46,7 +60,7 @@ export namespace ExecutionAuthority {
writable: z.array(z.string()),
sandbox: z.object({
enabled: z.boolean(),
network: z.enum(["allow", "deny"]),
network: z.enum(["deny", "allowlist", "allow"]),
allowWrite: z.array(z.string()),
onUnavailable: z.enum(["warn", "error", "allow"]),
backend: z.enum(["seatbelt", "bubblewrap", "none"]),
Expand Down
12 changes: 10 additions & 2 deletions backend/cli/src/pty/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { lazy } from "@synsci/util/lazy"
import { Shell } from "@/shell/shell"
import { ExecutionAuthority } from "@/project/execution"
import { Sandbox } from "@/sandbox/sandbox"
import { EgressRuntime } from "@/sandbox/egress-runtime"
import { OpenScience } from "@/openscience"
import { terminalArgs, terminalEnv } from "./environment"

Expand Down Expand Up @@ -108,14 +109,21 @@ export namespace Pty {
const args = terminalArgs(command)
const cwd = authority.workspace
const source = await OpenScience.subprocessEnv(process.env)
const env = terminalEnv(source, Instance.project.id, input.sessionID, command)
const egress = await EgressRuntime.egressFor(authority.sandbox)
const sandbox = Sandbox.wrapArgv({
file: command,
args,
workspace: authority.writable,
unreadable: OpenScience.kernelSensitivePaths(),
options: authority.sandbox,
options: { ...authority.sandbox, egress },
})
// terminalEnv gained a `command` argument on main (it picks the PS1/PROMPT
// shape from the shell); the env still has to be built *after* wrapArgv,
// because sandbox.env carries the proxy variables the shim needs.
const env = {
...terminalEnv(source, Instance.project.id, input.sessionID, command),
...(sandbox.env ?? {}),
}
log.info("creating session", { id, cmd: command, args, cwd })

const spawn = await pty()
Expand Down
Loading