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

| Layer | Package | Responsibility | | ---------- | ------------------- | --------------------------------------------------------------------------------------------------------- | | Kernel | @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 | @lumenflow/cli | Business 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:

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

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.

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:

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

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 @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('@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 @lumenflow/kernel/sandbox barrel exposes host-side sandbox builders and dispatchers. Executable worker helpers live behind the narrower @lumenflow/kernel/sandbox/tool-runner-worker subpath so server bundlers that import @lumenflow/kernel do not accidentally bundle the worker process entrypoint.

Diagram

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

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

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