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 module family: 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-sh/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() validates without spawning and returns a serializable ToolApproval backed by a private stored record. After your authenticated approval decision, execute() consumes the record once, checks the approved digest and current policy, and revalidates the original input. Child environment comes only from the current CommandSpec. Browser-only proposals cannot authorize execution.
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.