The Permission Gate


Lecture Overview

Lecture 03 finished with all five tools wired up — read_file, list_files, write_file, edit_file, bash — and bash can run anything a shell can run, unconditionally, the instant runTool("bash", ...) is called. Nothing currently stops a model from asking for rm -rf fixtures/ and getting it. GATED — the set naming write_file, edit_file, and bash as dangerous — has been sitting GIVEN in your skeleton since lecture 01, unused. This lecture builds the two functions that make it matter: preview(name, input), which turns a tool call into a human-readable summary of exactly what's about to happen, and confirm(ask, name, input), which shows that summary and waits for a y/n answer before anything runs. Together they're the entire safety mechanism this course has — not sandboxing, not permission scopes, not an allowlist of safe paths, just a human in the loop for anything that changes the machine. Real Claude Code's permission system is a much richer version of exactly this same idea: show the human what's about to happen, get an explicit yes, refuse silently doing anything otherwise.

Building confirm also means writing this course's first real asynchronous code. Lecture 01 planted Promise<string> in the Asker type and told you to read it as "a function that eventually hands back a string" — a placeholder gloss, deliberately incomplete, so you wouldn't have to learn async control flow and the permission gate in the same breath. confirm cannot be a normal function: it has to pause, mid-execution, and wait for a real human to type something — possibly not for several seconds — before it can return an answer. Nothing else in index.ts so far has needed to wait for anything external: read_file blocks on disk I/O for microseconds and returns; confirm blocks on a person, for however long a person takes. That difference is the whole reason async/await exists, and today's the day you use it for real, not just read about it in a type signature. By the end of this lecture, agentLoop (lecture 05) has everything it needs to gate a tool call before running it.

How this lecture works: two numbered problems, 4.1 and 4.2, each with a spec, a worked example, constraints lifted directly from tests/04_permission_gate.test.ts, and 2–3 unlock questions whose answers live only in the Appendix. Write real code in index.ts for each before checking its snippet. This lecture is worth 1.5 of the course's 10 points: ./ok 04 green plus correct unlock answers.


1. Promises: A Value That Isn't Ready Yet

Concept

You've been reading Promise<string> since lecture 01 as "a function that eventually hands back a string" — true, but vague enough to hide the actual mechanics. Here's the real shape: a Promise<T> is an object representing a value of type T that doesn't exist yet, but will (or will fail trying) at some point in the future. It has three states: pending (not settled yet), fulfilled (settled with a value of type T), or rejected (settled with an error instead). Nothing about the type Promise<string> tells you when it settles — could be a microsecond (reading a variable), could be milliseconds (disk I/O), could be minutes (a human typing an answer). The type only promises eventually, one of those two outcomes.

Why It Matters

Every tool you built in lectures 02–03 — read_file, list_files, write_file, edit_file, bash — is synchronous: it blocks the entire program until it's done (disk I/O, spawning a process), then returns a plain value directly, no Promise involved. That was a deliberate simplification lecture 02 flagged and deferred ("why synchronous is fine here... is lecture 04's job"). Here's the payoff of that deferral: synchronous is fine for fast, bounded operations where blocking for a few milliseconds is invisible. It is fundamentally wrong for an operation that waits on a human, because there's no bound on how long a person takes to type y. A synchronous confirm would have to freeze the entire JavaScript runtime — no I/O, no timers, nothing — for however many seconds or minutes the human takes, which in Node's single-threaded event-loop model isn't just bad practice, it's not really achievable for keyboard input at all. readline's question() method is built as a Promise-returning function specifically because "wait for a line of stdin" is the textbook case a Promise exists for.

Your Task

No code yet — predict, don't write. Asker's question method has the signature question(prompt: string): Promise<string>. Two Promises of the exact same declared type, Promise<string>, can settle at wildly different speeds — one in a test (a scripted fake Asker that resolves instantly) and one at a real terminal (a human, who might sit there for 30 seconds). Does the type Promise<string> tell you anything about how long the wait will be, or only about what eventually comes back?

Checkpoint

Only about what comes back — the type says nothing about when. Promise<string> is a contract about shape ("eventually, either a string or an error"), never about timing. That's exactly why Asker can be satisfied both by a real readline.Interface (waits on a human) and by tests/04_permission_gate.test.ts's scriptedAsker (resolves on the next microtask, from a pre-loaded array — no waiting at all): both honor the same question(prompt: string): Promise<string> contract, and confirm, written against that interface, can't tell the difference and doesn't need to.

Stuck? See Snippet 1 in the Appendix.


2. await: What Actually Happens to Control Flow

Concept

await is the keyword that unwraps a Promise<T> into a plain T, but the mechanically interesting part is what it does to the surrounding function while it waits. When you write:

const answer = await ask.question("Allow? [y/n] ");

execution of confirm pauses at that exact line — not the whole program, just this one function's progress — and control returns to whatever called confirm, or further up the chain, or to Node's event loop itself, which goes and does other work (or, in a real terminal, just sits idle waiting for keystrokes). The instant the Promise settles — the human hits Enter — confirm resumes on the very next line, with answer now holding the plain string the Promise was carrying. From the reader's perspective, the code looks synchronous, top to bottom, one line after another. From the runtime's perspective, there was a real suspend-and-resume in between those two lines.

Why async has to be on the function signature. A function can only contain await if it's declared async — that's not a style preference, it's what makes the "pause and resume" machinery available at all. async function confirm(...): Promise<boolean> means two things at once: inside the function, you're allowed to await other Promises; outside the function, every caller now receives a Promise<boolean> back, never a bare boolean — even if confirm's body never actually needed to wait for anything, calling it still hands you a Promise you must await (or otherwise handle) to get the real value out. This is why confirm's signature in the skeleton is already Promise<boolean>, not boolean — the async-ness is contagious upward through the call chain, one caller at a time, all the way to agentLoop in lecture 05 (which will itself be async, precisely because it calls confirm).

Why It Matters

This is the concrete answer to "why must confirm be async": it has to await a human's answer, and await is only legal syntax inside a function marked async. There's no synchronous way to write "pause here until a Promise settles" in JavaScript/TypeScript — await (or its uglier ancestor, .then() callbacks) is the only mechanism. If you tried to make confirm a plain synchronous function that somehow "blocks" until the human answers, you'd either need to spin-loop checking a flag (burning CPU, freezing everything else, and still not actually working with readline's Promise-based API) or you'd need to redesign the entire tool around callbacks — a strictly worse version of what async/await already gives you for free.

Your Task

No code yet. agentLoop (built in lecture 05, but you can reason about it now) will need to call confirm and use its result to decide whether to run a gated tool. Given everything above, will the line that calls confirm inside agentLoop need its own await, and will agentLoop's own function signature need to be async too? Chain the logic through, the same way you just saw it chain from confirm up to its callers.

Checkpoint

Yes to both, and it's not a special case — it's the "contagious" property from §2 working exactly as described. agentLoop calls await confirm(...) to get a plain boolean out (not a Promise<boolean> it forgot to unwrap), which means agentLoop itself must be declared async function agentLoop(...): Promise<void> — and sure enough, if you glance at the skeleton's signature for agentLoop, it already is. Every function in the call chain from "a human types a keystroke" up to "the REPL's main() loop" ends up async, for the same one reason repeated at each link: somewhere below it, something has to wait on a real-world event that isn't instant.

Stuck? See Snippet 2 in the Appendix.


3. Asker: Dependency Injection, Not a Coincidence

Concept

Reopen the Asker type from lecture 01:

export type Asker = { question(prompt: string): Promise<string> };

This is deliberately the minimal slice of Node's real readline.Interface that confirm (and later, agentLoop) actually needs — one method, one signature. confirm is written to accept any object satisfying that shape, not specifically a real readline.Interface. This is dependency injection: instead of a function reaching out and grabbing a specific concrete resource itself (here, real stdin), you pass the resource in as a parameter, typed as the narrowest interface the function actually needs. The function becomes agnostic to where its answers come from — a real terminal, a test double, a network socket, anything satisfying question(prompt): Promise<string>.

Now look at how tests/04_permission_gate.test.ts exploits exactly this:

function scriptedAsker(answers: string[]): Asker & { prompts: string[] } {
  const prompts: string[] = [];
  return {
    prompts,
    async question(prompt: string) {
      prompts.push(prompt);
      return answers.shift() ?? "";
    },
  };
}

scriptedAsker(["y"]) builds an object with a question method that (a) records every prompt string it was asked, and (b) returns pre-scripted answers off a queue, one per call, instead of reading real keystrokes. It's still async — it still returns a Promise<string> — but that Promise resolves near-instantly, off canned data, with zero real waiting. Because confirm was written against the Asker interface and never against readline.Interface directly, every single test in this file can drive confirm through its full behavior — approvals, denials, lenient parsing — with no terminal, no human, no timing dependency, running in milliseconds as part of ./ok 04.

Why It Matters

This is why confirm is testable at all. If confirm had been written to call readline.createInterface(...) and read process.stdin directly, baked into its own body, there would be no way to script a y/n answer in a test without literally piping fake keystrokes into the test process — fragile, slow, and exactly the kind of test nobody bothers to write. By taking ask: Asker as a parameter instead, confirm doesn't know or care whether it's being driven by a human or a script, and the test suite gets to inject whichever one it wants. This is the same reason agentLoop takes an injectable model parameter (you'll see this explicitly in lecture 05) — the pattern repeats because it's the same problem (real component slow/flaky, test needs a fast fake) solved the same way.

Your Task

No code yet — read, don't write. Given scriptedAsker(["y"]) from the test file above, what does ask.prompts contain immediately after confirm calls await ask.question("Allow? [y/n] ") once? And separately: what would happen if confirm's implementation called ask.question(...) twice against a scriptedAsker(["y"]) built with only one scripted answer?

Checkpoint

ask.prompts would contain exactly ["Allow? [y/n] "] — the scripted question pushes every prompt string it receives onto that array before returning an answer, which is precisely how tests/04_permission_gate.test.ts's "shows the '[y/n]' prompt to the user" test verifies your confirm actually asked something, not just that it returned the right boolean. If confirm called question a second time against a one-answer script, answers.shift() on an empty array returns undefined, and the ?? "" fallback kicks in — the second call would resolve to "", not a crash. (This is also a hint that a correct confirm should only ever call question once per invocation — nothing about today's spec calls for retry loops.)

Stuck? See Snippet 3 in the Appendix.


4. Problem 4.1: preview — Showing Exactly What Will Happen

Concept

Before any human can meaningfully answer "yes" or "no" to a gated action, they need to know, precisely, what they're approving. A vague prompt like "Allow bash command?" is not informed consent — it's a coin flip dressed up as a safety check. preview(name, input) exists to close that gap: given a gated tool's name and its raw input object (the exact same shape runTool receives), it renders a plain-text summary of what's about to happen — the literal command, the literal file content, the literal old/new text — so the y/n that follows is answering a real, specific question.

You're formatting three distinct shapes, one per gated tool — bash, write_file, edit_file — because each tool's input carries a different kind of information a human needs to see differently:

Why It Matters

preview's output is the entire informed-consent surface of this whole course's safety design. There is no second layer checking whether an approved action was "actually" safe — once a human says yes to what preview showed them, confirm returns true and the real runTool call proceeds with zero further scrutiny. If preview's bash case silently truncated a long command, or its write_file case showed the path but not the content, a human could type y while genuinely misunderstanding what they just approved. Every dollar of trust this permission gate is worth comes from preview being complete and literal, not summarized or abbreviated.

Your Task

Spec. Write preview(name: string, input: any): string. Branch on name:

Worked example. preview("bash", { command: "echo hi" }) must return exactly "$ echo hi" — the bash test checks equality (.toBe(...)), not just containment, so this one branch has to be exact, not approximate.

Constraints (from tests/04_permission_gate.test.ts):

it("renders a bash command as '$ <command>'", () => {
  expect(preview("bash", { command: "echo hi" })).toBe("$ echo hi");
});
it("shows the target path and content for write_file", () => {
  const p = preview("write_file", { path: "a.txt", content: "body" });
  expect(p).toContain("a.txt");
  expect(p).toContain("body");
});
it("shows an old/new diff-ish view for edit_file", () => {
  const p = preview("edit_file", { path: "a.txt", old_string: "OLD", new_string: "NEW" });
  expect(p).toContain("OLD");
  expect(p).toContain("NEW");
});

Note the write_file and edit_file tests never check for input.path inside the edit_file case, and never check input.path is absent from the write_file case either — read the assertions literally: only the fields they actually call .toContain(...) on are graded. Anything extra you add (a label, a path, a header) is fine as long as it doesn't remove what's required.

Hint: This is a chain of if statements (or an equivalent branch) keyed on name, mirroring the switch (name) dispatch pattern from lectures 02–03, just returning a formatted string instead of running a tool. Template literals (lecture 03 §4's "Node gloss") are the natural tool for embedding multiple fields into one multi-line string — you'll want at least one \n inside the write_file and edit_file cases to separate the label from the content/diff visually, though nothing in the tests requires a newline specifically.
Common mistake: Reaching for JSON.stringify(input) as the return value for all four branches because it's the least amount of code and it does technically contain every field. It would pass the write_file and edit_file .toContain(...) tests (JSON output does contain the raw strings), but it fails the bash test outright — JSON.stringify({ command: "echo hi" }) is '{"command":"echo hi"}', not "$ echo hi", and that test checks exact equality. JSON.stringify is correct only as the anything-else fallback, not as a universal shortcut.

Unlock questions:

  1. The bash test uses .toBe(...) (exact match) while write_file and edit_file use .toContain(...) (substring match). Why might the test suite hold bash to a stricter standard than the other two — what's different about how a human reads a shell command versus how they read a file's new content?
  2. Lecture 02 §3 called switch (name) { ... default: ... } inside runTool a dispatch table. Is preview's if/else if chain on name doing the same conceptual job? What would change, mechanically, if you rewrote preview using an actual switch statement instead?
  3. The spec says the edit_file branch needs input.old_string and input.new_string visible, but doesn't require input.path. Given this lecture's "Why It Matters" (informed consent — a human needs to know what's about to happen), is omitting input.path from your edit_file preview actually a good design, even though the tests wouldn't catch you for doing it?

Checkpoint

Run just this problem's tests in isolation:

npx vitest run tests/04_permission_gate.test.ts -t "preview"

Expected: all three preview tests pass; the GATED set test and all six confirm tests are skipped by the -t filter, not failing — you haven't written confirm yet.

Stuck? See Snippet 4 in the Appendix.


5. Problem 4.2: confirm — Asking, and Parsing the Answer Leniently

Concept

preview renders the question; confirm actually asks it and interprets the answer. Three things happen, in order, every time confirm is called:

  1. Show the preview. Somewhere in confirm, call preview(name, input) and display it — the skeleton's doc comment says "print the preview," and console.log is the natural fit, the same way you've used it in earlier lectures' runTool reasoning.
  2. Ask, and wait. await ask.question("Allow? [y/n] ") — this is the await from §2 in action: confirm's execution genuinely pauses here until the Asker resolves with a string. Note the exact prompt text matters: tests/04_permission_gate.test.ts's first confirm test checks that some prompt containing "[y/n]" was actually sent, not just that a boolean came back correct — the gate has to actually ask, not silently default to an answer.
  3. Interpret the answer leniently. The raw string back from a human could be "y", "Y", "yes", "Y " (trailing space, from an errant keystroke), "n", "nah", or anything else. confirm needs one rule that correctly sorts all of these into "approved" or "not approved," without hardcoding every possible spelling of "yes."

That third step is the interesting design decision. The rule the tests require: trim whitespace, lowercase, then check if the result starts with "y". "Y " → trim → "Y" → lowercase → "y" → starts with "y" → approved. "yes" → already lowercase, no leading/trailing space → starts with "y" → approved. "nah" → starts with "n" → not approved, even though a human clearly typed a real word, not just a bare letter.

Why lenient, not strict-equals-"y". Humans don't type single characters reliably under real conditions — a trailing space from a fumbled keystroke, or typing out "yes" in full out of habit from other CLI prompts, are both extremely common and both obviously mean "yes." A confirm that only accepted the exact string "y" would reject "Y" (capitalization) and "yes" (a perfectly clear affirmative) as if they were denials — a a strict-equality gate is more fragile than a lenient one here, not more secure, because the failure mode of over-strict parsing is "the human said yes and got treated as no," which is just as wrong as the reverse.

Why It Matters

This is the direct payoff of §§1–2: confirm cannot be written without await, and now you can see exactly why in the code, not just in the abstract. Everything before the await line runs the instant confirm is called; everything after it only runs once a real answer exists — and in between, the calling code (eventually agentLoop) is free to do other things, or simply wait. Get the lenient-parsing rule right and a human can approve actions the way they naturally type; get it wrong (say, checking answer === "y" exactly) and you'll ship a gate that silently denies half of what a reasonable person would consider a clear "yes" — a worse failure than being slightly too permissive, because a wrongly-denied action just means retyping, while an unintentionally-approved one is the actual danger this gate exists to prevent. Correct lenient parsing threads that needle.

Your Task

Spec. Write async function confirm(ask: Asker, name: string, input: any): Promise<boolean>. Print (or otherwise display) preview(name, input). Call await ask.question(...) with a prompt string containing "[y/n]". Return true if the trimmed, lowercased answer starts with "y"; false otherwise.

Worked example. confirm(scriptedAsker(["Y "]), "bash", { command: "x" }) resolves to true — trim removes the trailing space, lowercase turns "Y" into "y", and "y".startsWith("y") is true. confirm(scriptedAsker(["nah"]), "bash", { command: "x" }) resolves to false — "nah" trimmed and lowercased is still "nah", and "nah".startsWith("y") is false.

Constraints (from tests/04_permission_gate.test.ts):

it("shows the '[y/n]' prompt to the user — the gate must actually ask", async () => {
  const ask = scriptedAsker(["y"]);
  await confirm(ask, "bash", { command: "echo hi" });
  expect(ask.prompts.some((p) => p.includes("[y/n]"))).toBe(true);
});
it("'y' approves", async () => {
  expect(await confirm(scriptedAsker(["y"]), "bash", { command: "x" })).toBe(true);
});
it("'n' denies", async () => {
  expect(await confirm(scriptedAsker(["n"]), "bash", { command: "x" })).toBe(false);
});
it("'Y ' (uppercase, trailing space) approves — answers are trimmed and lowercased", async () => {
  expect(await confirm(scriptedAsker(["Y "]), "bash", { command: "x" })).toBe(true);
});
it("'yes' approves — any answer starting with y counts", async () => {
  expect(await confirm(scriptedAsker(["yes"]), "bash", { command: "x" })).toBe(true);
});
it("'nah' denies — only a leading y approves", async () => {
  expect(await confirm(scriptedAsker(["nah"]), "bash", { command: "x" })).toBe(false);
});
Hint: String.prototype.trim() removes leading/trailing whitespace; String.prototype.toLowerCase() lowercases; String.prototype.startsWith(...) checks a prefix. Each of these returns a new string rather than mutating anything in place, so you have some freedom in how you combine them into the final check. Your question call needs to literally await — recall §2: without await, you'd be holding a Promise<string>, and comparing that against anything with .trim() would fail to type-check, since Promise<string> has no trim method, only string does.
Common mistake: Checking exact equality against the normalized answer instead of a prefix check. This passes the "y", "Y ", and "n" tests but fails "yes" outright — "yes" would fail equality but should count as an approval. Re-read the spec's exact wording: "any answer starting with y counts," which describes a prefix check, not an equality check.

Unlock questions:

  1. Trace "Y " through the pipeline by hand: what does .trim() produce, what does .toLowerCase() produce on top of that, and why does .startsWith("y") return true at the end? Do the same for "nah" and confirm why it returns false.
  2. The first confirm test asserts something about ask.prompts, not about confirm's return value. What would a confirm implementation that returns the technically correct booleans, but never actually calls ask.question(...) at all (say, by hardcoding a default), fail on — and why does that failure matter more than it might look like from the assertion alone?
  3. preview is a plain (non-async) function returning string; confirm is async returning Promise<boolean>. Given §2's "contagious" rule (a function needs async only if it itself contains an await), why does confirm calling preview(name, input) inside its body not force preview to become async too?

Checkpoint

Run the full lecture 04 suite:

./ok 04

Expected — real output, captured from this exact repo before this lecture's code exists (the whole 9-of-10-fail state you're about to fix):

ok: running tests for problem 04

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 ❯ tests/04_permission_gate.test.ts (10 tests | 9 failed) 6ms
     × renders a bash command as '$ <command>' 2ms
     × shows the target path and content for write_file 0ms
     × shows an old/new diff-ish view for edit_file 0ms
     × shows the '[y/n]' prompt to the user — the gate must actually ask 0ms
     × 'y' approves 0ms
     × 'n' denies 0ms
     × 'Y ' (uppercase, trailing space) approves — answers are trimmed and lowercased 1ms
     × 'yes' approves — any answer starting with y counts 0ms
     × 'nah' denies — only a leading y approves 0ms

 Test Files  1 failed (1)
      Tests  9 failed | 1 passed (10)

(The one pre-existing pass is the GATED set test — GATED was already GIVEN, lecture 01, nothing to write there. Every failure above is Error: YOUR CODE HERE, thrown from whichever of preview/confirm the test called.)

After you've implemented both functions, expected output flips to:

ok: running tests for problem 04

 RUN  v4.1.10 /Users/jeffrey/Documents/claude_playground/claude-code-nano

 Test Files  1 passed (1)
      Tests  10 passed (10)

All 10 green: the GATED set check, all three preview cases, and all six confirm cases (the prompt-text check, y/n, and the three lenient-parsing edge cases). That's Problem 4.1 and 4.2 done — 1.5 of the course's 10 points, plus correct answers to every unlock question above.

Stuck? See Snippet 5 in the Appendix.


6. Denial Is Data, Not an Exception

Concept

You now have a confirm that returns false when a human says no. Sit with that return type for a second: Promise<boolean>, not Promise<void>, not something that throws on denial. A "no" is not an error condition — it's a completely ordinary, expected outcome that confirm's type signature already accounts for. Compare this with lecture 02 §5's convention for runTool: failures there throw, because a missing file or a non-zero exit code really is an exceptional condition the caller didn't ask for. A human declining a gated action is different in kind — it's not runTool malfunctioning, it's the safety mechanism working exactly as designed.

This distinction matters because of what happens next, in lecture 05. When agentLoop sees confirm return false, it does not crash, does not throw, does not skip silently. It converts the denial into an ordinary tool_result — the same kind of message a successful tool call produces, just carrying different content (something like "The user declined this action.") — and sends it back to the model exactly the way lecture 01's trace sent back "Edited fixtures/convert.js" for a successful edit_file. The model reads that denial as plain text on its next turn and reacts to it as information: maybe it asks a clarifying question, maybe it tries a different, less risky approach, maybe it just stops. None of that reaction logic lives in confirm — confirm's entire job ends the moment it returns true or false.

Why It Matters

If denial were implemented as a thrown exception instead, lecture 05's agentLoop would need a second, structurally different code path just to handle "the human said no" — separate from the try/catch around runTool that already exists for genuine tool failures (lecture 02 §5, lecture 03's edit_file/bash error paths). By making denial an ordinary boolean, the loop's shape stays uniform: gated tool → confirm → boolean → branch on it, no exception machinery required for what is, after all, an entirely normal and anticipated outcome. This is the same design instinct as lecture 01 §3's unlock question 4 (an edit_file failure produces an is_error: true tool_result, and the loop keeps going, not stopping) — errors and denials are both just data the model gets to read and respond to, never events that unwind the stack.

Your Task

No code — connect the dots forward into lecture 05. Given that confirm returns a plain boolean, and given lecture 01's message-protocol rules (a tool's outcome always becomes a tool_result block, role: "user", matched by tool_use_id), predict: when agentLoop sees a denial, what two fields of that tool_result block would you expect it to set, and would is_error plausibly be true or false?

Checkpoint

content would hold some human-readable denial message (something like "The user declined this action." — the model needs some text explaining what happened, not an empty string), and is_error would plausibly be false, not true — because nothing actually went wrong. The gate did its job correctly; the model's request was heard and explicitly refused, which is a completely valid, non-erroneous outcome for a tool_result to carry. (You'll see the real answer, and the exact string used, when lecture 05 builds agentLoop for real — this is a prediction exercise, not a ./ok-checked answer, so there's some room for reasonable disagreement on the exact wording, but not on the shape.)

Stuck? See Snippet 6 in the Appendix.


7. Why Reads Aren't Gated: The Threat Model in Two Sentences

Concept

GATED names exactly three tools — write_file, edit_file, bash — and conspicuously omits read_file and list_files. The rule isn't "gate anything the model might get wrong"; it's narrower and sharper: gate anything that can change state on the human's real machine, and nothing else. read_file and list_files can return a wrong or unexpected answer (a huge file, a path outside the project), but no matter what path the model asks for, calling either one leaves your disk in exactly the state it was in before the call — there's nothing to consent to, because there's nothing to undo.

Why It Matters

This is the entire threat model this course's permission gate is built around, in two sentences: a mistaken read costs you, at worst, a confusing answer the model has to recover from on its own — no human needed, because nothing external changed. A mistaken write — an overwritten file, a botched edit, a destructive shell command — changes something real, possibly irreversibly, and that's precisely the class of action this course decided deserves a human's explicit yes before it happens. Curriculum-wise, this is also exactly the boundary the course draws around what it's teaching versus what it's skipping (recall lecture 03 §5 and the curriculum's own "what we deliberately skip" list): no sandboxing, no path restrictions, no scoped permissions — the y/n gate in front of state-changing actions is the one lesson this course commits to, on purpose, as the lesson about agent safety, not a partial one.

Your Task

No code. One-sentence prediction: if a sixth tool were added to this course later — say, delete_file — would it belong in GATED, and why, using the exact two-sentence rule above rather than a general "better safe than sorry" instinct?

Checkpoint

Yes, delete_file belongs in GATED — it changes state on the real machine (removes a file that existed before the call), which is the entire and only criterion GATED membership runs on. It doesn't matter whether deleting is "less complicated" than editing or "more dangerous" than writing — the rule isn't a severity ranking, it's a binary: does calling this tool leave the disk different than it found it? delete_file clearly does, so it clears the same bar write_file, edit_file, and bash already cleared.

Stuck? See Snippet 7 in the Appendix.


Key Takeaways

  1. A Promise<T> is a value of type T that doesn't exist yet — a pending/fulfilled/rejected container whose type says nothing about when it settles, only what eventually comes back. Asker's Promise<string> is satisfied identically by a real human at a terminal and an instant-resolving scripted test double.
  1. await pauses the enclosing async function at that exact line and resumes it once the Promise settles. async-ness is contagious upward: any function that calls await confirm(...) must itself be async, all the way up to agentLoop and eventually main.
  1. Asker is dependency injection in one method — confirm depends on the narrowest possible interface (question(prompt): Promise<string>), not a concrete readline.Interface, which is exactly what lets tests/04_permission_gate.test.ts drive it with a scriptedAsker and no real terminal.
  1. preview renders exactly what's about to happen, per gated tool, because informed consent requires literal, complete information.
  1. confirm parses answers leniently: trim, lowercase, startsWith("y") — not because strictness is bad, but because humans reliably type "Y ", "yes", and similar affirmatives that a strict-equals check would wrongly read as denials.
  1. Denial is data, not an exception. confirm returns a plain boolean; lecture 05's agentLoop will turn a false into an ordinary tool_result (is_error: false) the model reads and reacts to on its own — the same "errors/denials are just information the model gets to see" principle from lecture 01's unlock questions and lecture 02's throw-don't-swallow convention.
  1. GATED draws the line at "changes real machine state," not "might be wrong." read_file/list_files can return bad answers with zero consequence to your disk; write_file/edit_file/bash can change something real and irreversible — that distinction, and only that distinction, is what earns a tool a spot in GATED.

What we skipped (for future study or future lectures)


Appendix: Reference Implementation

Check your work here after attempting each section. Don't read ahead — one snippet at a time.

Snippet 1: Promise timing answer

Promise<string> is a contract about shape only — eventually, either a string (fulfilled) or an error (rejected). Nothing in the type constrains when that happens. A real readline.Interface's question() and a test's scriptedAsker's question() both satisfy Asker, and both return genuine Promise<string> values, even though one waits on human input (unbounded time) and the other resolves almost immediately off a pre-loaded array. Code written against Asker — like confirm — cannot tell, and doesn't need to, which kind of Promise it received.

Snippet 2: await chain answer

Yes to both. agentLoop must write await confirm(ask, block.name, block.input) to get a plain boolean (not a Promise<boolean>) out of the call, and await is only legal inside a function declared async — so agentLoop's own signature must be async function agentLoop(...): Promise<void>, which is exactly what the skeleton already declares. This is the "contagious" property: every function on the call path from a human's keystroke up through confirm up through agentLoop up through main ends up async, each one for the same underlying reason — something below it in the chain has to wait on a real-world event.

Snippet 3: scriptedAsker trace

After confirm calls await ask.question("Allow? [y/n] ") once against scriptedAsker(["y"]), ask.prompts is ["Allow? [y/n] "] — the scripted question method pushes its prompt argument onto the prompts array on every call, before returning the next queued answer. If confirm called ask.question(...) a second time against that same one-answer script, answers.shift() on the now-empty array returns undefined, and ?? "" supplies "" instead of throwing — the call would still resolve, just with an empty string as the "answer," which would fail the lenient-parsing check ("".startsWith("y") is false) rather than crash. Nothing in this lecture's spec calls question more than once per confirm invocation, so this scenario shouldn't arise in a correct implementation.

Snippet 4: preview case
export function preview(name: string, input: any): string {
  if (name === "bash") return `$ ${input.command}`;
  if (name === "write_file") return `write ${input.path}:\n${input.content}`;
  if (name === "edit_file") return `edit ${input.path}:\n--- old\n${input.old_string}\n+++ new\n${input.new_string}`;
  return JSON.stringify(input);
}
Snippet 4: preview case — unlock answers
  1. A shell command's exact text is safety-critical down to the character — "echo hi" versus "echo hi; rm x" differ by a few characters with wildly different consequences, so a human approving a bash call needs to see the literal string, not a paraphrase or a truncated version, hence exact equality in the test. File content, by contrast, is naturally long and free-form — a human confirming a write just needs to see the content is present and recognizable, not verify it byte-for-byte in the test itself (a real human reading the actual printed preview still sees everything; the test's .toContain(...) looseness is about not over-constraining your formatting choices, not about the human seeing less).
  2. Conceptually yes — both are dispatch-by-key. Mechanically, a switch (name) { case "bash": return ...; case "write_file": return ...; ... default: return JSON.stringify(input); } would behave identically for every input in this lecture's tests; the choice between if/else if and switch here is stylistic, not behavioral, unlike runTool's dispatch where switch's default fallback (throwing on an unknown tool) mattered for correctness. preview's fallback returns a value instead of throwing, so either dispatch style is equally correct.
  3. Arguably not ideal, but defensible for this course's scope: a human approving an edit does lose useful context (which file is about to change) if path isn't shown, and a stronger preview would include it. The tests don't require it because they only check for the presence of old_string/new_string — but nothing stops you from writing a more complete preview than the bare minimum the tests enforce, and doing so is closer to this section's own stated goal of informed consent. This is a case where "passes the tests" and "is the best implementation" aren't quite the same bar — worth noticing now, since it'll recur.
Snippet 5: confirm implementation
export async function confirm(ask: Asker, name: string, input: any): Promise<boolean> {
  console.log(`\n[permission] ${preview(name, input)}`);
  const answer = await ask.question("Allow? [y/n] ");
  return answer.trim().toLowerCase().startsWith("y");
}
Snippet 5: confirm implementation — unlock answers
  1. "Y " → .trim() → "Y" → .toLowerCase() → "y" → .startsWith("y") → true. "nah" → .trim() → "nah" (no whitespace to remove) → .toLowerCase() → "nah" (already lowercase) → .startsWith("y") → false, because the first character is "n", not "y".
  2. It would fail the "shows the '[y/n]' prompt to the user" test specifically — ask.prompts would stay empty ([]) since question was never called, so ask.prompts.some((p) => p.includes("[y/n]")) evaluates to false against the test's expectation of true. This matters beyond just failing one test: a confirm that returns correct-looking booleans without ever actually asking would be lying about being a permission gate at all — every other test in the suite happens to script "y" as the first (and often only) answer, so a hardcoded return true could coincidentally pass several of them too, which is exactly why this test exists — to catch a fake gate that never really asks.
  3. Because "contagious" only runs in one direction: a function needs async only if it itself contains an await. confirm calling preview(...) synchronously — no await in front of that call, because preview returns a plain string, not a Promise<string> — doesn't require anything from preview's own signature. preview stays a plain function precisely because none of its own work (string formatting) ever needs to pause and wait on anything external. async-ness propagates upward through callers of async functions, not through functions being called by an async one.
Snippet 6: Denial-as-data forward reference

content would hold a human-readable string explaining the outcome (something like "The user declined this action."), and is_error would be false — a denial is the gate functioning correctly, not a failure. This mirrors lecture 01 §3 unlock question 4's prediction about a thrown edit_file error (is_error: true, loop continues) but lands on the opposite is_error value, because the two situations are different in kind: one is an unplanned failure the model should try to work around; the other is a deliberate, expected "no" the safety mechanism is designed to produce.

Snippet 7: GATED rule answer

Yes — delete_file would belong in GATED under the exact rule from §7: it changes state on the real machine (a file that existed before the call no longer does after it), which is the one and only test GATED membership runs on. The rule is binary, not a severity scale, so no separate judgment call about "how dangerous" deleting is relative to writing or editing is needed — clearing the "does this leave disk state different than it found it" bar is sufficient by itself.


End of lecture.