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.
Three-Layer Execution Stack
Section titled “Three-Layer Execution Stack”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.
| Layer | Package | Responsibility |
|---|---|---|
| Kernel | @hellmai/lumenflow-kernel | Domain-agnostic orchestration: scope enforcement, policy evaluation, evidence recording, sandbox dispatch |
| Pack | software-delivery | Tool implementations: 90+ exported functions grouped by domain (WU lifecycle, memory, git, etc.) |
| CLI | @hellmai/lumenflow-cli | Business logic: each command module exports a main() function that performs the actual work |
Complete Execution Trace
Section titled “Complete Execution Trace”Here is the full path a tool call takes, using wu:create as an example.
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.
Two Implementation Paths
Section titled “Two Implementation Paths”The Software Delivery Pack uses two strategies for implementing its 90+ tools.
CLI Adapter Path (~80 tools)
Section titled “CLI Adapter Path (~80 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:
| Domain | Tools |
|---|---|
| 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 |
Direct Implementation Path (~1 tool)
Section titled “Direct Implementation Path (~1 tool)”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.
| Tool | Why direct |
|---|---|
git:status | Needs 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.
Runtime CLI Adapter Deep Dive
Section titled “Runtime CLI Adapter Deep Dive”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.
Serialized Execution
Section titled “Serialized Execution”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.
What Gets Patched
Section titled “What Gets Patched”Before calling a CLI module’s main(), the adapter patches six global surfaces:
| Global | Patch | Why |
|---|---|---|
process.argv | Set to [execPath, command, ...args] | CLI modules parse arguments from process.argv |
process.exit | Replaced with a function that throws RuntimeCliExitSignal | Prevents the subprocess from actually exiting; captures the exit code instead |
process.stdout.write | Redirected to a capture buffer | Captures stdout output as a string |
process.stderr.write | Redirected to a capture buffer | Captures stderr output as a string |
console.log/info/debug | Redirected to stdout capture buffer | Captures console output that would otherwise go to the real stdout |
console.error/warn | Redirected to stderr capture buffer | Captures 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.
The RuntimeCliExitSignal Trick
Section titled “The RuntimeCliExitSignal Trick”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:
The adapter’s try/catch block recognizes this error and captures the exit code without terminating the worker process.
Module Resolution
Section titled “Module Resolution”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 Sandbox Boundary
Section titled “The Sandbox Boundary”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.
The invocation payload sent on stdin contains:
| Field | Purpose |
|---|---|
tool_name | Which tool to execute |
handler_entry | Host-registered module/export from the loaded capability; never request-selected |
input | The tool’s input data |
scope_enforced | Diagnostic copy of the computed intersection; physical mounts remain authoritative |
receipt_id | UUID 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).
Sandbox Profile
Section titled “Sandbox Profile”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.
Built-in file tools
Section titled “Built-in file tools”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):
| Parameter | Meaning |
|---|---|
offset, limit | First line (0-based) and maximum number of lines to return. |
byte_offset, byte_limit | First 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:
| Code | Cause |
|---|---|
INVALID_INPUT | old_text is empty, or equals new_text. |
FS_EDIT_NO_MATCH | old_text does not occur. |
FS_EDIT_MULTIPLE_MATCHES | old_text occurs more than once (details.match_count, overlaps count). |
FS_EDIT_TARGET_NOT_FOUND | The file does not exist; it is never created. |
FS_EDIT_NOT_TEXT | The file is not valid UTF-8. |
FS_EDIT_TOO_LARGE | The file is larger than 2 MiB. |
FS_EDIT_CONFLICT | The file changed between the read and the write. |
SCOPE_VIOLATION | Outside 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.
Running commands
Section titled “Running commands”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 isCOMMAND_NOT_ALLOWED; a path-qualified name (/bin/echo,./x) or a call with no workspace path scope isSCOPE_VIOLATION. Shells and launchers (sh,bash,env,xargs,sudoand 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_truncatedorstderr_truncated. A timeout (30 s by default, 120 s at most) kills the process group and returnsoutcome: "timed_out"with the output captured so far. A command that finishes returnsoutcome: "exited"with itsexit_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.
Code-work tool catalog
Section titled “Code-work tool catalog”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.
Confinement tiers
Section titled “Confinement tiers”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: thebwrapsandbox described above (namespaces, bind mounts, a fixed environment). The dispatcher fails closed withSUBPROCESS_SANDBOX_UNAVAILABLEwhen 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:readandfs: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.
Supported Sandbox Backend
Section titled “Supported Sandbox Backend”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.
Linux: bwrap (bubblewrap)
Section titled “Linux: bwrap (bubblewrap)”On Linux, the kernel uses bwrap (bubblewrap) to create a user-namespace sandbox. The SandboxProfile is translated into bwrap flags:
| Profile field | bwrap behavior |
|---|---|
| pinned read file | inherited descriptor plus --ro-bind-fd |
| pinned write file | inherited descriptor plus --bind-fd |
| runtime support root | explicit read-only runtime mount |
deny_overlays | /dev/null file bind or isolated directory overlay |
network_posture: off | --unshare-net |
network_posture: full | no network namespace isolation; still requires the projected scope decision |
Network allowlist on Linux
Section titled “Network allowlist on Linux”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:
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.
Security events
Section titled “Security events”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:startedis 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 typedSUBPROCESS_SANDBOX_UNAVAILABLEandSUBPROCESS_HARDENING_UNAVAILABLErefusals, which emit no event at all (the typed output is their record).sandbox:violationis emitted only when the supervisor’s own output budget is exhausted (violation_type: other, fixed text, no tool output).sandbox:abortedis 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:admissionrecords the engine admission verdict of a launch (admitted,unadmittedorattested) with its typed reason, for exampleengine.unadmittedwhen the host has no admission claim, and one further event per other typed reason, for exampleengine.policy-path-ignored. Inside a box the kernel layer reportsengine.nested-unadmittedand carries the box nonce asbox_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 otherwu:sandboxsurfaces (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 ownsurfacename.
Limits to plan around:
- Timing.
abortedandviolationare stamped after the process group is reaped, so they can trail the decision by up to 2 s.startedmeans 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.
Other operating systems
Section titled “Other operating systems”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.
Linux pack sandbox: bwrap command mode
Section titled “Linux pack sandbox: bwrap command mode”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 path | Mode |
|---|---|
/ (tmpfs) | root |
/usr, /bin, /sbin, /lib, /lib64, /etc | RO |
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 worktree | RW |
| the state directory | RW |
| the work unit file | RW |
a fresh temp directory, with HOME a fresh directory under it | RW |
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.
Why This Architecture
Section titled “Why This Architecture”This architecture is an intentional incremental migration from CLI-first to pack-first, not accidental complexity.
-
LumenFlow started as a CLI tool. The original 80+ commands were standalone CLI scripts, each parsing
process.argvand writing to stdout. This was the fastest way to build and validate the workflow. -
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.
-
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. -
New tools are written pack-native. The direct implementation path shows the target architecture: self-contained functions that return
ToolOutputdirectly.
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
Reserved input keys
Section titled “Reserved input keys”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).
Single-read input
Section titled “Single-read input”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.
Spend ceiling helper
Section titled “Spend ceiling helper”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.
Next Steps
Section titled “Next Steps”- Kernel Runtime — The 8-step pipeline that dispatches tools
- Package Architecture — How packages relate and how packs are distributed
- Packs — How domain tools are declared and loaded
- Create a Pack — Build your own domain pack