Skip to main content

Addendum, 2026-07-27 (later still still) β€” js-exec is now fixed and verified too; all three (python3, sqlite3, js-exec) are enabled. Supersedes the "Addendum, 2026-07-27 (later still)" addendum immediately below re: js-exec's status only (kept, not rewritten; its root-cause writeup for python3/sqlite3/the shared entrypoint bug still stands).

js-exec's hang was never QuickJS itself β€” isolated outside a compiled binary, getQuickJS() and evalCode() resolve and run in single-digit milliseconds β€” and not the entrypoint-resolution bug the addendum below fixes (js-exec-worker.js was already resolving correctly as its own compile entrypoint). It was the SAME import.meta.url defect (oven-sh/bun#29124) one layer deeper still: quickjs-emscripten's own WASM loader (@jitl/quickjs-wasmfile-release-sync) falls back to new URL("emscripten-module.wasm", import.meta.url) when given no locateFile, and quickjs-emscripten's public API (getQuickJS()/newQuickJSWASMModule()) never threads one through β€” the WASM instantiation rejected with an ENOENT on a nonexistent /$bunfs/root/.../emscripten-module.wasm path almost instantly, but the rejection carried no protocol token, so the caller's token-matched response handling silently discarded it. The only externally visible symptom was a SEPARATE mechanism: the sync-bridge servicer loop (which never received a single opcode, since the worker died before constructing its SyncBackend) timing out on its own independent 10s clock β€” a genuinely silent hang from the caller's side, not a QuickJS defect, confirmed with an instrumented build (console.error breadcrumbs compiled into js-exec-worker.js, run against the real worker on linux-arm64-gnu).

Fixed via quickjs-emscripten-core's own public newVariant(baseVariant, options) extension point (re-exported through quickjs-emscripten's package entrypoint), wrapping RELEASE_SYNC β€” the variant getQuickJS() uses by default β€” to inject a locateFile resolving to a process.execPath-derived path, the same pattern already used for sqlite3, with the real .wasm file materialized to disk the same way. The fix stays entirely inside just-bash's own chunk (an import change plus a different loader call), never touching quickjs-emscripten's own generated/bundled output β€” same discipline as the stripTypeScriptTypes shim. js-exec is FIXED AND VERIFIED: real js-exec -c "console.log(3+3)" returns 6 in well under 100ms (not a 10s timeout), a non-trivial loop-and-accumulate expression evaluates correctly, and js-exec --strip-types -c "..." correctly strips and runs a TypeScript input β€” all on real linux-arm64-gnu Docker, alongside python3/sqlite3 still working from the same compiled worker binary.

WorkerDefenseInDepth/DefenseInDepthBox were not touched, disabled, or weakened at any point in this investigation either β€” the defect and fix are both entirely about WASM asset resolution, upstream of and unrelated to the sandbox layer. Full mechanism and evidence in runners/hermes-binary/README.md's "python3, sqlite3, js-exec: all three fixed and verified" section.

Addendum, 2026-07-27 (later still) β€” python3/sqlite3 fixed and verified; js-exec fixed at the resolution layer but disabled on a separate, newly discovered execution hang. Supersedes the "Correction, same day" addendum below re: root cause (kept, not rewritten).

The real cause left unisolated by that addendum: in a bun build --compile binary, import.meta.url/.dir/.path for ANY non-entrypoint module report the PRIMARY ENTRYPOINT's own identity, not the module's own actual location β€” confirmed by direct import.meta introspection in a minimal, clean-directory repro with a mandatory control test (a deliberately-wrong specifier fails cleanly first, ruling out Bun's disk-fallback contaminating the result). Matches the open upstream bug oven-sh/bun#29124. This breaks every relative-path resolution scheme from these nested chunks regardless of literal form, explicit-entrypoint status, or static/dynamic import β€” which is why WorkerDefenseInDepth/DefenseInDepthBox were never actually implicated (confirmed again here) and why hand-reconstructions never reproduced the failure while the verbatim compiled chunk always did.

Fix, verified end to end on real linux-arm64-gnu Docker (not just darwin), two layers deep: (1) each worker file passed as an explicit --compile entrypoint (so Bun bundles its own dependency graph β€” a {type:"file"} asset import alone finds the file but embeds it as an opaque unbundled blob, which only moves the failure one layer deeper: Cannot find package 'quickjs-emscripten'/'sql.js') plus (2) each call site patched to a hardcoded absolute /$bunfs/root/<path> literal matching exactly where that entrypoint lands (never a relative literal, which still runs through the broken resolution above regardless of wording).

Fixing that unmasked three further, genuinely separate, per-command defects: python3's and sqlite3's WASM vendor runtimes (CPython- Emscripten, ~10 MiB; sql.js) are emscripten-generated glue computing their own sibling-asset path the same broken way, one layer deeper, and too large for --compile embedding regardless β€” fixed by materializing them to real disk (like the CPython interpreter itself) and resolving via process.execPath, the compiled worker's own real on-disk location (reliable regardless of caller cwd β€” justbash_driver.py's Popen call does not set one). python3 and sqlite3 are FIXED AND VERIFIED: real python3 -c "print(2+2)" returns 4, real sqlite3 :memory: "SELECT 6*7;" returns 42, both on real linux-arm64-gnu Docker. js-exec's quickjs-emscripten statically imports stripTypeScriptTypes from node:module, a Node 22.6+ API Bun does not implement β€” fixed with a working shim via Bun's own built-in transpiler. That got js-exec's worker loading and running, but its QuickJS execution then genuinely hangs (a trivial console.log(3+3) times out at just-bash's own 10s default limit, confirmed in isolation on real linux-arm64-gnu Docker) β€” a separate, not-yet-root-caused defect, unrelated to the entrypoint-resolution bug this addendum fixes. Left disabled (javascript unset) rather than shipped hanging on every call.

Neither WorkerDefenseInDepth nor DefenseInDepthBox was touched, disabled, or weakened at any point across any of this investigation. Full mechanism and evidence in runners/hermes-binary/README.md's "python3, sqlite3: fixed and verified. js-exec: fixed at the resolution layer, disabled on a separate execution hang" section.

Addendum, 2026-07-27 (later same day) β€” two findings from driving the justbash/AgentFS terminal-tool wiring through a real browser turn, not a harness.

1. Landlock /proc widened from /proc/self to the whole directory β€” a real, deliberate widening of the confinement, not a no-op. The just-bash worker hard-aborted (SIGABRT, empty stderr) every time it was spawned the way production actually spawns it β€” Python's subprocess.Popen inside a running Hermes turn, which forks a NEW pid before exec'ing the worker β€” while starting fine unconfined and via a single-hop hermes-run --shell-exec. Root cause, confirmed live: the original /proc/self Landlock rule is bound at rule-creation time to the directory /proc/<hermes-run's own pid> resolves to at that moment; execve preserves pid so a single hop stays covered, but Python's fork creates a pid the rule was never granted for. Confirmed directly, not inferred: the parent process could read its own /proc/self/maps but got PermissionError(13, 'Permission denied') reading /proc/<the forked child's pid>/maps for that same child β€” and JavaScriptCore's allocator (bmalloc) reads /proc/self/maps on startup to place heap regions, hard-aborting rather than falling back when that read is denied. There is no way to pre-grant a pid that does not exist yet, and Landlock rulesets are one-way, so the only structurally possible fix is widening the readonly rule from /proc/self to /proc itself. This means every process this container confines can now read every OTHER confined process's /proc/<pid> tree, including /proc/<pid>/environ (which carries that run's token and other secrets injected via HERMES_*/HUMANWORK_* env vars), scoped to other processes inside the same confined container only (never the host, never another container). Fixed and empirically verified: rebuilt, restarted humanwork-agent, and the worker now starts cleanly on every subsequent turn with real command execution and a file the agent created (contract.md, written via a real turn's terminal tool call and confirmed byte-identical via a read_file call in the same turn) persisting in AgentFS. Full writeup: runners/hermes-binary/README.md's "The /proc Landlock rule: why it is /proc, not /proc/self" section.

2. Historical note: the model gateway once stopped turns after eight model cycles without an MCP tool event. That rule could terminate valid Hermes-native terminal/file/browser work because those executions correctly remain in Hermes SessionDB/AgentFS rather than the Humanwork tool ledger. ADR-046 retired the rule and its duplicate API telemetry path. The model gateway now authenticates, brokers, and accounts for provider calls without inferring native tool execution from model messages.

Superseded again, 2026-07-27. The runners/hermes-binary zig binary described in the entry directly below is further along than that entry states, and the API-side scheduler substrate selection it references (local_process/local_docker/ecs) has itself been deleted. Verified against source on dankovk/new-agent-runtime-follow-up (2026-07-27). This work is now committed locally but not yet pushed: as of writing, local HEAD is 9+ commits ahead of origin/dankovk/new-agent-runtime-follow-up (still 3bedad883) β€” PR #4953 as it reads on GitHub does not yet reflect any of this, and none of it has been through CI.

  • API orchestrates only. ConversationsService (api/src/conversations/conversations.service.ts) optionally injects ManagedHermesTurnService and pins the tool-grant intersection onto the durable AgentRun row before handing off β€” its own comment: "the resolved intersection is pinned on the durable AgentRun before ManagedHermesTurnService executes it" (conversations.service.ts:3413). ManagedHermesTurnService's class comment (managed-hermes-turn.service.ts:347-350): "Production composition root for one Hermes turn. Conversation code supplies only the current turn and a held assignment fence; the active release, grants, home, gateway route and prior ACP session are resolved here." It injects the AgentRun repository, RunTokenService (mints the run token), and AgentClient (drives the turn), and tracks approval state on the persisted run (waitingForApproval: persistedRun.status === 'waiting_approval', :929). It no longer executes Hermes itself.
  • local_process, local_docker and ECS-per-run substrates are deleted (docker-hermes-runner.service.ts, ecs-hermes-runner.service.ts, local-process-hermes-runner.service.ts, hermes-runner.provider.ts, efs-hermes-mailbox.ts, hyperframe-sandbox-bridge.service.ts) β€” committed locally in this branch's protective commit pass, not yet pushed.
  • AgentClient reaches a long-lived agent container. agent.client.ts resolves its base URL from AGENT_SERVICE_URL (agent.client.ts:506). The agent container's hermes_acp_client.py execs whatever HERMES_ACP_CLI names; agent/Dockerfile sets HERMES_ACP_CLI=/usr/local/bin/hermes-run, copied there from the hermes-builder stage (Dockerfile:110,133).
  • hermes-run is a single static zig binary (runners/hermes-binary/src/main.zig). It @embedFiles a zstd payload containing CPython 3.13.14 and the pinned Hermes tree at commit 31c08a9aad6e83ded5d0e55dc7d41b94a99f08a1 (agent/Dockerfile:24; a CI build log confirms this resolves to Hermes 0.18.2 β€” "Created wheel for hermes-agent: filename=hermes_agent-0.18.2..."). It materializes a content-hash-keyed root once (Blake3 payloadDigest(), main.zig:24-31; extract-to-staging then renameAbsolute so a crash or race never leaves a half-populated root, main.zig:81-116), provisions config.yaml only if absent via an O_EXCL create (main.zig:156-163), and applies no_new_privs + Landlock (confine(), main.zig:199-256) as the LAST step before execve, so the ruleset is inherited by every child and can never be widened. Timing: verified text only supports "single-digit milliseconds" warm (README.md:71); the specific "14.4 s cold / 7 ms warm" figures could not be verified in this tree β€” README.md cites a PACKAGING.md for "the real in-image number" twice and that file does not exist here. Treat those two numbers as unverified pending that file or a real measurement.
  • Landlock ABI 6 is a real observed value, not a hard-coded one. agent/hermes_acp_client.py:337-338 documents the actual stderr line the binary emits and this client forwards: [hermes-run] guard: landlock abi=6 applied=true readonly=6 writable=12 session=.... main.zig's own guard queries the ABI dynamically (landlock.queryAbi()) rather than assuming 6 β€” ABI 6 is what the deployment kernel negotiates.
  • Containment is exercised through a fixed self-test; the specific "live portal turn" provenance is not independently verified here. hermes-run --guard-selftest runs a fixed Python probe (main.zig:392-431, incl. a subprocess-escape attempt) and reports what the kernel allowed. README.md:134-139 separately quotes a terminal-tool transcript (INSIDE_OK / /tmp/escape.txt: Permission denied / ESCAPE_BLOCKED) as coming "through the product path." I found no e2e/browser artifact in this tree reproducing that transcript; the only other occurrence of that exact log-line shape is a hand-written fake in test_hermes_acp_client.py:526, written to unit-test the stderr forwarding path, not to prove a real turn produced it. The forwarding mechanism is real β€” hermes_acp_client.py:330-371 streams every hermes-run stderr line, guard line included, to the service logger on every turn, success or failure β€” so a real turn reproducing this is plausible, but this repo's evidence does not itself pin the transcript's origin to a live end-user turn, and this exact figure has been repeated elsewhere today without a traceable source β€” treat it as reported, not proven.
  • Bake-time patch pipeline, verified fail-closed. scripts/apply-patches.sh git apply --checks every patches/*.patch before applying any of them; the first one that doesn't apply cleanly aborts the whole stage, names itself and the pin, and leaves the tree untouched (apply-patches.sh:51-59). PROVENANCE is written into the payload at stage time (stage-payload.sh:136-143) and surfaced by hermes-run --version (main.zig:127-141). patches/ is empty today β€” the normal state, not an oversight.
  • Namespace containment confirmed unavailable on both targets, at the level this repo's text documents. README.md:50-52: "Railway denies unshare even as uid 0, Fargate offers no privileged mode or CAP_SYS_ADMIN, and bwrap is absent from the agent image" β€” restating this ADR's first superseding banner below it. I could not find, in this repo, an independent Terraform/ECS task-definition citation for the more granular claims that Fargate's capabilities.add is limited to SYS_PTRACE or that linuxParameters.devices is absent; those read as firsthand platform observations rather than something grep-able here β€” not contradicted, just outside what this repo's text proves.
  • The dead managed-turn feature-flag branches were removed on 2026-08-05. prompt_assembler_enabled, task classification, KB/directive injection, and per-turn tool grants no longer compute a second prompt or tool authority. A warm ACP turn carries the exact inbound message. Stable release assets and any stable response style are written once through native AgentFS session configuration at creation, while Hermes owns normal tool discovery and MCP only supplements missing native capabilities. Historical implementation plans for the deleted branches are retained only in Git history.

Addendum, 2026-07-27 β€” runners/hermes-binary's embedded shell tool (worker/, bun build --compile): python3, js-exec, and sqlite3 are all currently disabled, for two different, both-real reasons. js-exec/sqlite3 route untrusted guest code through just-bash's WorkerDefenseInDepth β€” a Proxy-based global-primitive security wrapper that sandboxes what that guest code can touch (the actual isolation boundary for code executed via js-exec/sqlite3). Confirmed via six independent isolation tests (original execution order, reordered before WASM load, security class moved to a separate module, WASM pre-warmed before activation, activation removed entirely, and a minimal Function-blocking-proxy replica): actually calling WorkerDefenseInDepth.activate(), at any point in the worker thread's lifetime regardless of ordering or file layout, permanently corrupts Bun's own internal dynamic-import resolution for the rest of that compiled binary's worker-thread process β€” manifesting as a misleading Cannot find package 'quickjs-emscripten'/'sql.js' runtime error that has nothing to do with either package actually being missing (both resolve cleanly via a minimal probe run alongside the exact same primary graph). Disabling activate() "fixes" the symptom by removing the sandbox around guest code β€” not a trade made here, matching this ADR's own no-bypass invariant in spirit: a shell command is not shipped by quietly running its guest code unconfined.

python3 has no equivalent wrapper dependency, and its own known blocker (Bun cannot resolve a node:worker_threads Worker's entrypoint when given new URL(spec, import.meta.url) β€” oven-sh/bun#29124) is fixed, bake-time, in scripts/stage-payload.sh. That fix is confirmed necessary but was NOT sufficient: end-to-end verification on darwin-arm64 looked complete, but darwin-arm64 was the only platform ever exercised. The first real run on the actual linux-arm64-gnu target hung 90+ seconds β€” confirmed via strace as genuinely stuck (a futex-wait loop, near-zero CPU), Landlock ruled out β€” before surfacing the same ModuleNotFound shape bundled with a timeout, not yet isolated further. python3 stays disabled until this is understood on the platform that actually ships, not the one that was easiest to test on. Full mechanism, evidence, and the two open questions (WorkerDefenseInDepth's ~15 global-primitive overrides; python3's linux-arm64-gnu-only hang) are in runners/hermes-binary/README.md's "python3, js-exec, sqlite3: all three disabled, for two different reasons" section.

Correction, same day (2026-07-27) β€” the WorkerDefenseInDepth attribution above is retracted; it was a false positive. All six isolation tests cited above ran the compiled binary from a scratch directory where bun install had left a real node_modules/just-bash/dist/bundle/chunks/{js-exec,sqlite3}-worker.js on disk. Per Bun's own docs, an entrypoint specifier Bun's --compile bundler doesn't recognize as statically-known falls back to loading from disk relative to the process's cwd. The "defense disabled β†’ works" result was that fallback succeeding, not the embedded $bunfs bundle resolving β€” it could never have worked in the real, self-contained deployed binary (no node_modules/ on disk) regardless. Redone with the compiled binary moved to a directory containing nothing but itself before every run (matching the real deployed worker/ layout exactly): WorkerDefenseInDepth active vs. neutralized fail identically. Also tested and ruled out: the entrypoint literal inlined directly at the new Worker(...) call site (one of Bun's three documented guaranteed-working forms) β€” still fails, clean directory, defense fully active. Extensive hand-reconstruction of every structural element of the real code path (the aliased Worker import, just-bash's actual dynamic-import() command-loading pattern, the real runTrusted(() => new Worker(...)) wrapping via a third, previously-undocumented security class β€” DefenseInDepthBox in chunk-VACKIICN.js, distinct from WorkerDefenseInDepth β€” sibling-import count, and an AsyncLocalStorage instance eagerly constructed at a sibling chunk's top level) never reproduced the failure; swapping in the real, verbatim compiled chunk bytes into the same isolated harness reproduces it immediately, for both the literal-patched and original forms alike. The real cause is not yet isolated β€” not the specifier form alone, not WorkerDefenseInDepth, not (as far as tested) DefenseInDepthBox's mere presence. Neither defense class is being disabled or weakened to chase this; both remain the isolation boundary for guest code, and removing either was never a fix, only an artifact of a contaminated test directory. python3's own worker does instantiate WorkerDefenseInDepth too (confirmed by direct source read) β€” it is sandboxed identically to js-exec/sqlite3; that fact stands regardless of how this bug resolves. Full evidence in runners/hermes-binary/README.md's "python3, js-exec, sqlite3: all three disabled, none fully explained yet" section.

Superseded 2026-07-26. The bwrap/namespace substrate this ADR selected was never reachable from a product path and is not deployable on either target: Railway denies unshare even as uid 0, Fargate offers neither privileged mode nor CAP_SYS_ADMIN, and bwrap is absent from the agent image. Hermes ran inside a WebAssembly kernel (runners/hyperframe-runtime) that asked the host kernel for nothing, with the runtime host process as the single egress chokepoint. The supervisor, python worker, capability shell and their Terraform/K8s deployment were deleted.

Superseded again, same day (2026-07-26). The WebAssembly runtime above was itself built and mounted into the wrong container (the API image spawned it in-process) and has been removed, including runners/hyperframe-runtime/ and the HyperframeSandboxBridge/SessionSandboxBridge API-side wiring. It is replaced by a self-contained agent-side binary (runners/hermes-binary) that materializes a content-hash-keyed CPython + pinned Hermes payload and applies a Landlock ruleset before exec'ing Hermes, launched by agent/hermes_acp_client.py inside the humanwork-agent container via HERMES_ACP_CLI, proven under plain Docker with no privileges. The API-side HermesRunScheduler reverted to the local_process/local_docker/ecs substrate selection this ADR's supervisor was never wired behind in the first place.

ADR-022: Sandbox substrate for runtime capability execution

Date: 2026-05-28 Status: Target locked for implementation; production cutover is NO_GO until AlexD / Paul / Eusden cosign is actually recorded. Humanwork Sandbox Supervisor with supervised bubblewrap backend is the target shipped substrate for hostile user-space runtime isolation.

2026-06-01 β€” Renumbered from ADR-008 β†’ ADR-022 to resolve a numbering collision with ADR-008 (KB architecture K1 + K2). Previously lived at docs/architecture/decisions/008-sandbox-substrate.md; the secondary docs/architecture/decisions/ ADR root has been folded into the primary docs/decisions/ tree.

2026-06-01 β€” Discussion update: Slack thread 1780054357 (cosign ask) saw pushback on Firecracker/gVisor. Paul challenged necessity/complexity; AlexD proposed skipping Firecracker/gVisor and writing a small controlled layer like the existing capability shell.

2026-06-02 β€” Current proposal: ship a Humanwork-owned supervisor substrate that uses bubblewrap as the Linux isolation mechanism. The self-written part is the product/runtime contract: policy translation, profile generation, workspace materialization, cgroups, proxy/no-network semantics, cancellation/reaper, evidence, and fail-closed API verification. bubblewrap stays load-bearing as the namespace/mount/seccomp executor; it is not dropped.

2026-06-03 β€” Implementation lock: Tasks may build the supervisor substrate, but production readiness remains NO_GO while ADR cosign is pending. This repository does not record AlexD / Paul / Eusden cosign yet; runtime health must report that honestly instead of fabricating signatures or treating implementation progress as approval.

Production cutover / cosign gate​

Production hostile-code sandbox traffic is disabled unless all of these are true:

  1. AlexD, Paul, and Eusden cosign ADR-022 in repo history or an approved release record.
  2. Host smoke reports GO on an eligible worker host.
  3. CI units, schema drift checks, direct-spawn bypass checks, evidence validation, and hostile-code deny battery are green.
  4. The cutover verification envelope is GitHub-provider verified and signed by an out-of-repo Ed25519 cutover attestation key; supervisor health verifies it with configured trusted public key(s), not local JSON shape.
  5. Worker/supervisor health reports complete host primitive readiness.

The current state is NO_GO: adr_cosign_pending. No code path may convert that pending state into production readiness with an env flag, fake signature, unsigned provider-shaped envelope, direct spawn fallback, raw bwrap success, or degraded-success backend.

Context​

Runtime runners now route file/process/browser/network operations through typed capability operations and a compiled capability shell. That removes raw shell execution from the Python adapter, but it is not enough by itself: hostile user-space code can still attempt syscalls, process escape, filesystem traversal, resource exhaustion, and network exfiltration unless the worker owns a real OS isolation boundary.

The architecture deck rule remains explicit: do not fake sandboxing in the API process. If the deployment target cannot provide required Linux primitives, run sandbox workers somewhere that can.

Current dev state at the time of this proposal:

  • api/src/runtime-control-plane/sandbox-capability-executor.service.ts validates typed ops, rejects raw shell-shaped input, caps time/output, and dispatches to a remote worker through SANDBOX_WORKER_URL.
  • runners/zig-capability-shell/ enforces path checks, argv-only exec, scrubbed child env, and executable allowlists.
  • runners/sandbox-worker/worker.py is a Python HTTP adapter that directly runs the Zig capability shell with subprocess.run; the new target replaces that direct spawn with supervisor-owned sandbox launch.
  • runners/sandbox-worker/seccomp.json, infra/k8s/sandbox-worker.yaml, and firecracker/vmconfig.json are scaffolds; they do not by themselves provide the product substrate contract.

Options​

OptionBoundaryReuseOps burdenVerdict
A. Docker/task boundary onlyContainer runtime policy; no Humanwork per-op contractMediumLowNot enough; inherits Docker/platform limits
B. Firecracker microVMSeparate guest kernel + VM boundaryLow unless snapshottingMedium-highContingency only; not a roadmap follow-up
C. Hardened K8s podPod seccomp/NetworkPolicy/rootfs hardeningMediumMedium-highUseful deployment wrapper, not product substrate
D. gVisor/runscUser-space kernelMediumLow-mediumContested; compatibility/ops surface for default path
E. bwrap aloneNamespaces/mount jail via external binaryMediumLowMechanism only; missing product contract/evidence/reaper
F. Humanwork Supervisor + bwrap backendbwrap kernel primitives + Humanwork lifecycle/evidence/cgroups/proxy contractHighMediumRecommended target substrate
G. Humanwork direct Linux backendReimplement bwrap-style primitive orchestration ourselvesHighHighFuture fallback only if bwrap becomes a proven blocker
H. WASI/WasmtimeCapability model for WASM modulesHigh for WASM onlyMediumFuture plugin lane, not general Python/Node/browser runtime

Feasibility and decision quality​

Feasible: yes, if the target is hostile user-space runtime isolation and not kernel-escape isolation.

Good decision: yes, with gates, because it matches the existing layered architecture better than Firecracker as a default substrate and avoids prematurely reimplementing a namespace/mount jailer.

  • The control plane, manifest, no-ambient-secret policy, and Zig capability shell already remove the broad authority path.
  • The missing layer is a deterministic worker-side OS boundary and lifecycle contract.
  • bubblewrap gives the Linux primitive composition we need today: user/pid/mount/net namespaces, bind plan, tmpfs root, --die-with-parent, --new-session, seccomp FD support, and no host Docker daemon dependency.
  • Humanwork-owned supervisor gives the part bwrap does not: policy translation, evidence schema, cgroups, proxy semantics, operation registry, cancellation, process-tree reaper, TTL cleanup, and API fail-closed integration.
  • The only Firecracker-only property is a separate guest kernel. That should be an explicit escalation requirement, not an assumed roadmap follow-up.

Risk: sandboxing is sharp. The decision stays good only if implementation is evidence-driven: no production bypass, hostile probes, fail-closed production mode, no unsandboxed fallback, and explicit host-primitives readiness.

Decision proposal​

Target substrate: Humanwork Sandbox Supervisor with supervised bubblewrap backend.

The supervisor is a shipped Humanwork service/binary. It receives already-authorized typed operations from the worker endpoint, materializes a per-run/per-op workspace, builds a deterministic bwrap profile, attaches cgroups and optional supervisor-side preflight checks, launches a sandbox-init/capability-shell process under bwrap, captures bounded output, emits structured events, and tears down the slot.

bubblewrap is part of the target solution, not something we dropped. The boundary is:

API β†’ sandbox-worker β†’ Humanwork supervisor β†’ bwrap sandbox β†’ sandbox-init/capability-shell

The Humanwork-owned part is the substrate contract and enforcement glue. bubblewrap is the Linux primitive executor. A direct Linux backend is not the initial target; it only reopens if bwrap itself becomes a measured blocker after the supervisor contract and deny battery exist.

Firecracker feature coverage we need​

The goal is not to recreate a microVM. The goal is to cover the Firecracker properties Humanwork actually needs for the runtime threat model with a smaller supervisor+bwrap substrate.

Firecracker property people wantedSupervisor + bwrap coverage
Per-run clean machine shapeper-op/per-run sandbox slot + synthetic rootfs + no mutable cross-run state
Filesystem isolationbwrap mount namespace + pivot_root-style tmpfs root + bind allowlist + read-only root + optional Landlock via sandbox-init
Process isolationbwrap pid namespace + sandbox-scoped /proc + process-group/cgroup reaper
Resource containmentsupervisor-owned cgroups v2 pids/memory/cpu/io limits + timeout/cancel
Network isolationbwrap net namespace off or explicit proxy-only path; no host network fallback
Syscall reductionbwrap --seccomp profile + no_new_privs + dropped capabilities
No ambient secretsworker/supervisor env scrub + generated minimal /etc + no host credential mounts
Auditabilitysupervisor version, bwrap version, profile hash, seccomp hash, limits, denial reason, cleanup evidence

The one Firecracker feature we are not covering ourselves is the separate guest kernel / hypervisor boundary. That is not needed for the current Humanwork runtime target unless kernel-escape resistance becomes an explicit product/security requirement.

No-bypass invariant​

The supervisor is not optional in production. The final design must make bypass structurally impossible:

  • runners/sandbox-worker/worker.py must not execute capability-shell directly for production operations.
  • There is no production backend=none, sandbox=off, degraded-success, or direct-spawn escape hatch.
  • linux-bwrap-supervisor is the only backend that can satisfy production evidence gates.
  • Raw bwrap invocation without Humanwork supervisor evidence is not accepted.
  • API production mode accepts only sandboxBackend=linux-bwrap-supervisor plus required evidence fields.
  • Timeout/cancel cleanup is owned by the supervisor/cgroup boundary, not by API best-effort cleanup alone.

Threat-model boundary​

The hostile-code claim is precise:

  • In scope: adversarial user-space code trying to read host files, dump env, escape workspace paths, fork bomb, inspect sibling processes, open raw network egress, abuse allowed interpreters, or bypass typed capability policy.
  • Required boundary: bwrap user/mount/pid/ipc/net namespaces, synthetic/minimal root, seccomp syscall policy, cgroups v2 limits, no ambient secrets, no host convenience mounts, proxy-only/no-network enforcement, process-tree reaper, and event evidence.
  • Defense in depth: sandbox-init may add Landlock where available before execing the capability shell.
  • Out of scope: a kernel zero-day / malicious kernel-level escape class that requires a separate guest kernel. That is the only real Firecracker-shaped reason to reopen the substrate decision.

Consequences​

  • runners/sandbox-worker/worker.py stops invoking the capability shell directly and delegates to the supervisor.
  • Add runners/sandbox-supervisor/ as a shipped long-lived Humanwork daemon/service with a Unix-socket RPC surface, in-flight registry, cancel RPC, and linux-bwrap-supervisor backend.
  • Add bwrap as an explicit worker-host dependency, pinned and probed by readiness checks; add cgroup v2 pids/memory/cpu/io, project quota, seccomp BPF, and proxy-only primitive readiness gates.
  • Worker hosts must support bwrap requirements: unprivileged user namespaces or an approved equivalent, mount namespaces, cgroups v2, seccomp, and required filesystem layout. If a platform blocks those primitives, deploy sandbox workers on a native/systemd or dedicated K8s node class that permits them.
  • Firecracker is not a planned follow-up. Reopen it only if proof gates fail or a separate guest-kernel threat model becomes product scope.

Acceptance gates​

  • Raw shell remains impossible.
  • Production worker never invokes capability-shell directly; it talks to the long-lived supervisor daemon over the Unix socket.
  • Canonical protocol is strict camelCase JSON; caller-provided env and unknown fields reject.
  • Operation manifest maps every API op to a concrete capability-shell handler, network policy, workspace policy, and executable allowlist requirement.
  • Empty allowedExecutables is deny-all; child-spawning ops require explicit non-empty manifest-approved allowlists of canonical in-sandbox absolute executable paths only. Manifest aliases, bare executable names, PATH lookup, wildcard/glob entries, relative paths, and host paths outside the sandbox mount namespace reject before dispatch.
  • Env secret scrub is proven inside the sandboxed process.
  • Supervisor constructs evidence; sandboxed child output cannot forge sandboxBackend, hashes, versions, limits, cleanup, or Landlock fields.
  • ptrace, process VM syscalls, mount, new mount API, setns, namespace-changing clone/clone3, unshare, bpf, io_uring, keyring syscalls, raw sockets, raw egress, metadata endpoints, and host-local network probes are denied.
  • Network modes are enforced as off or explicit proxy-only; no silent downgrade and no host network fallback.
  • proxy-only is supervised netns/veth + proxy + nftables allow-only-proxy with a thread-safe per-op /30 lease from 169.254.128.0/17, or dispatch returns proxy_only_unavailable on missing primitives, pool exhaustion, or IP/route conflict.
  • Root filesystem is synthetic/read-only; writes are limited to quota-bound output/tmp/log slots.
  • Workspace cleanup is fd-relative/no-follow and race-safe against symlink/hardlink/device tricks.
  • Timeout/cancel kills the full sandbox process tree and cgroup subtree and leaves no live child.
  • Cgroup pids/memory/cpu/io limits and workspace quota are applied before user code execs.
  • Events include supervisor version/build, bwrap version/path, capability-shell hash, operation manifest hash, profile hash, seccomp hash, limits including cpuMax, ioMax, stdioMaxBytes, workspaceOutputMaxBytes, and workspaceLogsMaxBytes, optional/explicit Landlock status, exit code, duration, stdio byte counts, denial reason, and canonical cleanup result (processTreeReaped, cgroupKilled, cgroupRemoved, workspaceTornDown, quotaReleased).
  • Hostile-code deny battery proves the boundary with direct probes, not exit-code theater.
  • Host readiness is NO_GO when bwrap, namespaces, cgroup v2 controllers, project quotas, seccomp BPF, required proxy-only primitives, or runtime assets are missing.
  • CI cannot report fake production green on ineligible GitHub runners; host-gated smoke must distinguish NO_GO from GO.
  • No production operation path can bypass the supervisor, select an unsandboxed backend, accept raw bwrap evidence, or report degraded sandbox success.
  • Rollback never routes to unsandboxed direct spawn.

Canonical cleanup evidence object:

{
"processTreeReaped": true,
"cgroupKilled": false,
"cgroupRemoved": true,
"workspaceTornDown": true,
"quotaReleased": true
}

This object is required in every supervisor result. The API validator must check presence and boolean type for all five fields. For status=ok, processTreeReaped, cgroupRemoved, workspaceTornDown, and quotaReleased must be true; cgroupKilled is required factual evidence and may be false on normal exit or true when the supervisor used cgroup subtree kill during timeout/cancel/reaper fallback.

References and source-of-truth chain​

Authoritative Humanwork docs:

Code seams on dev this ADR intentionally targets:

  • API dispatch/evidence seam: api/src/runtime-control-plane/sandbox-capability-executor.service.ts
  • API ledger seam: api/src/runtime-control-plane/sandbox-capability-gateway.service.ts
  • Current direct spawn path to remove: runners/sandbox-worker/worker.py
  • Existing typed executor to keep: runners/zig-capability-shell/
  • Existing scaffolds to either absorb or supersede: runners/sandbox-worker/seccomp.json, infra/k8s/sandbox-worker.yaml, runners/sandbox-worker/firecracker/vmconfig.json

External / adjacent references: