Tool-exec gate
A default-deny allowlist maps every command an agent is allowed to run, call anything not on it, and NotFoundError refuses the call before a process ever spawns.
What it is
tool-exec is Caisson's governed tool-call gate, composed live into the Agentic-Dev edition surface: a default-deny allowlist maps a logical command name to a real executable and a Zod-`.strict()` argv schema, validated with parseStrict before spawn and passed to execFile as an array, never a shell string. A two-phase propose/execute split lets an external approval step run between validation and the actual spawn.
What ships in the module
Browser-safe entry point
Import @caisson/tool-exec/browser inside a client bundle for createToolProposer, the default-deny lookup and Zod argv validation with no spawn seam attached. It is the same gate createToolExec runs, so a UI can decide whether a call is permitted without the process boundary. The main entry keeps the full node-capable surface, and every browser-entry export is also on it.
Default-deny allowlist, fail-closed
createToolProposer builds its registry from the allowlist it is handed (createToolExec passes config.allowlist straight through), a name not registered there throws NotFoundError before anything spawns. An empty allowlist refuses every call; there's no wildcard escape hatch.
Argv arrays, never a shell
Each CommandSpec pairs a logical name with the real executable and a Zod argsSchema producing string[]; parseStrict validates the caller's args into that exact argv array before defaultExecFn spawns it via execFile, execSync, exec, and shell: true are never used anywhere in the package.
Two-phase propose/execute for external approval
propose() runs the same allowlist lookup and Zod validation as run() but returns a serializable ProposedToolCall without spawning, park it in your own approval store. execute() re-checks the name is still allowlisted to the same command (defense against the allowlist changing between propose and execute) but never re-validates args.
Bounded output, always a provenance record
Every call returns an ExecResult, command, args, exitCode, stdout, stderr, ok, and an at timestamp from an injectable now(). bound() caps stdout/stderr at 64KB before Node's own maxBuffer would throw; a spawn failure resolves exitCode: -1 instead of throwing, so the caller always gets a record.
Injectable spawn seam for hermetic tests
The default spawn path (execFile, no shell) is swappable via config.execFn, the suite injects a fakeExecFn double that records calls and returns canned output, so the allowlist and validation logic are exercised without ever spawning a real process.
propose(name: string, args: unknown, reason?: string): ProposedToolCall {
const spec = registry.get(name);
if (spec === undefined) {
throw new NotFoundError(`No command registered for "${name}"`, {
command: name,
});
}
const validatedArgs = parseStrict(spec.argsSchema, args);
const proposed: ProposedToolCall = {
name,
command: spec.command,
args: validatedArgs,
};
return reason === undefined ? proposed : { ...proposed, reason };
},- registry.get(name) is the default-deny lookup, a name not in config.allowlist throws NotFoundError before parseStrict or any spawn path runs.
- parseStrict validates args against the allowlisted CommandSpec's own argsSchema, a bad shape throws ValidationError, still before anything spawns.
- run() spawns exactly the validated argv this returns and never re-derives it, so the single-phase and two-phase paths cannot drift, and this module reaches no node builtin, which is why it is also the browser entry.