Runs one bounded, ordinary Node.js program against read-only snapshots inside a macOS kernel sandbox and reports what the trusted host observed. It observes; it never judges, stores Evidence, writes the journal or interprets program output.
Status: "Accepted for the tested stateless macOS boundary" (build review). Receipt: executor-receipt.json (review accepted; no open material executor findings; npm run check 209/209 at acceptance, none skipped). Its files hashes equal the current source, tests and helpers, and its profile.digest equals confinedProfile().digest (checked 2026-09-28 at 574e70c). Local only: no remote, CI, release or customer value. All 27 epics remain open.
- Source:
src/confined-executor.ts - Tests:
test/confined-executor.test.ts(contract),test/confined-executor-boundaries.test.ts(tested denials, judged from host-side evidence with negative controls) - Helpers:
test/helpers/confined.ts,confined-controller.ts,confined-probe.mjs,confined-native-probe.c,confined-native-library.c - Long-form contract and limits: local development
Intelligence: none — A kernel sandbox boundary that runs one bounded process and reports what the host saw; any decision a model made here would weaken confinement.
What it hides
- Pins and identity. The runtime (node, its non-system dylib closure from Mach-O load commands, launcher bytes, OS build), the profile, and the program and input snapshots are all recomputed and compared with the caller's digests before any process exists.
- Namespace lifecycle. It claims a capacity slot, writes a private namespace, verifies it by device, inode and content, then removes it non-recursively or quarantines it.
- Confined launch.
/bin/dashsets limits, thenenv -i, thensandbox-exec -p <profile>, then the pinned node. Adata:preload checks one sandbox effect (the canary is unreadable) and waits for a go byte before the program's entry loads. - Honest collection. Reaping, pipe closure, kill, timeout, cancellation and output bounds become one frozen result:
not-started,observed(withattributable) orunresolved.
Public interface
Run (ConfinedExecutor)
new ConfinedExecutor(options: ConfinedExecutorOptions), whereConfinedExecutorOptionsis{ root: string; maxRetained?: number }(a plain object with no other keys).rootmust be absolute, normalised, without a trailing/, and match/^\/[A-Za-z0-9._@+/-]*$/.maxRetainedis an integer from 1 toMAX_RETAINED; the default is 8. Invalid options throwTypeError. The constructor touches nothing; the root is checked on everyrun.run(request: ConfinedRunRequest, signal?: AbortSignal): Promise<ConfinedRunResult>(L452).
Pins (call before run, then pass the digests)
describeRuntime(): RuntimeIdentity. ThrowsConfinedExecutorError(unsupported-host|runtime-unavailable).confinedProfile(): ProfileIdentity, which is{ name: CONFINED_PROFILE, digest }. The digest covers the profile name, template, launch script, preload, tool paths and environment names.snapshotDigest(files: readonly SnapshotFile[]): string. It issha256:oversf-confined-snapshot/1\nplus onepath\tsize\tsha256hex\nline per file in path order (an empty list is allowed). ThrowsTypeErrorfor files a snapshot cannot hold.
Types
SnapshotFile { path; content: Uint8Array }ConfinedRunRequest { runtime; profile; program: { files; digest; entry; args }; input: { files; digest }; timeoutMs; maxOutputBytes; cpuSeconds; maxDescriptors }.inputbecomes the read-only working directory.ConfinedRunResultis one of:{status:"not-started", reason: NotStartedReason, message, namespace: NamespaceDisposition | null}{status:"observed", attributable, problems, identities: ConfinedIdentities, process: ProcessFacts, namespace}{status:"unresolved", problems, identities, process, namespace: quarantined}
NotStartedReason:aborted,unsupported-host,runtime-mismatch,profile-mismatch,snapshot-mismatch,retained-limit,setup-failed.NamespaceDisposition:{state:"removed", durable}or{state:"quarantined", path, reason}.ProcessFacts:pid(diagnostic only);admission:admitted,refused,absentormalformed;limits: MeasuredLimits | null, withcpuSeconds,descriptors,coreBlocksandfileBlocks, each aLimitPair {soft, hard}ofnumber | "unlimited"in dash units (seconds, descriptors, 512-byte blocks);exit: {code, signal} | null;pipesClosed;stopped:null,timeout,cancelled,output-limit,limits-mismatchorcontrol-malformed;killSent,stdout,stderr,outputBytes,durationMs.
ConfinedIdentities { program: {digest, entry, args}; input; runtime; profile: {name, digest, instantiated}; bounds }, whereinstantiatedis the sha256 of the profile text actually passed tosandbox-execandboundsechoes the four request bounds.- Also exported:
RuntimeIdentity,RuntimeFile,RuntimeAlias,ProfileIdentity,LimitPair,MeasuredLimits,ExecutorErrorCode. ConfinedExecutorError extends Error, withcode: ExecutorErrorCode("unsupported-host" | "runtime-unavailable") andname"ConfinedExecutorError".
Constants (L76–101; CONFINED_ENVIRONMENT_KEYS at L152)
| Name | Value |
|---|---|
CONFINED_PROFILE | "sf-confined-node-stateless/1" |
RUNTIME_PROTOCOL / SNAPSHOT_PROTOCOL | "sf-confined-runtime/1" / "sf-confined-snapshot/1" |
EXECUTOR_CHANNEL | "sf:confined-executor" (diagnostic only; never Evidence) |
PROVEN_PLATFORM / PROVEN_ARCH / PROVEN_RUNTIMES | "darwin" / "arm64" / ["v26.8.1"] |
MIN_TIMEOUT_MS / MAX_TIMEOUT_MS | 1 / 300 000 |
MAX_OUTPUT_BYTES | 1 048 576 (stdout and stderr together; minimum 1) |
MAX_CPU_SECONDS | 600 (minimum 1) |
MIN_DESCRIPTORS / MAX_DESCRIPTORS | 32 / 1024 |
MAX_SNAPSHOT_FILES | 1024 per tree (program ≥ 1, input ≥ 0) |
MAX_SNAPSHOT_BYTES | 16 MiB, program and input together |
MAX_ARGS | 32 (each ≤ 1024 characters, well-formed, no NUL) |
MAX_RETAINED | 64 |
KILL_CONFIRM_MS / CLOSE_GRACE_MS | 5000 / 1000 |
CONFINED_ENVIRONMENT_KEYS | LC_ALL, TZ, HOME, TMPDIR, OPENSSL_CONF |
Not exported: control text ≤ 1024 bytes, path ≤ 256 characters and ≤ 16 segments, refusal exit code 125, go byte 0x67.
Snapshot paths are relative, at most 256 characters and 16 segments of A-Z a-z 0-9 . _ - (not . or ..). Case-insensitive duplicates and file/directory clashes are rejected. entry must be a program file ending .mjs, .cjs, .js, .mts, .cts or .ts.
Diagnostic events on EXECUTOR_CHANNEL (subscribers run synchronously at the publish, which is how the tests inject faults): counted {root, retained}, materialised {namespace}, launched {namespace, pid, profile} (the instantiated profile text), admitted {namespace, pid} (the preload's admission record arrived), collected {namespace}.
Invariants and guarantees
- Validate first. An invalid request throws
TypeError(prefixinvalid confined run request:) and an invalidsignalthrowsTypeError(signal must be an AbortSignal), both before anything is touched. Fields are read once from own data properties, accessors and extra keys are rejected, and contents are copied. A digest that does not match its files is aTypeError, notsnapshot-mismatch. - No fallback. The host (
unsupported-host), profile digest (profile-mismatch), runtime digest (runtime-mismatch) and root (setup-failed: must be a directory, canonical, owned by this user, mode& 0o077 == 0) are checked before any write. Each refusal returnsnot-startedwithnamespace: nulland creates nothing. Tested: "pinned runtime or profile mismatches start nothing and create nothing" and "an aliased or shared root is refused; leftover entries count against the retained limit". - Capacity. Every root entry counts, including foreign and leftover ones. If
readdir(root).length >= maxRetained, the result isretained-limit. A slot is then claimed with an exclusivemkdirofslot-<n>,n < maxRetained; if every slot is taken, the result isretained-limit("every slot is taken"). A retained slot stays claimed and is never reclaimed automatically (materialise). Tested: "controllers sharing a root cannot exceed maxRetained: a slot claimed after the count refuses the run" and "two controllers that both counted an empty root cannot both run with maxRetained 1". - Pre-launch integrity. The namespace is fully re-verified immediately before a synchronous spawn, with no
awaitin between. It checks dev, inode and uid of every entry; for directories the privacy bits (mode & 0o077 == 0) and the exact listing; for files that they are regular, link count 1, size and sha256. A file-mode-only change is not detected. A failed check returnssnapshot-mismatch, and the namespace is quarantined and preserved, slot included. - Gated admission.
- The launch step reports eight limit lines on fd 3.
- The host sends the go byte
0x67only if they equal the request exactly (soft = hard for CPU and descriptors; core and file size 0), and only if the signal is not aborted at that moment. A mismatch stops the process withlimits-mismatch. - The preload requires
EPERMon the canary, writessf-confined/1 admittedand closes fd 3 before the entry loads; any refusal exits 125. - The program cannot write or reopen fd 3.
- Tested: the broken, permissive and limits variants, and the EX-1 abort between spawn and collection.
- Output has no authority. The program's stdout, stderr, exit code (including 125) and forged control text never change
admissionorlimits. Tested: "control records and exit claims printed by the program are not authoritative". - Signals. SIGKILL is sent only through the live
ChildProcess, and only while it is unreaped (exitCodeandsignalCodeboth null). The PID is never a signal target afterrunreturns. - Reaping.
exitis set only when the process was reaped, andpipesClosedis reported separately.observedrequires both. Otherwise the result isunresolved, the namespace is quarantined and the unreaped handle isunref'd, not signalled again.collectsettles once: onclose,CLOSE_GRACE_MSafter exit, orKILL_CONFIRM_MSafter a stop. - Attribution.
attributableis true exactly whenproblems.length === 0. That needs admissionadmitted, no lost contact, no limits mismatch, and the runtime digest and namespace unchanged after the run. A timeout, cancellation or output limit after admission can still be attributable: a stop reason is a fact, not a Verdict. A compile error is an admitted, attributable failure. - Output bounds. At most
maxOutputBytesare kept across stdout and stderr, whileoutputBytescounts everything received before collection settles. Overflow requests a stop withoutput-limit, but the first stop reason wins and no stop is recorded after exit, so judge overflow fromoutputBytes > maxOutputBytes, not fromstoppedalone. Control text over 1024 bytes requestscontrol-malformedand makes admissionmalformed; an earlier stop reason likewise stays. - Isolation inputs. The spawn receives
env: {}, thenenv -iwith exactlyLC_ALL=C,TZ=UTC,HOME=<ns>/home,TMPDIR=<ns>/homeandOPENSSL_CONF=/dev/null. Only fds 0–3 are mapped,cwdis<ns>/input, and stdin is at end-of-file once the go byte is sent. - Cleanup. Removal re-verifies the namespace first, then unlinks only the recorded entries, children first. It stops and quarantines at the first surprise, and nothing is deleted recursively.
durable: truemeans the root was fsynced afterwards (removeNamespace). - Frozen output. Results,
problems,identities,process,limits,exitand dispositions are frozen; thestdout/stderrBuffers are not.
Failure semantics
Order of decision in
run(the first hit wins):TypeError→aborted(null) →unsupported-host→profile-mismatch→runtime-mismatch→setup-failed(root) →retained-limit(count) →setup-failed(partial namespace) orretained-limit(no free slot) →snapshot-mismatch(quarantined) →aborted→setup-failed(spawn failed) → collection.Thrown:
TypeErrorfor invalid options, request, signal or snapshot files;ConfinedExecutorErroronly fromdescribeRuntime().runreports host and runtime problems asnot-started:unsupported-host, orruntime-mismatchincluding "cannot be verified".
not-started. No process exists.namespaceis:nullif none was created;removedafter an abort, a spawn failure or a partial setup, if verification passes;quarantinedforsnapshot-mismatchor a failed removal.
A
retained-limitmessage says leftovers are never reclaimed automatically.observed, not attributable.problemsgives the reasons.- Admission
absentorrefused: the program never ran. - Admission
malformed: its start is unconfirmed. - Runtime or namespace changed after the run: the output cannot be credited to the pins. A namespace change quarantines the namespace; a runtime-only change still removes it when verification passes.
- Admission
unresolvedis unknown, not failed. The process was not confirmed gone, or its pipes stayed open. Treat resources as live, and never read it as exited, clean or retryable in place.No idempotency or retry. Each
runcreates a fresh namespace and process. A retry afterunresolved, quarantine or controller loss uses another slot, and leftovers accumulate untilretained-limit. Proven cleanup and recovery are not provided.
Trust scope
Established (tested locally, host-side evidence with negative controls):
- The stateless single-process boundary on darwin/arm64, Node v26.8.1, with
/usr/bin/sandbox-exec. The launcher binaries, the static non-system runtime closure and the OS build are pinned; the closure equals an independentotoolderivation. - Denied: every write, including under
home/,TMPDIRand/tmp; file-content reads outside the snapshots and the listed runtime and system paths (tested against a secret file and its/varalias, a SQLite database, an Evidence store, another Product, another namespace and the canary); network (TCP, IPv6, UDP, DNS, Unix sockets, listening); spawn, fork, shells and detached children; signals to and probes of other processes; executable mapping of snapshot files; inherited descriptors and environment; raising the four configured hard limits (CPU time, descriptors, core size, file size). - Effective limits measured natively inside the admitted process equal the request.
- The four tested broken-setup variants (profile syntax error,
(allow default), missingsandbox-exec, wrong CPU limit) never run the program. The canary checks one denial, not every restriction. - Launch-cancellation and shared-capacity races have permanent regressions.
Not established (receipt limits and local development):
- Any other host or runtime.
hostProblemrefuses another platform, architecture, Node version or launcher-tool state; another darwin/arm64 Mac with Node v26.8.1 is not refused, only unproven, and runs with freshly derived pins (a different OS build makes old pinsruntime-mismatch). There is no unconfined fallback. - Hard RAM or aggregate-disk limits.
RLIMIT_AS/RLIMIT_DATAcould not be set on this host. - A hard CPU-time bound: a program can handle SIGXCPU. The host requests SIGKILL when its
timeoutMstimer fires; that is not a hard execution or completion deadline, and unconfirmed termination is reported asunresolved. - Automatic termination or reclamation after the controller is lost. The orphan runs on, and its slot stays.
- Runtime immutability during a run: the runtime files are hashed before and after only; only the three launcher tools must be root-owned, and the tested Homebrew runtime files are user-owned.
- Hermetic filesystem identity: named public runtime directory subtrees, system libraries,
/,/dev/{null,random,urandom}and 12 sysctls stay readable, and metadata of the enumerated ancestors of the runtime paths and snapshot directories (slot and root included) is readable throughfile-read-metadata. Host-information disclosure is not zero. - Build tools, subprocesses, package managers and writable scratch: fork is denied.
- Consistency across controllers sharing a root with different
maxRetainedvalues.
Composition
- Depends on: Node built-ins only (
child_process,crypto,diagnostics_channel,fs,os,path,perf_hooks; type-onlystream). It needs host tools/bin/dash,/usr/bin/envand/usr/bin/sandbox-exec, each a root-owned regular file not group- or other-writable. It imports no Factory module and must never import, or be imported by, the program it runs. - Used by:
src/repository.tsimportssnapshotDigestandtype SnapshotFile.Repository.#readSealed, behindseal()andexportCandidate(), returnsSealedCandidate.snapshotDigest = snapshotDigest(files), for callers to pin asinput.digest.src/repository-assurance.ts(Repository Assurance) imports theSNAPSHOT_PROTOCOLconstant and theConfinedIdentitiesandNamespaceDispositiontypes, to compute the snapshot digest from file identities and to check recorded runs.src/repository-collector.ts(Repository collector, step 1, increment 3: accepted, not composed) callsConfinedExecutor.runonce per repository check, with a base or Candidate tree as the program, and usesdescribeRuntime,confinedProfile,snapshotDigest,MAX_SNAPSHOT_BYTESandCONFINED_PROFILE; itsplacementProblemrefuses protected paths inside whatPROFILE_TEMPLATElets a program read. Nothing composes the collector, so no Recipe, CLI,local-factory, Execution or Guarded verification runs the executor.test/repository.test.ts("an exported Candidate runs as the accepted confined executor input") proves one real run on an export.
- Related agent references: Repository sealing / export and integration · Execution · Guarded verification / recovery · Evidence / Assurance. Human page: guide.
Changing it safely
- Profile digest. Editing
CONFINED_PROFILE,PROFILE_TEMPLATE,LAUNCH_SCRIPT,PRELOAD_TEMPLATE, a tool path orCONFINED_ENVIRONMENT_KEYSchangesconfinedProfile().digest. Callers must re-pin, and the receipt'sprofile.digest(sha256:4003ae88…) goes stale. The boundaries test asserts this for every patched variant. - Runtime digest. Launcher bytes, the node binary, the dylib closure or an OS update change
describeRuntime().digest. Pins recorded elsewhere must be refreshed. - Proven runtimes. Never add to
PROVEN_RUNTIMES, relaxhostProblem, add a writable path, network, fork or signal allowance, or add an unconfined fallback without a new independent boundary review. The contract tests alone are not enough. - Keep:
- the synchronous path from the pre-launch check to
spawn; - the abort recheck in
releaseGate; - the unreaped-only SIGKILL;
- exclusive slot
mkdir; - non-recursive, verify-first removal;
(deny process-info*)with its self-only allowance:(deny default)alone does not stop the tested read of another process's arguments.
- the synchronous path from the pre-launch check to
- Run: focused,
node --test test/confined-executor.test.ts test/confined-executor-boundaries.test.ts;npm run typecheck;npm run checkbefore acceptance. Both files spawn real sandboxed processes and calldescribeRuntime()at load, so on a hosthostProblemrefuses they fail at load rather than skip; on another qualifying Mac they run with fresh pins, which proves nothing about the recorded host. Only two cases skip: theotoolclosure check (no/usr/bin/otool) and the native probe (no working/usr/bin/clang). The accepted run had none skipped. - Which tests prove what:
confined-executor.test.ts: pins, validation, root, retained limit, abort, timeout, output, CPU/descriptor limits, forged control, swaps, hard links, attribution and theotoolclosure.confined-executor-boundaries.test.ts: filesystem, network, process, native, actual-launch limits, descriptors and environment, controller loss, two-controller capacity and broken profiles.
- After a change:
- After fresh independent review, create a new successor receipt with the updated
fileshashes,runtimeandprofile,validationandreview; keep executor-receipt.json unchanged. - Update the Confined executor row in build-review.md, its section in local-development.md and the combined model.
- Keep this file and the guide page in step.
- After fresh independent review, create a new successor receipt with the updated
