Search⌘ K
AI Features

Give the Agent Hands: Three Tools and One Permit

Explore how to equip an AI agent with three essential tools plus a permit function to control authority. Understand managing read, write, and command permissions to prevent unauthorized actions. Learn why limiting tools simplifies auditing and improves reliability, and see how denials provide clear, actionable feedback for ongoing verification.

Wren can hold a conversation and refuse a truncated batch. It cannot open a file, so every answer it gives about CLIN-547 is a guess dressed as a diagnosis.

This lesson fills the Execute seam. Three tools, one permit function in front of all of them, and 77 lines. agent.ts does not change by a single character, which is the first evidence that last lesson’s seam was real.

By the end, you run node run.ts and watch the agent read a real file, run an allowed command, and get refused when it tries to overwrite the source.

Which tools are worth having?

The ones a person uses in the first 10 minutes of debugging.

Read a file. Write a file. Run a command. Everything else in a mature harness is a specialization of those three: a patch tool is a careful write, a search tool is a command, and a test runner is a command with a parser attached.

Starting with three keeps the authority question in front of you. Each new tool is a new way for a session to reach outside its boundary, and the cost of that shows up in the permit function instead of in the tool.

There is a second reason to stop at three. A tool the model rarely picks is still a tool you have to reason about every time you audit the policy, and an unused capability is the one nobody remembers to check. Three tools produce a permit function short enough to read in one sitting, which is the only length at which anyone will actually reread it after a model change.

Where does authority actually live?

In one function that runs before any tool does.

TypeScript 5.8.3
import { readFile, writeFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { resolve, relative, isAbsolute } from 'node:path';
import { promisify } from 'node:util';
import type { Execute } from './agent.ts';
import type { ToolCall, ToolResult } from './types.ts';
const runCommand = promisify(execFile);
export interface Policy {
root: string; // Nothing outside this directory is reachable
writable: boolean; // May the agent change files at all
commands: string[]; // Exact argv[0] values that may run; nothing else
}
// Deny by default. A permit returns the reason on refusal, never a boolean,
// so the transcript records why an action did not happen.
export function permit(policy: Policy, call: ToolCall): string | null {
const tool = TOOLS[call.name];
if (!tool) return `unknown tool ${call.name}`;
if (call.name === 'write' && !policy.writable) return 'this session is read-only';
if (call.name === 'read' || call.name === 'write') {
const path = call.args.path ?? '';
if (!path) return 'missing path';
const full = resolve(policy.root, path);
const rel = relative(policy.root, full);
if (rel.startsWith('..') || isAbsolute(rel)) return `path escapes the workspace: ${path}`;
}
if (call.name === 'bash') {
const argv = (call.args.command ?? '').trim().split(/\s+/);
if (!argv[0]) return 'missing command';
if (!policy.commands.includes(argv[0])) return `command not allowed: ${argv[0]}`;
}
return null;
}
type Tool = (policy: Policy, args: Record<string, string>, signal: AbortSignal) => Promise<string>;
const TOOLS: Record<string, Tool> = {
read: async (policy, args) => {
const text = await readFile(resolve(policy.root, args.path), 'utf8');
return text.length > 4000 ? `${text.slice(0, 4000)}\n... truncated` : text;
},
write: async (policy, args) => {
await writeFile(resolve(policy.root, args.path), args.content ?? '', 'utf8');
return `wrote ${args.content?.length ?? 0} bytes to ${args.path}`;
},
bash: async (policy, args, signal) => {
const argv = args.command.trim().split(/\s+/);
const { stdout, stderr } = await runCommand(argv[0], argv.slice(1), {
cwd: policy.root,
signal,
timeout: 10_000,
});
return (stdout + stderr).trim() || '(no output)';
},
};

Read it as a sequence of refusals, not a collection of tool implementations.

  • ...