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.
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 implementation files that use this path:
| File | Domain | Tools |
|---|---|---|
wu-lifecycle-tools.ts | WU create, claim, prep, done, block, edit, etc. | ~21 |
runtime-native-tools.ts | File read/write/edit/delete, config, validation, etc. | ~20 |
memory-tools.ts | Checkpoint, inbox, signal, recover, etc. | ~14 |
initiative-orchestration-tools.ts | Initiative create, plan, status, orchestrate, etc. | ~11 |
agent-tools.ts | Agent session management, delegation | ~8 |
flow-metrics-tools.ts | 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.
| File | Domain | Why direct |
|---|---|---|
git-tools.ts | 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 (runtime-cli-adapter.ts) 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 @hellmai/lumenflow-cli dist output:
For example, wu-create resolves to packages/@lumenflow/cli/dist/wu-create.js. The adapter first tries import.meta.resolve('@hellmai/lumenflow-cli') to locate the installed package; if that fails, it falls back to sibling cli/dist directories resolved relative to the adapter itself, then packages/@lumenflow/cli/dist under the current working directory. The adapter 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.
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 .lumenflow and .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 /proc/self/fd. A broad or non-materializable glob fails closed.
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.
Supported Sandbox Backend
Section titled “Supported Sandbox Backend”Subprocess execution currently requires Linux, Bubblewrap, user namespaces, /proc/self/fd, and
Bubblewrap --bind-fd/--ro-bind-fd support. Missing prerequisites return
SUBPROCESS_SANDBOX_UNAVAILABLE; scope materialization failures return
SUBPROCESS_SCOPE_CONFINEMENT_FAILED. The runtime does not silently downgrade to path-only mounts
or an unconstrained child process.
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”When network_posture is allowlist, bwrap still uses --unshare-net to create an isolated network namespace, then runs an iptables script inside the sandbox before executing the tool command. The script is built by buildIptablesAllowlistScript():
- Allow loopback traffic (
-o lo -j ACCEPT) - Allow established/related connections (
--state ESTABLISHED,RELATED -j ACCEPT) - Per-entry ACCEPT rules for each allowlisted host:port or CIDR
- Default REJECT with
icmp-port-unreachable(producesECONNREFUSEDat the application level)
The entire iptables setup and tool command are wrapped in a sh -c invocation so iptables rules are applied before the tool runs.
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. A future backend needs its own verified no-follow, exact-object
confinement contract before it can claim parity.
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. What differs is the
primitive that implements it, and macOS and Windows have no kernel-side confinement backend
yet. The sandbox-exec/SBPL enforcement that exists for macOS, and the AppContainer + Job Object
enforcement described below for Windows, both belong to the pack sandbox used for lifecycle
commands (wu:sandbox), and the kernel dispatcher contains no macOS or Windows code path — this
dispatch() refusal is gated purely on whether bwrap resolves on PATH, never on
process.platform, so it already generalizes correctly to every non-Linux host with zero
per-OS code. ADR-033 records the contract, the shared posture vocabulary, and the single
escape-hatch variable that applies identically on all three hosts.
macOS pack sandbox: sandbox-exec/SBPL (WU-3921, unwired)
Section titled “macOS pack sandbox: sandbox-exec/SBPL (WU-3921, unwired)”The pack sandbox’s macOS backend
(packages/@lumenflow/packs/software-delivery/src/sandbox/sandbox-backend-macos.ts) is a pure
invocation-plan builder for wu:sandbox, exactly like its Linux (bwrap) and Windows
(AppContainer + Job Object) siblings: resolveExecution(request) returns {command, args} and
never executes anything itself. The mechanism is Apple’s Seatbelt sandbox (sandbox-exec) driven
by a generated SBPL (Sandbox Profile Language) policy string passed via -p:
| Envelope guarantee | Mechanism |
|---|---|
| Filesystem read confinement | (deny default) plus a scoped (allow file-read* (subpath "...")) per granted root (workspace, the fixed macOS system paths a process needs to execute at all, and the profile’s temp path) — never a broad (allow file-read*) |
| Filesystem write confinement | A scoped (allow file-write* (subpath "...")) per writable root from the profile’s allowlist only |
| Sensitive deny overlays | Explicit (deny file-read* (subpath "<home>/.ssh"|"/.aws"|"/.gnupg")), emitted after every allow rule — SBPL is deny-wins on the last matching rule for a given path, so this ordering is load-bearing, not cosmetic |
| Network scope | Posture off → (deny network*); full → (allow network*); allowlist → (deny network*) plus one (allow network-outbound (remote ip "host:port")) per IP-literal/CIDR entry, each validated against a strict host[:port]/CIDR grammar before interpolation (WU-3193 closed an SBPL-quote and shell-metacharacter breakout in this exact path); a DNS hostname entry refuses closed instead (see below) |
| Child-process confinement | (allow process*) permits fork/exec at all ((deny default) would otherwise block even the target command from starting); Seatbelt profiles are inherited transitively across fork/exec, so every descendant is confined by the same policy automatically — no per-child rule needed, the process-tree equivalent of bwrap’s namespace confinement |
| Process-tree termination | Not provided by this backend (WU-3921 F3) — resolveExecution returns {command, args} and never spawns, tracks, or kills anything, so there is no seam here to express one. The intended mechanism is ordinary POSIX process groups (the sandbox-exec process would be a direct child of whatever spawns it, so killing that process’s own group tears down the tree — the path terminateProcessGroup() already applies to every non-win32 host in the kernel’s own sandbox subprocess), but wiring a spawner that does this for this backend’s invocation is WU-3881’s job, not done here |
| Canonicalized path enforcement | Every read root, writable root, and sensitive deny path is granted/denied under both its literal and canonical (symlink-resolved) spelling — see below |
Canonicalized path enforcement, and why it is macOS-specific. Stock macOS ships real symlinks
on exactly the paths this backend allows — /tmp → /private/tmp, /var → /private/var — a
layout Linux does not have for the equivalent paths. sandbox-allowlist.ts (shared by every
backend) already computes a canonical form per writable root specifically to guard symlink
escapes, but the original macOS policy builder consumed only the literal normalizedPath. This WU
adds injectable realpathSync/homedir seams (mirroring the Windows backend’s injectable
pathImpl) so read rules, writable-root write rules, and the sensitive deny overlays all resolve
and grant/deny both forms — pinned on this Linux implementation host with a fake resolver, since no
real macOS filesystem is available to exercise the real symlinks against. A path that does not
exist yet falls back to its literal spelling (there is nothing to canonicalize).
No self-verification probe, and why that is not a gap. The Windows backend runs a negative and
positive probe on itself before the real command, because AppContainer confinement is established
by a separate ACL pre-grant step (icacls) that could silently fail to take effect. Seatbelt has
no equivalent two-step: the kernel mediates every filesystem and network syscall against the policy
string at the point of the syscall itself, and resolveExecution already fails closed via
commandExists('sandbox-exec') when the binary itself is missing — there is no intermediate
“looked like it worked but didn’t” state for a self-check to catch.
Network posture allowlist is enforced for IP literals and CIDR ranges — a DNS hostname entry
is a named, fail-closed gap instead (WU-3921 F1), not Windows’ whole-posture refusal. Windows
Firewall’s block-always-wins-over-allow rule ordering makes a per-host allowlist unbuildable at
all without an unsafe global policy flip, so the Windows backend refuses posture allowlist
unconditionally. SBPL’s (allow network-outbound (remote ip "...")) predicate has no equivalent
ordering problem — it evaluates the same deny-wins way as every other SBPL rule — but its remote ip filter can only express an IP literal (optionally :port) or a CIDR range; it has no
hostname-resolution step. assertValidNetworkAllowlistEntry validates the shared injection-safety
grammar but still admits a DNS hostname like registry.npmjs.org:443, which would otherwise be
interpolated straight into a policy string sandbox-exec cannot compile — an opaque profile-parse
failure at spawn time rather than a named, typed refusal. resolveExecution now classifies every
allowlist entry before building the policy: an IP-literal or CIDR entry still builds the remote ip rule exactly as before; a DNS hostname entry makes the backend refuse unconditionally (fails
closed, reason from buildMacosNetworkAllowlistHostnameUnsupportedReason(entry), never waived by
allowUnsandboxedFallback — ADR-033 §6 forbids waiving on a host with a working backend) — the
same shape as Windows’ own named refusal (WINDOWS_NETWORK_ALLOWLIST_UNSUPPORTED_REASON), just
scoped to the one grammar case SBPL cannot express instead of the whole posture.
Like its siblings, this backend stays entirely unwired: nothing reachable from the kernel
dispatcher, a selector, Connected Compute, or doctor calls it yet. WU-3881 owns wiring the shared
posture vocabulary and doctor surfacing across all three hosts. Real-OS proof of enforcement
(allowed/denied filesystem and network operations, symlink and canonical-path escape attempts,
child-process confinement, and process-tree cleanup) belongs to the owner’s manual verification run
recorded in docs/operations/tasks/wu/WU-3921.yaml tests.manual — nothing here is signed off
from this Linux implementation host.
Windows pack sandbox: AppContainer + Job Object (WU-3922, unwired)
Section titled “Windows pack sandbox: AppContainer + Job Object (WU-3922, unwired)”The pack sandbox’s Windows backend
(packages/@lumenflow/packs/software-delivery/src/sandbox/sandbox-backend-windows.ts) builds an
invocation for wu:sandbox the same way its Linux (bwrap) and macOS (sandbox-exec/SBPL)
siblings do: a pure function returning {command, args} that the caller spawns. Windows ships
neither a general per-invocation sandboxing binary nor a scriptable kernel policy language, so the
mechanism here is Windows’ own AppContainer model (the same capability-scoped, deny-by-default
primitive UWP apps and browser sandboxes use) plus a Job Object, driven by a generated PowerShell
script that compiles a small, fixed C# P/Invoke helper via Add-Type:
| Envelope guarantee | Mechanism |
|---|---|
| Filesystem read + write confinement | A freshly-minted, per-invocation AppContainer profile SID is denied read AND write everywhere by default (no ACE grants it access); icacls grants it access to exactly the profile’s writable roots ((OI)(CI)F) and read-only roots ((OI)(CI)RX) |
| Network scope | Posture off mints the profile with zero capability SIDs (network denied by the OS itself, independent of any firewall rule); posture full grants internetClient/internetClientServer/privateNetworkClientServer; posture allowlist is refused (see below) |
| Child-process confinement | Children created by the confined process inherit its token by default, so they run confined too |
| Process-tree termination | The confined process is assigned to a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE before its suspended thread resumes; the whole tree is torn down the moment the job handle closes |
Self-verification, not blind trust. Before running the caller’s real command, the confined process runs two probes on itself: a negative probe (write outside every granted root — must fail) and a positive probe (write inside a granted root — must succeed). Either probe behaving unexpectedly aborts before the real command ever runs, with a typed diagnostic on stderr. This turns a subtle P/Invoke or ACL-setup defect into a loud refusal instead of a silent, unconfined success.
Network posture allowlist is an explicit, named gap, not a silent downgrade. Windows Firewall
evaluates every Block rule before every Allow rule regardless of specificity, so a package-scoped
“block all, then allow these hosts” ruleset can never work — the block-all rule always wins. A true
per-host allowlist would need either a system-wide default-outbound-policy change (unsafe on a box
running concurrent sandboxed invocations with different postures) or the raw Windows Filtering
Platform callout API. The backend refuses unconditionally (fails closed; ADR-033 section 6 forbids
waiving on a host that has a working backend, so allowUnsandboxedFallback does not apply) rather
than silently enforcing off or full instead of what was actually requested.
A genuine mechanism-cost difference, not a shortcut. NTFS ACLs are materialized per object, not
evaluated lazily against a path prefix the way bwrap’s mount namespace or SBPL’s subpath
predicate are. Granting access to a large, pre-existing tree therefore costs icacls /T
proportional to the tree size, unlike Linux/macOS’s O(1) setup. This is an ADR-033 R1 “mechanism,
never tier” difference; its real wall-clock cost on a large checkout is unmeasured from a Linux
dev box and is a natural input to WU-3923’s tri-platform performance evidence work.
Like its siblings, this backend stays entirely unwired: nothing reachable from the kernel
dispatcher, a selector, Connected Compute, or doctor calls it yet. WU-3881 owns wiring the shared
posture vocabulary and doctor surfacing across all three hosts.
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 would have been a multi-month 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 (git-tools, worktree-tools, lane-lock) shows the target architecture: self-contained functions that return
ToolOutputdirectly. Over time, more tools will migrate from the adapter path to direct implementations.
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
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