Skip to content

Tool Execution

The Kernel Runtime page explains the 8-step pipeline that every tool call passes through. This page explains what happens inside step 7 (dispatch) — how the kernel routes a tool call into a sandboxed subprocess, how the pack’s tool implementation runs, and how the runtime CLI adapter bridges ~80 tools to existing CLI command modules.

Tool execution flows through three layers. The kernel dispatches into a sandboxed subprocess, where the pack’s tool-runner worker loads the tool implementation, which either calls the runtime CLI adapter or executes directly.

Diagram
LayerPackageResponsibility
Kernel@hellmai/lumenflow-kernelDomain-agnostic orchestration: scope enforcement, policy evaluation, evidence recording, sandbox dispatch
Packsoftware-deliveryTool implementations: 90+ exported functions grouped by domain (WU lifecycle, memory, git, etc.)
CLI@hellmai/lumenflow-cliBusiness logic: each command module exports a main() function that performs the actual work

Here is the full path a tool call takes, using wu:create as an example.

Diagram

Trusted provenance before consequential dispatch

Section titled “Trusted provenance before consequential dispatch”

Trusted read adapters attach a bounded provenance label to returned content. The agent runtime persists labels with tool-result messages and restores them with session/run state. Trusted capability effect_classification identifies consequential dispatches, defaulting to the permission-derived classification when absent. The tool host loads labels from the current root session or executing workflow node before each consequential dispatch. A label in active context requires human approval; the host reloads current labels when an approval is consumed, so an earlier approval cannot authorize a changed context. Missing or malformed persisted context holds dispatch. Agent-runtime provider computation is classified non_consequential for this effect check because it proposes steps by returning model text. The provider call remains governed by authenticated caller, network, credential, budget, purpose, and base policy controls; requested external effects remain consequential and gated.

Hosts replacing active context with a summary use replaceGovernedAgentWorkingContextWithSummary({ workflowStateStore, sessionId, executionSelector, summary, nodeId? }). It derives labels from the current persisted messages and commits the replacement through the existing fenced checkpoint compare-and-swap. The engine does not schedule compaction automatically.

The active context supports at most 64 labels, with nonempty source_id values no longer than 256 characters. Malformed or excessive label data holds dispatch instead of being truncated or treated as clean.

Hosts may opt in to a pre-dispatch monitor. It receives the proposed step and current labels and can leave the decision unchanged, hold, or deny. It cannot grant permission or reverse a policy denial; an invalid or unavailable result holds the call. Policy and monitor denials write a coalesced provenance_owner_notice_recorded event to the run aggregate. The event carries scope, a coalescing key, and bounded, sorted, deduplicated kind-prefixed source references, not external message bodies. There are at most 64 references, each no longer than 300 characters. The notice key is based on stable source references and decision classes, not diagnostic text. Invalid or excessive data, or notice persistence failure, holds the action.

This boundary emits the durable notice for a host or downstream adapter to consume.

The Software Delivery Pack uses two strategies for implementing its 90+ tools.

Most tools delegate to existing CLI command modules via the runtime CLI adapter. The tool implementation function builds an argument array and calls runtimeCliAdapter.run(command, args), which dynamically imports the corresponding CLI module and calls its main() function.

Tool domains that use this path:

DomainTools
Work unit create, claim, prep, done, block, edit, etc.~21
File read/write/edit/delete, config, validation, etc.~20
Checkpoint, inbox, signal, recover, etc.~14
Initiative create, plan, status, orchestrate, etc.~11
Agent session management, delegation~8
Flow reports, bottleneck analysis, metrics~6

A smaller set of tools are self-contained implementations that do not use the CLI adapter. These use simple-git wrappers or Node builtins directly.

ToolWhy direct
git:statusNeeds fine-grained control over git binary invocation and output parsing

Direct implementations follow the same ToolOutput interface and receive the same ToolRunnerWorkerContext — the kernel treats both paths identically.

The runtime CLI adapter is the bridge that lets pack tools reuse CLI command modules without spawning a child process for each command. It loads CLI modules in-process and captures their output.

All adapter invocations are serialized through runExclusive() — a promise-based queue that ensures only one CLI module runs at a time. This prevents concurrent mutations to process.argv and other patched globals.

// Simplified
let executionQueue: Promise<void> = Promise.resolve();

async function runExclusive<T>(operation: () => Promise<T>): Promise<T> {
  const scheduled = executionQueue.then(operation, operation);
  executionQueue = scheduled.then(
    () => undefined,
    () => undefined,
  );
  return scheduled;
}

Before calling a CLI module’s main(), the adapter patches six global surfaces:

GlobalPatchWhy
process.argvSet to [execPath, command, ...args]CLI modules parse arguments from process.argv
process.exitReplaced with a function that throws RuntimeCliExitSignalPrevents the subprocess from actually exiting; captures the exit code instead
process.stdout.writeRedirected to a capture bufferCaptures stdout output as a string
process.stderr.writeRedirected to a capture bufferCaptures stderr output as a string
console.log/info/debugRedirected to stdout capture bufferCaptures console output that would otherwise go to the real stdout
console.error/warnRedirected to stderr capture bufferCaptures console errors that would otherwise go to the real stderr

All patches are restored in a finally block, guaranteeing cleanup even when the CLI module throws.

CLI modules call process.exit(code) to signal success or failure. In a normal Node process, this terminates the process. The adapter replaces process.exit with a function that throws a RuntimeCliExitSignal error:

// Simplified
class RuntimeCliExitSignal extends Error {
  readonly exitCode: number;
  constructor(exitCode: number) {
    super(`CLI requested process exit (${exitCode})`);
    this.exitCode = exitCode;
  }
}

The adapter’s try/catch block recognizes this error and captures the exit code without terminating the worker process.

CLI modules are resolved from the built output of the installed @hellmai/lumenflow-cli package. The adapter locates the package, converts the resolved path to a file:// URL and uses dynamic import() to load the module.

The kernel uses bwrap (bubblewrap) to create a sandboxed subprocess for each tool execution. This provides OS-level isolation. On macOS and Windows the kernel refuses autonomous subprocess tools.

The public @hellmai/lumenflow-kernel/sandbox barrel exposes host-side sandbox builders and dispatchers. Executable worker helpers live behind the narrower @hellmai/lumenflow-kernel/sandbox/tool-runner-worker subpath so server bundlers that import @hellmai/lumenflow-kernel do not accidentally bundle the worker process entrypoint.

Diagram

The invocation payload sent on stdin contains:

FieldPurpose
tool_nameWhich tool to execute
handler_entryHost-registered module/export from the loaded capability; never request-selected
inputThe tool’s input data
scope_enforcedDiagnostic copy of the computed intersection; physical mounts remain authoritative
receipt_idUUID linking this execution to the evidence trace

The response on stdout is a JSON object with an output field containing the standard ToolOutput structure (success, data, error, metadata).

The bwrap sandbox profile is constructed from the enforced scopes. Literal files are opened and pinned by the host, then passed to Bubblewrap with --ro-bind-fd or --bind-fd. The dispatcher requires a Bubblewrap build that supports both flags. Directory patterns are materialized only as the exact mounts the profile can prove safe; it does not expose the workspace root as a convenience mount. Paths outside the scope intersection are not mounted — the tool literally cannot see them.

The host rejects symlinks, hard-linked files, non-regular files, FIFOs, identity changes between inspection and open, and mounts that escape the canonical workspace. The opened descriptor, rather than a later path lookup, is the source for the sandbox mount. Protected workspace authority and credential paths, including .env, remain denied.

Kernel fs:write uses a separate no-follow broker instead of giving an in-process handler ambient filesystem access. It walks pinned directory descriptors, permits only exact literal files or literal directory/** scopes, rejects protected paths and linked/non-regular targets, and writes through the pinned descriptor. A broad or non-materializable glob fails closed.

fs:write creates the missing parent directories of an allowed path, so a writer can add app/src/features/x/Y.tsx under an app/src/** write scope without creating the folders first. The scope check runs on the full path before anything is created, so a path outside every write scope or a protected path returns SCOPE_VIOLATION and leaves the workspace unchanged. Each missing segment is created relative to the already-pinned parent descriptor with private permissions (0700) and re-opened with the same no-follow pin; a parent that is a symlink (to a directory inside or outside the scope, or dangling) is refused with SCOPE_VIOLATION and never followed, even if it is swapped in between creation and open. A genuine failure while creating a parent (a file in the way, permission denied, a read-only filesystem) returns PARENT_CREATE_FAILED, with the cause in the message and details.cause_code, instead of a misleading SCOPE_VIOLATION.

The sandbox profile also carries an explicit environment allowlist. Only variables declared through manifest required_env entries or capability-factory augmentations are passed into the tool process. Ambient credentials are cleared before the allowed variables are reintroduced.

The kernel built-ins fs:read, fs:write, fs:list, fs:edit and fs:search (pack kernel-builtins) are the file tools an agent turn can call. All five use the same no-follow descriptor broker, scope intersection, deny overlays, policy hook, approval gating and receipts, and all five declare the in-process-scope-checked tier. A host that restricts tools by name (policy_profiles allow_tools) must list fs:list, fs:edit and fs:search to use them. The connected-compute local and agent allowlists still admit only fs:read and fs:write.

fs:read takes path and encoding (utf8 or base64). A call with none of the range parameters below returns exactly { path, content, bytes }. To page a file, pass either lines or bytes (both together, line parameters with base64, or a non-positive limit return INVALID_INPUT):

ParameterMeaning
offset, limitFirst line (0-based) and maximum number of lines to return.
byte_offset, byte_limitFirst byte (0-based) and maximum number of bytes to return.

Integer fields also accept a decimal numeric string such as "20" or "90.0", which is coerced to the integer, because model providers often send numbers as strings. fs:list limit and fs:search max_results behave the same way. Any other string, a negative, a fraction with a non-zero part or NaN returns INVALID_INPUT, and the message names the field and the expected type.

A ranged result adds total_bytes, total_lines (null for base64) and range: { unit, start, end, byte_start, byte_end, next_offset }, and bytes is the size of the returned slice. next_offset is the value to pass as the next offset (or byte_offset) and is null at the end of the file, so a model pages a file of any length by feeding it back. An offset at or beyond the end returns an empty slice, not an error. Line endings are returned verbatim. A UTF-8 byte range never splits a character: a start inside a character moves back to its first byte, and an end inside a character moves back to the previous boundary (forward when that would return nothing, so every page makes progress); range.byte_start and range.byte_end report the bytes actually used.

fs:list takes path (a directory), optional limit (default 200, maximum 1000) and an opaque cursor. It returns { path, entries, next_cursor }, where each entry is { name, kind, size_bytes } with kind one of file, directory, symlink or other and size_bytes null for anything but a regular file. Entries are sorted by name; pass next_cursor back as cursor until it is null and the pages cover the directory exactly once. The directory must sit under a read scope: a directory outside every read scope, a symlink, or a regular file returns SCOPE_VIOLATION. Entries that match no read scope are omitted and never named, symlinks are reported and never followed, and a malformed cursor returns INVALID_CURSOR. Listing is not recursive and does not accept globs.

fs:edit takes path, old_text and new_text and replaces exactly one occurrence of old_text in an existing UTF-8 text file (at most 2 MiB), under the same write scopes, protected-path rules, policy hook, approval gating and receipts as fs:write. Replacement text is inserted literally (no $ patterns), CRLF line endings and a byte-order mark are preserved, and the result is { edited, target, replacements, bytes } with bytes the new file size. A refused edit leaves the file byte-identical:

CodeCause
INVALID_INPUTold_text is empty, or equals new_text.
FS_EDIT_NO_MATCHold_text does not occur.
FS_EDIT_MULTIPLE_MATCHESold_text occurs more than once (details.match_count, overlaps count).
FS_EDIT_TARGET_NOT_FOUNDThe file does not exist; it is never created.
FS_EDIT_NOT_TEXTThe file is not valid UTF-8.
FS_EDIT_TOO_LARGEThe file is larger than 2 MiB.
FS_EDIT_CONFLICTThe file changed between the read and the write.
SCOPE_VIOLATIONOutside the write scopes, a protected path, a symlink segment or target, or a hard-linked target.

fs:edit is a consequential write capability exactly like fs:write, so approval and spend gating apply. It reports whether old_text matched, which reveals at most substring presence in a file the caller may already overwrite.

fs:search takes pattern and optional path (a directory, default .), regex (default false, so the pattern is a literal substring), case_sensitive (default true), glob (a filter on the workspace-relative path; a glob without / matches the file name) and max_results (1 to 500, default 100) and exclude_directories (up to 32 directory names to leave out at any depth). It returns { path, matches, truncated, stopped_by, files_scanned, files_skipped }, where each match is { path, line, text } with a workspace-relative path, a 1-based line and the line clipped to 500 characters. The walk is read-only and visits entries in name order: only entries a read scope reaches are searched, symlinks are never followed, hard-linked files, binary files and files over 1 MiB are skipped (skipped files are counted in files_skipped). When a cap ends the search (max_results, 5000 files, or 64 MiB read), truncated is true and stopped_by names it. A start directory outside every read scope, a symlink or a regular file returns SCOPE_VIOLATION, a missing one FS_SEARCH_TARGET_NOT_FOUND, and an invalid regular expression INVALID_INPUT.

code:run_command is an opt-in kernel built-in for a coding agent. It is registered only when the host passes runCommand (allowed_commands, plus optional timeout_ms, max_timeout_ms, max_output_bytes and env) to the runtime; the runtime supplies the canonical workspace root. The model supplies only argv and an optional timeout_ms:

  • argv[0] must be a bare executable name on the allowlist. A name not on the list is COMMAND_NOT_ALLOWED; a path-qualified name (/bin/echo, ./x) or a call with no workspace path scope is SCOPE_VIOLATION. Shells and launchers (sh, bash, env, xargs, sudo and similar) are refused even when listed, and the process is spawned without a shell, so metacharacters are plain argument text.
  • The working directory is always the workspace root and the environment is rebuilt from a fixed minimal set plus the host’s static additions; nothing ambient is inherited.
  • Each output stream is capped (64 KiB by default) and flagged with stdout_truncated or stderr_truncated. A timeout (30 s by default, 120 s at most) kills the process group and returns outcome: "timed_out" with the output captured so far. A command that finishes returns outcome: "exited" with its exit_code, which is data, not an error.

The tool always runs through the subprocess dispatcher, which in the default runtime is the bwrap sandbox (tier os-enforced-subprocess, failing closed when bwrap is unavailable). The sandbox mounts only exact-file path scopes, so a workspace-wide command needs exact scopes until a host supplies a dispatcher for an already isolated process. It is an admin-permission tool, so approval_required_tools can pause each call.

buildCodeWorkToolCatalog (agent-runtime pack) returns the catalog for a coding agent: exactly fs:read, fs:list, fs:search, fs:edit, fs:write and code:run_command, in that order, limited to the ones governed discovery admits for the task. Pass its result as the tool_catalog of the governed agent loop. Any other governed tool is left out.

Every mechanism above keeps a tool capability inside some boundary, but the boundaries are not all the same strength. The kernel’s ConfinementTier type names three tiers explicitly, and every builtin capability declares its tier.

  • os-enforced-subprocess — the operating system itself enforces the boundary: the bwrap sandbox described above (namespaces, bind mounts, a fixed environment). The dispatcher fails closed with SUBPROCESS_SANDBOX_UNAVAILABLE when a real sandbox is not present, rather than silently downgrading. Example: proc:exec.
  • in-process-scope-checked — a broker running in the same process as every other kernel capability checks the scope with syscalls (realpath/lstat/O_NOFOLLOW/dev-inode identity) before acting. No kernel- or namespace-level guarantee backs it. Examples: fs:read and fs:write’s no-follow broker (above).
  • unconfined — neither mechanism applies.

handler.kind ('in-process' or 'subprocess') tells dispatchTool which code path runs a capability; it does not by itself say which of these three guarantees backs it.

Subprocess execution currently requires Linux, Bubblewrap with descriptor-bind support, and user namespaces. Missing prerequisites return SUBPROCESS_SANDBOX_UNAVAILABLE; scope materialization failures return SUBPROCESS_SCOPE_CONFINEMENT_FAILED. Every dispatched tool also runs with the full command-mode hardening set (separate namespaces, dropped capabilities and a syscall filter). A host whose Bubblewrap or architecture cannot provide it returns SUBPROCESS_HARDENING_UNAVAILABLE before anything is launched; a tool that needs a capability the hardening removes receives EPERM at runtime. The runtime does not silently downgrade to path-only mounts, a weaker hardening plan or an unconstrained child process.

The kernel dispatcher never honours the operator’s unsandboxed waiver. The waiver applies only to operator commands, is recorded when used, and can be forbidden by host policy. See native kernel confinement.

On Linux, the kernel uses bwrap (bubblewrap) to create a user-namespace sandbox. The SandboxProfile is translated into bwrap flags:

Profile fieldbwrap behavior
pinned read fileinherited descriptor plus --ro-bind-fd
pinned write fileinherited descriptor plus --bind-fd
runtime support rootexplicit read-only runtime mount
deny_overlays/dev/null file bind or isolated directory overlay
network_posture: off--unshare-net
network_posture: fullno network namespace isolation; still requires the projected scope decision

network_posture: allowlist is refused by the kernel dispatcher. Bubblewrap alone provides no honest per-process network allowlist mechanism, so buildBwrapInvocation refuses an allowlist posture outright, before any mount is pinned:

Sandbox network allowlist posture is refused (kernel.allowlist-refused): the kernel dispatcher
has no honest per-process network allowlist mechanism.

Every other posture execs the tool command directly. An allowlisted network for command mode (wu:sandbox) uses a host-side egress proxy, described under the Linux pack sandbox below.

The sandbox dispatcher can report its lifecycle as the kernel sandbox:started, sandbox:violation and sandbox:aborted security events, and its admission verdict as sandbox:admission. Pass securityEventEmitter (and optionally now) in the runtime options (the runtime forwards it to the default dispatcher) or in sandboxSubprocessDispatcherOptions. Every invocation gets one sandbox_id shared by its events.

The runtime has no built-in sink; a host composes one. The lumenflow CLI does: Connected Compute execution and the default CLI runtime pass the production sink (createSecurityEventChannelSink from @hellmai/lumenflow-host/confinement), which validates each event and appends it to the user’s own kernel-security channel file in the LumenFlow state directory under your home directory (private to the user, rotated once past 8 MiB and hard-capped at twice that, with dropped events counted; each line records the writing process in emitter_pid and, if LUMENFLOW_EMITTER_RUN_ID is set, its run in emitter_run_id). A runtime you construct yourself emits nothing until you pass a securityEventEmitter. An explicit undefined under sandboxSubprocessDispatcherOptions does not remove the runtime’s emitter; an explicit, defined one still takes precedence.

  • sandbox:started is emitted once, after the host observes the native confinement process start. On the direct path that is the spawn; on the trusted custody path it is the guard’s own proof that it started the native process, because the guard can refuse and exit first. It is not emitted for a pre-aborted call, a spawn failure, argument construction failure, a guard refusal, or the typed SUBPROCESS_SANDBOX_UNAVAILABLE and SUBPROCESS_HARDENING_UNAVAILABLE refusals, which emit no event at all (the typed output is their record).
  • sandbox:violation is emitted only when the supervisor’s own output budget is exhausted (violation_type: other, fixed text, no tool output).
  • sandbox:aborted is emitted at most once when the supervisor terminates a launched sandbox: caller cancellation, the deadline, the output budget, or loss of the network confinement lease.
  • sandbox:admission records the engine admission verdict of a launch (admitted, unadmitted or attested) with its typed reason, for example engine.unadmitted when the host has no admission claim, and one further event per other typed reason, for example engine.policy-path-ignored. Inside a box the kernel layer reports engine.nested-unadmitted and carries the box nonce as box_nonce, a label only. The event is observational: it never says a launch was refused, and no launch that works today is refused by it. The other wu:sandbox surfaces (the box launcher, the Linux, macOS and Windows backends, the confined command runner, the egress proxy and relay, the unconfined guard) record the same event with their own surface name.

Limits to plan around:

  • Timing. aborted and violation are stamped after the process group is reaped, so they can trail the decision by up to 2 s. started means the native process started, not that the tool ran or that every namespace was established.
  • Your emitter must not block. It is called synchronously on the dispatch path; a slow synchronous emitter consumes the sandbox’s deadline.
  • Delivery. Best effort. The emitter is not awaited, errors and rejections are swallowed, and a failing emitter or clock never changes a result, deadline, cancellation or descriptor cleanup. There is no durable or ordered delivery. A sandbox that exits on its own, or ends through a child error or a truncated payload, has no closing event.
  • Observation. A non-zero exit, stderr text or a tool error string is never treated as a policy violation, and syscall denials inside the sandbox are not observed, so none is reported. Egress-proxy denials are not wired for kernel dispatch and are not part of these events.

macOS, Windows, and other non-Linux hosts currently report subprocess execution unavailable. They may still use lifecycle-only kernel APIs and any in-process intrinsic whose implementation enforces the minted ExecutionAuthority physically. They must not emulate autonomous mutation by launching an unconstrained process.

This is a refusal, not a degradation: the guarantee is the same on every host. Autonomous execution is confined by an OS-enforced boundary or it does not run. The kernel dispatcher refusal depends only on whether bwrap resolves on PATH, never on process.platform, so it applies to every non-Linux host.

The Linux pack backend is what wu:sandbox --id <wu-id> -- <command> and a claimed worktree’s post-claim wu:claim --sandbox session run under, on Linux. It is a thin adapter over the same kernel buildBwrapInvocation the tool dispatcher above uses, opted into an additional commandMode input. Both paths carry one hardening set.

Mount plan. Every mount is pinned by fd, exactly like the tool dispatcher’s; nothing here is a path string handed to bwrap unchecked.

In-sandbox pathMode
/ (tmpfs)root
/usr, /bin, /sbin, /lib, /lib64, /etcRO
the exact toolchain version directory of the running node (never ~/.nvm itself)RO
the main checkout (holds the shared .git and the dist symlinks worktrees resolve into)RO
the worktreeRW
the state directoryRW
the work unit fileRW
a fresh temp directory, with HOME a fresh directory under itRW

Because the main checkout is read-only, git commit inside command mode is unsupported, the same way it is today. Files the command writes in the worktree run later, outside the sandbox: the sandbox confines what the command itself can read, write, and reach while it runs; the lifecycle steps that act on those files afterward (gates, wu:prep, wu:done) run on the host directly, not re-sandboxed.

Namespaces and privilege. Command mode runs the command in separate user, PID, IPC, UTS and cgroup namespaces with all capabilities dropped, nested user namespaces disabled and a syscall filter applied. The filter is built for the host architecture (x86_64 or aarch64); any other architecture refuses.

Environment. Exactly PATH, HOME, TMPDIR, LANG, TERM, and COLORTERM are set via --setenv after --clearenv — never the invoking process’s own process.env. Env values never travel on argv: the whole bwrap argument vector, --setenv triples included, is written to a private, immediately unlinked file and handed to bwrap on an inherited descriptor. The host confinement guard likewise reads its launch spec from an inherited descriptor, so neither the guard’s nor bwrap’s command line carries a credential.

Network, one posture at a time. off uses --unshare-net (no interfaces but loopback; an abstract Unix-domain socket the host owns is unreachable too, since the abstract-socket namespace is scoped to the network namespace). full is today’s default: unchanged, host network and host abstract sockets are reachable. allowlist (wu:sandbox --network allowlist) is enforced: the command’s own network namespace is unshared (the same off posture underneath, so no direct route or DNS reaches the sandboxed process), and the ONLY network path is through a host-side allowlisting CONNECT proxy. The proxy itself runs on the host inside its own minimal bwrap (host network namespace shared, its own code and the toolchain read-only, its Unix socket directory read-write, no HOME, the same syscall filter) and reaches only the exact host:port pairs hand-configured in confinement.yaml (see Configuration), refusing every other destination and every address that resolves to a private, link-local, loopback, or otherwise non-public-unicast range. Inside the sandbox, a small dependency-free bridge (fwd) relays 127.0.0.1:3128 to the proxy’s socket, and the command’s own HTTPS_PROXY/HTTP_PROXY/npm_config_https_proxy/ npm_config_proxy (plus their lowercase forms), NO_PROXY, and NODE_USE_ENV_PROXY are set to point at it — the only environment names added on top of the six above, for this posture only. A confinement.yaml that fails to load (an unknown key, a CIDR-shaped host, a host with no port, or a name that resolves only to private addresses) refuses the whole run before any mount is pinned, never a silent partial allowlist. When the run ends, the proxy and fwd are both stopped and the socket directory removed.

Loopback grant (default off). The allowlist posture can also reach exactly one host loopback listener the caller names, for a run that must talk to a backend the app under test serves on the host (wu:sandbox --network allowlist --loopback-grant 127.0.0.1:54321 --loopback-reason '<why>'; from the SDK, loopbackGrants: [{ host: '127.0.0.1', port: 54321, reason: '<why>' }] on buildSandboxProfile, which requires networkPosture: 'allowlist'). A grant is the literal host 127.0.0.1 or ::1 and one exact port: no range, hostname, other loopback address, or other private range, at most four per run, never the forwarder port 3128, and a printable reason is required. The proxy checks a grant against the literal CONNECT authority only, so a name that resolves to loopback, a differently spelled address, a bracketless IPv6 form, or any other port is refused exactly as before (DNS is never consulted for a grant, so there is nothing to rebind), and a redirect to another loopback port is a new CONNECT the proxy refuses. Inside the sandbox a fixed-pair forwarder listens on the same loopback port in the command’s own network namespace and tunnels each connection through the proxy, so the sandboxed client uses the same http://127.0.0.1:54321 URL it uses on the host and needs no proxy settings; every ungranted loopback port has no listener at all. Each grant and its reason is carried on the execution plan, printed by wu:sandbox, appended to the sandbox telemetry stream before the command starts, and logged by the proxy on every connection. Linux only: the macOS and Windows backends refuse a grant, and a grant is never run unconfined.

Loopback grant without a work unit (default off). A caller that has no wuId, worktree or work unit file and builds its own kernel SandboxProfile can use the same grant through the host confinement exports. buildProxyEntryCommand builds the proxy argv (the validated grants as a fifth, final argument only when grants exist), launchProxyProcess({ loopbackGrants }) validates them before spawning, refuses a command that does not carry exactly them, and echoes them on the returned handle, and buildAllowlistSandboxCommand returns the /bin/sh -c argv for buildBwrapInvocation with one fixed-pair forwarder per grant, the read-only mounts it needs, and the validated grants. The validator, proxy matching, limits and logging are those of the section above; with no grant nothing changes.

import { buildBwrapInvocation } from '@hellmai/lumenflow-kernel/sandbox';
import {
  buildAllowlistSandboxCommand,
  buildProxyEntryCommand,
  launchProxyProcess,
} from '@hellmai/lumenflow-host/confinement';

const grants = [{ host: '127.0.0.1', port: 54321, reason: 'app-under-test backend' }] as const;
const entry = buildProxyEntryCommand({
  nodeBin,
  entryPath,
  socketPath,
  allowlist,
  loopbackGrants: grants,
});
const proxy = launchProxyProcess({
  toolchainDir,
  codeDir,
  socketDir,
  command: entry.command,
  loopbackGrants: grants,
});
const run = buildAllowlistSandboxCommand({
  nodeBin,
  fwdScriptPath,
  socketPath,
  argv,
  loopbackGrants: grants,
});
// Bind run.readonlyMounts read-only at the same path, use network_posture 'off', then:
const invocation = buildBwrapInvocation({ profile, command: [...run.command], commandMode: {} });

This architecture is an intentional incremental migration from CLI-first to pack-first, not accidental complexity.

  1. LumenFlow started as a CLI tool. The original 80+ commands were standalone CLI scripts, each parsing process.argv and writing to stdout. This was the fastest way to build and validate the workflow.

  2. The kernel introduced governance. When the kernel was added to provide scope enforcement, policy evaluation, and evidence recording, every tool call needed to pass through the kernel pipeline. But rewriting 80+ CLI commands as kernel-native tool implementations was a large effort.

  3. The runtime CLI adapter bridged the gap. Instead of rewriting everything, the adapter pattern lets pack tools call existing CLI modules in-process. The CLI modules do not know they are running inside a sandbox — they just call main() and write to stdout as usual.

  4. New tools are written pack-native. The direct implementation path shows the target architecture: self-contained functions that return ToolOutput directly.

This design means:

  • No big rewrite required — the system works today with full governance
  • External contributors can add tools either way — via CLI commands (adapter path) or direct implementations
  • Migration is incremental — each tool can be converted independently

A tool input that carries an own __proto__, constructor or prototype key at any depth is refused at the ToolHost boundary with INVALID_INPUT, before policy, evidence or the handler run. The approved-retry path does not return INVALID_INPUT; it throws InputPrototypeKeyError (exported from the kernel package). Without this, a passthrough input schema could turn an own __proto__ key into the parsed object’s prototype, and the handler would read fields that policy never evaluated. Handlers also receive a null-prototype copy of the parsed data objects (arrays are copied by index), which removes inheritance from the parsed tree; built-ins such as Date and Map pass through. Use Object.hasOwn(input, key) rather than input.hasOwnProperty(key).

ToolHost reads a tool input exactly once, on entry, into a plain deep-frozen snapshot. The reserved-key check, the evidence hash and stored input, the policy context, the schema parse and the handler argument all use that one snapshot, on first execution, inspectApprovalRetry and executeConsumedApproval alike (and the workflow-node dispatch path in the Agent Runtime pack). A getter, or a Proxy whose traps answer differently on each call, therefore cannot show policy one value and the handler another: each property, key list and prototype is consulted once.

The snapshot uses the kernel’s canonical encoder, so anything outside the JSON value domain (undefined, bigint, symbols, functions, non-finite numbers, Date, Map, class instances, array holes) is refused with a TypeError before any evidence write, policy call or handler. Cyclic input and nesting deeper than 1,000 levels are refused the same way instead of overflowing the stack. Only enumerable own string keys are carried over; named properties on arrays and non-enumerable keys never reach policy or the handler.

The kernel exports an in-memory SpendCeiling helper for callers that track the cost of consequential actions. A ceiling and each recorded cost must be finite, nonnegative numbers. Construction with an invalid ceiling throws INVALID_ARGUMENT; recording an invalid cost returns INVALID_ARGUMENT and leaves the accumulated total unchanged. Finite fractional values are accepted. This helper does not persist totals or automatically govern the dispatch flow described on this page; callers must explicitly use it.