Approval Gates
The allowlist you built in Module 1 has exactly one mode: block anything that isn't on the list. That's fine for a demo. It is not fine for a real harness.
CI doesn't have a human to ask. Subagents need to inherit a slice of their parent's trust, not the full allowlist. And the person running the agent locally probably wants to approve npm install express once and not be asked again three steps later.
Same gate. Three different operational modes. We'll get there by evolving the shape of the config itself, from a boolean, to a function, to a discriminated union.
Outcome
createBashTool accepts an ApprovalConfig discriminated union with three modes: interactive, background, and delegated. Each mode shapes when needsApproval returns true.
Fast Track
- Define
ApprovalConfigas a discriminated union with three modes - Write
createApproval(config)that returns aneedsApprovalfunction - Pass the result into
createBashTooland verify each mode behaves differently
Hands-on Exercise 2.3
Replace the static safe-prefix check with a configurable approval system.
Requirements:
- Define
ApprovalConfigwith three variants:{ mode: "interactive" },{ mode: "background" },{ mode: "delegated"; trust: string[] } - Write
createApproval(config: ApprovalConfig)that returns(input) => boolean - Update
createBashToolto accept the approval function as a parameter - Test each mode with the same command and verify the gate behavior changes
Implementation hints:
backgroundreturnsfalse(approved) for everything. This is for CI and automated runsdelegatedchecks the input againstconfig.trustand approves only matchesinteractivechecks the input against the safe-prefix list and approves the safe ones. Anything else needs human approval- The function returns
truewhen approval is needed,falsewhen the command can run
Stage 1: Boolean
The simplest possible approval gate is a boolean:
needsApproval: trueThis blocks every call. ls, pwd, rm -rf, all of it. It's useless, but it sets the shape. needsApproval is the question "should we pause for human approval before this runs?"
Stage 2: Function
A function lets you answer that question based on the input:
needsApproval: ({ command }) => {
if (SAFE_PREFIXES.some(p => command.startsWith(p))) return false;
return true;
}Better. ls runs. rm -rf blocks. But the function only knows about one rule, baked in. CI gets the same gate as a local terminal. A subagent gets the same gate as its parent. You can't reconfigure without rewriting the function.
Stage 3: Discriminated union
The config carries the mode. The factory builds the function from the mode:
type ApprovalConfig =
| { mode: "interactive" }
| { mode: "background" }
| { mode: "delegated"; trust: string[] };
function createApproval(config: ApprovalConfig) {
return ({ command }: { command: string }) => {
if (config.mode === "background") return false;
if (config.mode === "delegated") {
return !config.trust.some((p) => command.trim().startsWith(p));
}
return !SAFE_PREFIXES.some((p) => command.trim().startsWith(p));
};
}Three modes, one function. The discriminated union makes the modes typed and exclusive. TypeScript narrows config.trust to string[] only inside the delegated branch, which is the kind of error the boolean and the function couldn't catch.
Now createBashTool takes an approval function instead of a safe-prefix list:
function createBashTool(
operations: BashOperations,
needsApproval: (input: { command: string }) => boolean,
) {
return tool({
// ... same description and schema
execute: async ({ command }) => {
if (needsApproval({ command })) {
return `Blocked: "${command}" requires approval.`;
}
const { stdout } = await operations.exec(command);
return stdout || "(no output)";
},
});
}And the three modes look like this at the call site:
// Interactive: human approves anything not on the safe list
const bash = createBashTool(localOps, createApproval({ mode: "interactive" }));
// Background: auto-approve everything (CI, automation)
const bash = createBashTool(localOps, createApproval({ mode: "background" }));
// Delegated: subagent inherits a trust slice from its parent
const bash = createBashTool(
localOps,
createApproval({ mode: "delegated", trust: ["pwd", "find .", "git status"] }),
);You could just write three separate functions: interactiveApproval, backgroundApproval, delegatedApproval. The discriminated union wins because the config is data, not code. You can load it from AGENTS.md, validate it with Zod (z.discriminatedUnion("mode", [...])), serialize it across a subagent boundary, and let users change modes without touching the harness code.
What this unlocks
background is the obvious one. The agent runs in CI, there's no human to ask, you trust the prompt enough to let it go.
delegated is the interesting one. When a subagent fires up, you don't hand it the full safe-prefix list. You hand it the specific commands it needs for its job. A read-only explorer gets pwd, find, git status. An executor running tests gets npm test, npm run build. The parent agent decides what trust to delegate, command by command.
interactive is what you've already been doing, just expressed as a config now.
Try It
Try each mode with a command that should block, and a command that should pass:
bun run index.ts . "Run: git status"In interactive mode, this passes (it's on the safe list). In background mode, anything passes. In delegated mode with trust: ["git status"], it passes.
bun run index.ts . "Run: npm install express"In interactive mode, this blocks. In background mode, it runs (and probably fails because we don't have npm wired up, but that's a different problem). In delegated mode, it blocks unless npm install is in the trust list.
npx tsc --noEmitA command can be approved and still fail for ordinary reasons. npm test passes the gate, then exits non-zero because tests fail. That's the command's problem, not approval's. Keep them separate when you're debugging.
Commit
git add index.ts
git commit -m "feat(approval): add discriminated union config with three modes"Done-When
ApprovalConfigis a discriminated union withinteractive,background,delegatedcreateApproval(config)returns aneedsApprovalfunctioncreateBashToolaccepts the approval function as a parameter- Each mode behaves correctly for at least one safe and one unsafe command
npx tsc --noEmitpasses
The interactive mode auto-rejects every unknown command, every time. That gets old fast. Try adding a Set<string> that tracks patterns the user approves during the session. When the user approves npm test, add the pattern. The next npm test call skips the prompt. Add a trust --list command to show what's trusted. Now think about granularity: should npm install trust everything, or only npm install express exactly?
Solution
type ApprovalConfig =
| { mode: "interactive" }
| { mode: "background" }
| { mode: "delegated"; trust: string[] };
function createApproval(config: ApprovalConfig) {
return ({ command }: { command: string }) => {
if (config.mode === "background") return false;
if (config.mode === "delegated") {
return !config.trust.some((p) => command.trim().startsWith(p));
}
return !SAFE_PREFIXES.some((p) => command.trim().startsWith(p));
};
}
function createBashTool(
operations: BashOperations,
needsApproval: (input: { command: string }) => boolean,
) {
return tool({
description: `Execute a shell command in the working directory.
WHEN TO USE: running build commands, installing packages, running tests,
git operations, directory listings.
WHEN NOT TO USE: reading file contents (use read instead).
Searching for patterns (use grep instead).
DO NOT USE FOR: reading files (use read), searching code (use grep).
USAGE: command is a single shell string. Commands not approved by the
approval policy are blocked and return a clear error message.`,
inputSchema: z.object({
command: z.string().describe("Shell command to execute"),
}),
execute: async ({ command }) => {
if (needsApproval({ command })) {
return `Blocked: "${command}" requires approval.`;
}
const { stdout } = await operations.exec(command);
return stdout || "(no output)";
},
});
}
const bash = createBashTool(localOps, createApproval({ mode: "interactive" }));Was this helpful?