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

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 implementation files that use this path:

FileDomainTools
wu-lifecycle-tools.tsWU create, claim, prep, done, block, edit, etc.~21
runtime-native-tools.tsFile read/write/edit/delete, config, validation, etc.~20
memory-tools.tsCheckpoint, inbox, signal, recover, etc.~14
initiative-orchestration-tools.tsInitiative create, plan, status, orchestrate, etc.~11
agent-tools.tsAgent session management, delegation~8
flow-metrics-tools.tsFlow 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.

FileDomainWhy direct
git-tools.tsgit: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 (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.

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 from runtime-cli-adapter.ts
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 from runtime-cli-adapter.ts
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 @hellmai/lumenflow-cli dist output:

packages/@lumenflow/cli/dist/<command>.js

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 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.

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 .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.

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.

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

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():

  1. Allow loopback traffic (-o lo -j ACCEPT)
  2. Allow established/related connections (--state ESTABLISHED,RELATED -j ACCEPT)
  3. Per-entry ACCEPT rules for each allowlisted host:port or CIDR
  4. Default REJECT with icmp-port-unreachable (produces ECONNREFUSED at 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.

bwrap --unshare-net ... -- sh -c \
  'iptables -A OUTPUT -o lo -j ACCEPT && \
   iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT && \
   iptables -A OUTPUT -d registry.npmjs.org -p tcp --dport 443 -j ACCEPT && \
   iptables -A OUTPUT -j REJECT --reject-with icmp-port-unreachable && \
   exec node tool-runner-worker.js'

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 guaranteeMechanism
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 confinementA scoped (allow file-write* (subpath "...")) per writable root from the profile’s allowlist only
Sensitive deny overlaysExplicit (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 scopePosture 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 terminationNot 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 enforcementEvery 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 guaranteeMechanism
Filesystem read + write confinementA 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 scopePosture 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 confinementChildren created by the confined process inherit its token by default, so they run confined too
Process-tree terminationThe 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.

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 would have been a multi-month 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 (git-tools, worktree-tools, lane-lock) shows the target architecture: self-contained functions that return ToolOutput directly. 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