Addendum, 2026-07-27 (later still still) β
js-execis 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()andevalCode()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 SAMEimport.meta.urldefect (oven-sh/bun#29124) one layer deeper still: quickjs-emscripten's own WASM loader (@jitl/quickjs-wasmfile-release-sync) falls back tonew URL("emscripten-module.wasm", import.meta.url)when given nolocateFile, 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.wasmpath 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 itsSyncBackend) 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.errorbreadcrumbs 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 throughquickjs-emscripten's package entrypoint), wrappingRELEASE_SYNCβ the variantgetQuickJS()uses by default β to inject alocateFileresolving to aprocess.execPath-derived path, the same pattern already used for sqlite3, with the real.wasmfile 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 thestripTypeScriptTypesshim.js-execis FIXED AND VERIFIED: realjs-exec -c "console.log(3+3)"returns6in well under 100ms (not a 10s timeout), a non-trivial loop-and-accumulate expression evaluates correctly, andjs-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/DefenseInDepthBoxwere 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 inrunners/hermes-binary/README.md's "python3,sqlite3,js-exec: all three fixed and verified" section.
Addendum, 2026-07-27 (later still) β
python3/sqlite3fixed and verified;js-execfixed 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 --compilebinary,import.meta.url/.dir/.pathfor ANY non-entrypoint module report the PRIMARY ENTRYPOINT's own identity, not the module's own actual location β confirmed by directimport.metaintrospection 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 whyWorkerDefenseInDepth/DefenseInDepthBoxwere 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
--compileentrypoint (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 andsqlite3'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--compileembedding regardless β fixed by materializing them to real disk (like the CPython interpreter itself) and resolving viaprocess.execPath, the compiled worker's own real on-disk location (reliable regardless of caller cwd βjustbash_driver.py'sPopencall does not set one).python3andsqlite3are FIXED AND VERIFIED: realpython3 -c "print(2+2)"returns4, realsqlite3 :memory: "SELECT 6*7;"returns42, both on real linux-arm64-gnu Docker.js-exec's quickjs-emscripten statically importsstripTypeScriptTypesfromnode: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 trivialconsole.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 (javascriptunset) rather than shipped hanging on every call.Neither
WorkerDefenseInDepthnorDefenseInDepthBoxwas touched, disabled, or weakened at any point across any of this investigation. Full mechanism and evidence inrunners/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
/procwidened from/proc/selfto 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'ssubprocess.Popeninside a running Hermes turn, which forks a NEW pid before exec'ing the worker β while starting fine unconfined and via a single-hophermes-run --shell-exec. Root cause, confirmed live: the original/proc/selfLandlock rule is bound at rule-creation time to the directory/proc/<hermes-run's own pid>resolves to at that moment;execvepreserves 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/mapsbut gotPermissionError(13, 'Permission denied')reading/proc/<the forked child's pid>/mapsfor that same child β and JavaScriptCore's allocator (bmalloc) reads/proc/self/mapson 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/selfto/procitself. 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 viaHERMES_*/HUMANWORK_*env vars), scoped to other processes inside the same confined container only (never the host, never another container). Fixed and empirically verified: rebuilt, restartedhumanwork-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 aread_filecall in the same turn) persisting in AgentFS. Full writeup:runners/hermes-binary/README.md's "The/procLandlock 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-binaryzig 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 ondankovk/new-agent-runtime-follow-up(2026-07-27). This work is now committed locally but not yet pushed: as of writing, localHEADis 9+ commits ahead oforigin/dankovk/new-agent-runtime-follow-up(still3bedad883) β 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 injectsManagedHermesTurnServiceand pins the tool-grant intersection onto the durableAgentRunrow 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 theAgentRunrepository,RunTokenService(mints the run token), andAgentClient(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_dockerand 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.AgentClientreaches a long-lived agent container.agent.client.tsresolves its base URL fromAGENT_SERVICE_URL(agent.client.ts:506). The agent container'shermes_acp_client.pyexecs whateverHERMES_ACP_CLInames;agent/DockerfilesetsHERMES_ACP_CLI=/usr/local/bin/hermes-run, copied there from thehermes-builderstage (Dockerfile:110,133).hermes-runis 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 commit31c08a9aad6e83ded5d0e55dc7d41b94a99f08a1(agent/Dockerfile:24; a CI build log confirms this resolves to Hermes0.18.2β "Created wheel for hermes-agent: filename=hermes_agent-0.18.2..."). It materializes a content-hash-keyed root once (Blake3payloadDigest(),main.zig:24-31; extract-to-staging thenrenameAbsoluteso a crash or race never leaves a half-populated root,main.zig:81-116), provisionsconfig.yamlonly if absent via anO_EXCLcreate (main.zig:156-163), and appliesno_new_privs+ Landlock (confine(),main.zig:199-256) as the LAST step beforeexecve, 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.mdcites aPACKAGING.mdfor "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-338documents 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-selftestruns a fixed Python probe (main.zig:392-431, incl. a subprocess-escape attempt) and reports what the kernel allowed.README.md:134-139separately 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 intest_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-371streams everyhermes-runstderr 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.shgit apply --checks everypatches/*.patchbefore 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).PROVENANCEis written into the payload at stage time (stage-payload.sh:136-143) and surfaced byhermes-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 deniesunshareeven as uid 0, Fargate offers no privileged mode orCAP_SYS_ADMIN, andbwrapis 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'scapabilities.addis limited toSYS_PTRACEor thatlinuxParameters.devicesis 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, andsqlite3are all currently disabled, for two different, both-real reasons.js-exec/sqlite3route untrusted guest code through just-bash'sWorkerDefenseInDepthβ a Proxy-based global-primitive security wrapper that sandboxes what that guest code can touch (the actual isolation boundary for code executed viajs-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 minimalFunction-blocking-proxy replica): actually callingWorkerDefenseInDepth.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 misleadingCannot 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). Disablingactivate()"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.
python3has no equivalent wrapper dependency, and its own known blocker (Bun cannot resolve anode:worker_threadsWorker's entrypoint when givennew URL(spec, import.meta.url)β oven-sh/bun#29124) is fixed, bake-time, inscripts/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 viastraceas genuinely stuck (afutex-wait loop, near-zero CPU), Landlock ruled out β before surfacing the sameModuleNotFoundshape bundled with a timeout, not yet isolated further.python3stays 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 inrunners/hermes-binary/README.md's "python3,js-exec,sqlite3: all three disabled, for two different reasons" section.
Correction, same day (2026-07-27) β the
WorkerDefenseInDepthattribution above is retracted; it was a false positive. All six isolation tests cited above ran the compiled binary from a scratch directory wherebun installhad left a realnode_modules/just-bash/dist/bundle/chunks/{js-exec,sqlite3}-worker.json disk. Per Bun's own docs, an entrypoint specifier Bun's--compilebundler 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$bunfsbundle resolving β it could never have worked in the real, self-contained deployed binary (nonode_modules/on disk) regardless. Redone with the compiled binary moved to a directory containing nothing but itself before every run (matching the real deployedworker/layout exactly):WorkerDefenseInDepthactive vs. neutralized fail identically. Also tested and ruled out: the entrypoint literal inlined directly at thenew 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 aliasedWorkerimport,just-bash's actual dynamic-import()command-loading pattern, the realrunTrusted(() => new Worker(...))wrapping via a third, previously-undocumented security class βDefenseInDepthBoxinchunk-VACKIICN.js, distinct fromWorkerDefenseInDepthβ sibling-import count, and anAsyncLocalStorageinstance 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, notWorkerDefenseInDepth, 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 instantiateWorkerDefenseInDepthtoo (confirmed by direct source read) β it is sandboxed identically tojs-exec/sqlite3; that fact stands regardless of how this bug resolves. Full evidence inrunners/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
unshareeven as uid 0, Fargate offers neither privileged mode norCAP_SYS_ADMIN, andbwrapis 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 theHyperframeSandboxBridge/SessionSandboxBridgeAPI-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 byagent/hermes_acp_client.pyinside thehumanwork-agentcontainer viaHERMES_ACP_CLI, proven under plain Docker with no privileges. The API-sideHermesRunSchedulerreverted to thelocal_process/local_docker/ecssubstrate 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 secondarydocs/architecture/decisions/ADR root has been folded into the primarydocs/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
bubblewrapas 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.bubblewrapstays 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_GOwhile 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:
- AlexD, Paul, and Eusden cosign ADR-022 in repo history or an approved release record.
- Host smoke reports
GOon an eligible worker host. - CI units, schema drift checks, direct-spawn bypass checks, evidence validation, and hostile-code deny battery are green.
- 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.
- 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.tsvalidates typed ops, rejects raw shell-shaped input, caps time/output, and dispatches to a remote worker throughSANDBOX_WORKER_URL.runners/zig-capability-shell/enforces path checks, argv-only exec, scrubbed child env, and executable allowlists.runners/sandbox-worker/worker.pyis a Python HTTP adapter that directly runs the Zig capability shell withsubprocess.run; the new target replaces that direct spawn with supervisor-owned sandbox launch.runners/sandbox-worker/seccomp.json,infra/k8s/sandbox-worker.yaml, andfirecracker/vmconfig.jsonare scaffolds; they do not by themselves provide the product substrate contract.
Optionsβ
| Option | Boundary | Reuse | Ops burden | Verdict |
|---|---|---|---|---|
| A. Docker/task boundary only | Container runtime policy; no Humanwork per-op contract | Medium | Low | Not enough; inherits Docker/platform limits |
| B. Firecracker microVM | Separate guest kernel + VM boundary | Low unless snapshotting | Medium-high | Contingency only; not a roadmap follow-up |
| C. Hardened K8s pod | Pod seccomp/NetworkPolicy/rootfs hardening | Medium | Medium-high | Useful deployment wrapper, not product substrate |
| D. gVisor/runsc | User-space kernel | Medium | Low-medium | Contested; compatibility/ops surface for default path |
| E. bwrap alone | Namespaces/mount jail via external binary | Medium | Low | Mechanism only; missing product contract/evidence/reaper |
| F. Humanwork Supervisor + bwrap backend | bwrap kernel primitives + Humanwork lifecycle/evidence/cgroups/proxy contract | High | Medium | Recommended target substrate |
| G. Humanwork direct Linux backend | Reimplement bwrap-style primitive orchestration ourselves | High | High | Future fallback only if bwrap becomes a proven blocker |
| H. WASI/Wasmtime | Capability model for WASM modules | High for WASM only | Medium | Future 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.
bubblewrapgives 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 wanted | Supervisor + bwrap coverage |
|---|---|
| Per-run clean machine shape | per-op/per-run sandbox slot + synthetic rootfs + no mutable cross-run state |
| Filesystem isolation | bwrap mount namespace + pivot_root-style tmpfs root + bind allowlist + read-only root + optional Landlock via sandbox-init |
| Process isolation | bwrap pid namespace + sandbox-scoped /proc + process-group/cgroup reaper |
| Resource containment | supervisor-owned cgroups v2 pids/memory/cpu/io limits + timeout/cancel |
| Network isolation | bwrap net namespace off or explicit proxy-only path; no host network fallback |
| Syscall reduction | bwrap --seccomp profile + no_new_privs + dropped capabilities |
| No ambient secrets | worker/supervisor env scrub + generated minimal /etc + no host credential mounts |
| Auditability | supervisor 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.pymust not executecapability-shelldirectly for production operations.- There is no production
backend=none,sandbox=off, degraded-success, or direct-spawn escape hatch. linux-bwrap-supervisoris the only backend that can satisfy production evidence gates.- Raw
bwrapinvocation without Humanwork supervisor evidence is not accepted. - API production mode accepts only
sandboxBackend=linux-bwrap-supervisorplus 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.pystops 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, andlinux-bwrap-supervisorbackend. - 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-shelldirectly; it talks to the long-lived supervisor daemon over the Unix socket. - Canonical protocol is strict camelCase JSON; caller-provided
envand unknown fields reject. - Operation manifest maps every API op to a concrete capability-shell handler, network policy, workspace policy, and executable allowlist requirement.
- Empty
allowedExecutablesis 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-changingclone/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
offor explicitproxy-only; no silent downgrade and no host network fallback. proxy-onlyis supervised netns/veth + proxy + nftables allow-only-proxy with a thread-safe per-op/30lease from169.254.128.0/17, or dispatch returnsproxy_only_unavailableon 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, andworkspaceLogsMaxBytes, 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_GOwhen 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_GOfromGO. - 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:
- Architecture deck:
docs/architecture/ISOLATION_SANDBOXING_BINARIES.md - Runtime port plan:
docs/architecture/RIG_HARD_CUT_RUNTIME_PORT_PLAN.md - Splinter/Rig port plan:
docs/architecture/SPLINTER_PORTING_PLAN.md - Isolation matrix:
docs/decisions/020-isolation-classification.md - Cutover runbook:
docs/_archive/RIG_RUNTIME_HARD_CUT_CUTOVER.md(archived 2026-06-04) - Detailed design:
docs/architecture/HUMANWORK_SANDBOX_SUPERVISOR.md - Implementation plan:
docs/superpowers/plans/2026-06-02-humanwork-sandbox-supervisor.md
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:
- Rig bwrap reference backend: https://github.com/humanity-org/rig/blob/main/packages/runtime/src/control-plane/runtime/sandbox/backend-bwrap.ts
- Rig sandbox orchestrator: https://github.com/humanity-org/rig/blob/main/packages/runtime/src/control-plane/runtime/sandbox/orchestrator.ts
- bubblewrap project: https://github.com/containers/bubblewrap
- Linux namespaces: https://man7.org/linux/man-pages/man7/namespaces.7.html
- seccomp: https://docs.kernel.org/userspace-api/seccomp_filter.html
- cgroup v2: https://docs.kernel.org/admin-guide/cgroup-v2.html
- Landlock: https://docs.kernel.org/userspace-api/landlock.html