Factory docs, home
Page navigation

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/dash sets limits, then env -i, then sandbox-exec -p <profile>, then the pinned node. A data: 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 (with attributable) or unresolved.

Public interface

Run (ConfinedExecutor)

  • new ConfinedExecutor(options: ConfinedExecutorOptions), where ConfinedExecutorOptions is { root: string; maxRetained?: number } (a plain object with no other keys). root must be absolute, normalised, without a trailing /, and match /^\/[A-Za-z0-9._@+/-]*$/. maxRetained is an integer from 1 to MAX_RETAINED; the default is 8. Invalid options throw TypeError. The constructor touches nothing; the root is checked on every run.
  • run(request: ConfinedRunRequest, signal?: AbortSignal): Promise<ConfinedRunResult> (L452).

Pins (call before run, then pass the digests)

  • describeRuntime(): RuntimeIdentity. Throws ConfinedExecutorError (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 is sha256: over sf-confined-snapshot/1\n plus one path\tsize\tsha256hex\n line per file in path order (an empty list is allowed). Throws TypeError for 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 }. input becomes the read-only working directory.
  • ConfinedRunResult is 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, absent or malformed;
    • limits: MeasuredLimits | null, with cpuSeconds, descriptors, coreBlocks and fileBlocks, each a LimitPair {soft, hard} of number | "unlimited" in dash units (seconds, descriptors, 512-byte blocks);
    • exit: {code, signal} | null;
    • pipesClosed;
    • stopped: null, timeout, cancelled, output-limit, limits-mismatch or control-malformed;
    • killSent, stdout, stderr, outputBytes, durationMs.
  • ConfinedIdentities { program: {digest, entry, args}; input; runtime; profile: {name, digest, instantiated}; bounds }, where instantiated is the sha256 of the profile text actually passed to sandbox-exec and bounds echoes the four request bounds.
  • Also exported: RuntimeIdentity, RuntimeFile, RuntimeAlias, ProfileIdentity, LimitPair, MeasuredLimits, ExecutorErrorCode.
  • ConfinedExecutorError extends Error, with code: ExecutorErrorCode ("unsupported-host" | "runtime-unavailable") and name "ConfinedExecutorError".

Constants (L76–101; CONFINED_ENVIRONMENT_KEYS at L152)

NameValue
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_MS1 / 300 000
MAX_OUTPUT_BYTES1 048 576 (stdout and stderr together; minimum 1)
MAX_CPU_SECONDS600 (minimum 1)
MIN_DESCRIPTORS / MAX_DESCRIPTORS32 / 1024
MAX_SNAPSHOT_FILES1024 per tree (program ≥ 1, input ≥ 0)
MAX_SNAPSHOT_BYTES16 MiB, program and input together
MAX_ARGS32 (each ≤ 1024 characters, well-formed, no NUL)
MAX_RETAINED64
KILL_CONFIRM_MS / CLOSE_GRACE_MS5000 / 1000
CONFINED_ENVIRONMENT_KEYSLC_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

  1. Validate first. An invalid request throws TypeError (prefix invalid confined run request:) and an invalid signal throws TypeError (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 a TypeError, not snapshot-mismatch.
  2. 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 returns not-started with namespace: null and 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".
  3. Capacity. Every root entry counts, including foreign and leftover ones. If readdir(root).length >= maxRetained, the result is retained-limit. A slot is then claimed with an exclusive mkdir of slot-<n>, n < maxRetained; if every slot is taken, the result is retained-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".
  4. Pre-launch integrity. The namespace is fully re-verified immediately before a synchronous spawn, with no await in 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 returns snapshot-mismatch, and the namespace is quarantined and preserved, slot included.
  5. Gated admission.
    • The launch step reports eight limit lines on fd 3.
    • The host sends the go byte 0x67 only 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 with limits-mismatch.
    • The preload requires EPERM on the canary, writes sf-confined/1 admitted and 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.
  6. Output has no authority. The program's stdout, stderr, exit code (including 125) and forged control text never change admission or limits. Tested: "control records and exit claims printed by the program are not authoritative".
  7. Signals. SIGKILL is sent only through the live ChildProcess, and only while it is unreaped (exitCode and signalCode both null). The PID is never a signal target after run returns.
  8. Reaping. exit is set only when the process was reaped, and pipesClosed is reported separately. observed requires both. Otherwise the result is unresolved, the namespace is quarantined and the unreaped handle is unref'd, not signalled again. collect settles once: on close, CLOSE_GRACE_MS after exit, or KILL_CONFIRM_MS after a stop.
  9. Attribution. attributable is true exactly when problems.length === 0. That needs admission admitted, 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.
  10. Output bounds. At most maxOutputBytes are kept across stdout and stderr, while outputBytes counts everything received before collection settles. Overflow requests a stop with output-limit, but the first stop reason wins and no stop is recorded after exit, so judge overflow from outputBytes > maxOutputBytes, not from stopped alone. Control text over 1024 bytes requests control-malformed and makes admission malformed; an earlier stop reason likewise stays.
  11. Isolation inputs. The spawn receives env: {}, then env -i with exactly LC_ALL=C, TZ=UTC, HOME=<ns>/home, TMPDIR=<ns>/home and OPENSSL_CONF=/dev/null. Only fds 0–3 are mapped, cwd is <ns>/input, and stdin is at end-of-file once the go byte is sent.
  12. 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: true means the root was fsynced afterwards (removeNamespace).
  13. Frozen output. Results, problems, identities, process, limits, exit and dispositions are frozen; the stdout/stderr Buffers 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) or retained-limit (no free slot) → snapshot-mismatch (quarantined) → aborted → setup-failed (spawn failed) → collection.

  • Thrown:

    • TypeError for invalid options, request, signal or snapshot files;
    • ConfinedExecutorError only from describeRuntime(). run reports host and runtime problems as not-started: unsupported-host, or runtime-mismatch including "cannot be verified".
  • not-started. No process exists. namespace is:

    • null if none was created;
    • removed after an abort, a spawn failure or a partial setup, if verification passes;
    • quarantined for snapshot-mismatch or a failed removal.

    A retained-limit message says leftovers are never reclaimed automatically.

  • observed, not attributable. problems gives the reasons.

    • Admission absent or refused: 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.
  • unresolved is 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 run creates a fresh namespace and process. A retry after unresolved, quarantine or controller loss uses another slot, and leftovers accumulate until retained-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 independent otool derivation.
  • Denied: every write, including under home/, TMPDIR and /tmp; file-content reads outside the snapshots and the listed runtime and system paths (tested against a secret file and its /var alias, 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), missing sandbox-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. hostProblem refuses 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 pins runtime-mismatch). There is no unconfined fallback.
  • Hard RAM or aggregate-disk limits. RLIMIT_AS/RLIMIT_DATA could not be set on this host.
  • A hard CPU-time bound: a program can handle SIGXCPU. The host requests SIGKILL when its timeoutMs timer fires; that is not a hard execution or completion deadline, and unconfirmed termination is reported as unresolved.
  • 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 through file-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 maxRetained values.

Composition

  • Depends on: Node built-ins only (child_process, crypto, diagnostics_channel, fs, os, path, perf_hooks; type-only stream). It needs host tools /bin/dash, /usr/bin/env and /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.ts imports snapshotDigest and type SnapshotFile. Repository.#readSealed, behind seal() and exportCandidate(), returns SealedCandidate.snapshotDigest = snapshotDigest(files), for callers to pin as input.digest.
    • src/repository-assurance.ts (Repository Assurance) imports the SNAPSHOT_PROTOCOL constant and the ConfinedIdentities and NamespaceDisposition types, 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) calls ConfinedExecutor.run once per repository check, with a base or Candidate tree as the program, and uses describeRuntime, confinedProfile, snapshotDigest, MAX_SNAPSHOT_BYTES and CONFINED_PROFILE; its placementProblem refuses protected paths inside what PROFILE_TEMPLATE lets 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 or CONFINED_ENVIRONMENT_KEYS changes confinedProfile().digest. Callers must re-pin, and the receipt's profile.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, relax hostProblem, 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.
  • Run: focused, node --test test/confined-executor.test.ts test/confined-executor-boundaries.test.ts; npm run typecheck; npm run check before acceptance. Both files spawn real sandboxed processes and call describeRuntime() at load, so on a host hostProblem refuses 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: the otool closure 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 the otool closure.
    • 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:

Source: docs/agents/confined-executor.md