Skip to content

Part III · Build It / Ch. 04

Part III Chapter 4: Fitting Tools onto the Hands — Tools ​

Xia A and Lao Z guide a shaped piece through a matching aperture and caliper gate; a vine stops outside the clear enclosure as the octopus trims a ribbon from a small device.

Chapter guide: You will write the three tools read, write, and bash in src/tools.ts, plus the workspacePath path boundary and the truncateHead output truncation. The rules this step has to set up: four gates (schema, runtime validation, truncation, realpath) control what the model can touch, but none of them is OS-level isolation.

The loop was running, but Xiao A noticed something: when the model said it would "read a file," it really read one; but when it said it would "write a file," it could actually write outside the workspace. Xiao A broke into a cold sweat and hurried to find Lao Z.

"Should I add permissions to the tools?" he asked.

"Add them, but first think through where they go," Lao Z said. "createWorkspaceTools() in src/tools.ts returns read, write, and bash. Every ToolSpec has properties, required, and additionalProperties: false; before execution, stringField() does a runtime check, because the model's input is still untrusted."

Xiao A jumped to the summary: "Got it. The schema governs what the parameters look like, and the runtime catches whatever slips through."

"Catching whatever slips through is right. But this set of checks does path boundaries; it can't do an OS sandbox. There is still a TOCTOU window between the check and the actual open, and bash can still use whatever permissions the process itself has. The schema and the runtime check are two doors; you need both."

Xiao A wrote down "two doors" and asked again: "Then for the three tools createWorkspaceTools() returns, who defines their boundaries?"

"The boundary isn't inside the tools; it hangs off the root parameter of createWorkspaceTools(workspaceRoot)," Lao Z said. "Every check in workspacePath unfolds relative to rootReal: realpath(root) first resolves the real path of the root once, and from then on every candidate is compared against it. The root is locked in at construction time; the model can't change it on any call — all it has in hand is a path relative to the workspace. That division of labor — 'the root comes from construction, the path comes from the request' — is the precondition for the path boundary to hold at all."

The declaration and the runtime are two doors ​

Xiao A: "So what does each of those two doors do?"

Lao Z opened tools.ts:

ts
spec: {
  name: "write",
  inputSchema: schema(["path", "content"], {
    path: { type: "string" }, content: { type: "string" },
  }),
}

"The schema is for telling the model the expected shape; additionalProperties: false is a constraint expressed to the Provider, not a local JSON validator."

Xiao A pressed: "Doesn't the Provider validate the model output against the schema? Why check it again?"

"Because the schema plays two roles here, and you must not conflate them," Lao Z said. "The first role is 'tell the model how to call,' and it shows up in the input_schema sent to the Provider; the model generates its parameters from it. The second role is 'the Provider blocks bad parameters for us,' but nothing in the contract guarantees that: the Provider might change its behavior one day, and the model might emit JSON that slips around the schema. We can't control the Provider, but we can control our own executor. So stringField() is the backstop: no matter whose input it is, by the time execution reaches this step it gets revalidated. That's defense in depth: you trust the schema as usual, but security can't rest on that single gate alone."

Xiao A noticed the return value: "Then why does stringField return undefined instead of throwing?"

"Because the way a tool failure is handled is 'turn it into a message,' not 'interrupt the loop,'" Lao Z said. "The write executor returns { content: "invalid write input: ...", isError: true }, and the loop records it as a tool message to pass to the next round. The model reads 'path must be a string' and decides for itself whether to fix it. Throwing strips the model of the chance to fix; returning isError gives it feedback. These two paths are drawn clearly apart in agent.ts: isError takes the 'record and continue' road, a thrown exception takes the 'record the failure and rethrow' road."

Xiao A: "So is there a difference between undefined and an empty string? Would stringField treat an empty string as a valid value?"

"It would, and that's deliberate," Lao Z said. "stringField only judges 'is this the string type,' not 'is this non-empty.' An empty string is valid content: if you want to write an empty file, content should be an empty string. The real failure signal is undefined — the field is missing, the type is wrong, or input isn't an object at all. 'Right type but empty content' and 'wrong shape' are two different things; stringField only blocks the latter. If you want empty strings rejected too, that's a business rule — put it in the executor, not in this general-purpose function."

Xiao A: "All three tools reuse the same stringField. Won't the error messages be written too vaguely?"

"Just look at the executor's wording," Lao Z said. "read says invalid read input: path must be a string, write says invalid write input: path and content must be strings, bash says invalid bash input: command must be a string. Tool name first, rule after. When the model reads this message it learns at once 'which tool,' 'which field,' and 'which rule' — only then does fixing have something to go on. This is feedback for the model, and the information density of that feedback directly decides whether it gets the next round right."

The normal path, from declaration to execution ​

Lao Z then walked through the whole flow: "Take write as the example. The model first sees the ToolSpec's name, description, and schema; if it returns { path, content }, stringField() pulls out each string field one at a time. If either field is not a string, the tool returns an isError: true result directly, and the Agent can hand it back to the next round. Only after the field shapes pass does it enter the file system."

ts
function stringField(input: unknown, field: string): string | undefined {
  const value = asObject(input)?.[field];
  return typeof value === "string" ? value : undefined;
}

"asObject first rules out null, arrays, and primitive types; optional access means a missing field yields undefined; and at the end only a string is accepted," Lao Z said. "This does not treat the JSON schema as a runtime validator — which is exactly the repeated check the boundary of a model call requires."

Xiao A questioned each of the three exclusions in asObject one by one: "Why rule out null? Why are arrays out too?"

"typeof null is "object", but accessing a field on it blows up outright; typeof an array is also "object", but it has no named field of the one you want," Lao Z said. "asObject's job is to answer one precise question: 'can this value be safely read for fields as an object?' Only a value that is non-null, non-array, and of object type answers 'yes.' Those three checks are not defensive decoration; they are the safe downgrade the type input: unknown itself demands. Going from unknown to Record, every step has to be proven explicitly."

The path decision sequence ​

Xiao A: "So how is a path escape prevented? Can a symlink fool it?"

"workspacePath() runs realpath(root) first, then resolves the candidate path." Lao Z typed out a stretch:

ts
const requested = resolve(rootReal, candidate);
if (creating) {
  try { await lstat(requested); }
  catch (error) { checked = await nearestExistingAncestor(dirname(requested)); }
}
const checkedReal = await realpath(checked);
if (!isInside(rootReal, checkedReal)) throw new Error("path escapes workspace");

"The first line only does lexical resolution; it hasn't yet proven the object is inside the workspace. When the target exists, lstat lets the later realpath see the symlink's final location; when the target doesn't exist, the nearest ancestor is the only resolvable filesystem object. The canonical paths are compared only at the end. The real code walks up to an ancestor only on ENOENT — a permission error propagates rather than being disguised as 'file does not exist.'"

Xiao A stared at the creating parameter: "Why does writing a file have to go down a separate path?"

"Because when you write a file that doesn't exist, realpath fails — it can only resolve paths that already exist," Lao Z said. "So workspacePath, when creating, runs lstat first: if the target exists, resolve it normally; if not, walk up to the nearest existing ancestor and check whether that lies inside the workspace. Look at nearestExistingAncestor:"

ts
// examples/mini-agent/src/tools.ts, excerpt
async function nearestExistingAncestor(path: string): Promise<string> {
  let current = path;
  while (true) {
    try {
      await lstat(current);
      return current;
    } catch (error) {
      if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
      const parent = dirname(current);
      if (parent === current) throw new Error("no existing ancestor");
      current = parent;
    }
  }
}

"It walks up via dirname all the way until it finds a directory that exists. Note if (parent === current): it throws only after reaching the root of the filesystem and still finding nothing — otherwise it loops forever. And isInside uses relative for the judgment, and also handles the Windows path separator:"

ts
// examples/mini-agent/src/tools.ts, excerpt
function isInside(root: string, path: string): boolean {
  const value = relative(root, path);
  return (
    value === "" ||
    (!value.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) &&
      value !== ".." &&
      !isAbsolute(value))
  );
}

"The result of relative(root, path): if it starts with .. or is .. itself, that means path lies outside root. A look-alike prefix like /work-other/x gets rejected exactly this way."

Xiao A read the whole thing: "Put these checks into real scenarios — what do they look like?"

Lao Z drew the five typical scenarios into a single table, and Xiao A went through them row by row:

ScenarioObject checkedResult
src/a.ts existstarget realpathread and write only if inside root
nested/a.ts missingnearest existing parent dirmkdir allowed only if inside root
escape/x is a directory symlinksymlink's final directoryrejected if outside
write-link is a file symlinktarget's final filerejected if outside
/work-other/x look-alike prefixrelative/isAbsoluterejected

"Why doesn't this symlink check on the root apply to bash?" Xiao A asked. "Doesn't the bash tool also call workspacePath?"

"bash calls workspacePath(workspaceRoot, ".", false) — the candidate path is ".", that is, the workspace root itself," Lao Z said. "So bash only pins the cwd to the real workspace root; it does not check the paths the command itself runs against. cd /etc && cat shadow gets through, because that's the shell moving freely outside the cwd. The path boundary protects the two file tools, read and write — not bash's command content. bash's boundaries are timeout, maxBuffer, and approval at the call layer, and none of the three counts as path isolation."

Xiao A was uneasy: "Then if the tests pass green, does that prove this set of checks is absolutely safe? What exactly does it block — just symlinks?"

"No. A passing test proves which cases it blocks; it cannot prove that no race condition exists," Lao Z said. "It blocks three common classes of mistakes, and the test file has a case for each: a plain escape, path: "/etc/passwd" — relative computes it and immediately finds it isn't inside root; a look-alike prefix, /work-other/x — the relative result starts with .. and gets rejected; a static symlink, escape pointing outside the workspace — after realpath resolves it, the comparison finds it isn't inside root. On top of that, the tests cover truncation, nested write, and symlink read/write escapes. What it can't block is the fourth class, TOCTOU: between the check finishing and writeFile running, an external process swaps the directory for a symlink. That's a race, and at the application layer no atomic operation eliminates it — only an OS-level sandbox can."

Xiao A: "Then why not just go straight to an OS sandbox?"

"Because that's not this layer's job," Lao Z said. "An OS sandbox is containerization or privilege dropping — a separate piece of engineering that needs its own runtime environment. The deliverable of this chapter is a library; it can't assume it runs inside a container. The path boundary is the ceiling of the application-layer side; anything above it has to rely on the container or privilege dropping we mentioned earlier."

Xiao A drew the symlink scenario on paper once through, then asked: "If there's already a symlink pointing outside inside the workspace, and the model uses read on it, what's the error message?"

"realpath resolves it to an outside path, isInside returns false, and it throws path escapes workspace," Lao Z said. "That error is caught by read's catch, and via failure(cause) it becomes an isError: true result. What the model reads is 'path escapes workspace,' not Node's raw stack. A boundary violation and a command failure go down the same return channel; the model doesn't need to distinguish 'the path is bad' from 'the file doesn't exist' — it only needs to know 'this action didn't happen; try another way.'"

Output and execution boundaries ​

Xiao A: "Then what about long files? If read takes a 100,000-line file, won't it blow up the context?"

"Truncation. When read exceeds MAX_READ_CHARS, it keeps the head and tail and marks how much was omitted; so the model knows the content is incomplete." Lao Z pulled up read's truncation logic:

ts
return {
  content: content.length <= MAX_READ_CHARS
    ? content
    : `${content.slice(0, 8_000)}\n[truncated ${content.length - MAX_READ_CHARS} characters]\n${content.slice(-8_000)}`,
};

Xiao A: "Why not just keep the beginning? Aren't the definitions usually up front?"

"The beginning often has the definitions, the end often has the error or the result — neither end can be thrown away," Lao Z said. "Keeping both ends is an explainable tradeoff, but the middle is still lost. The test asserts that the marker, the head, and the tail are present; that proves only the current truncation rule, not that every large file will be correctly understood by the model."

He then pointed through the other two tools: "write, only after the checks, runs mkdir(..., { recursive: true }) and writeFile, so nested new directories can be created; bash uses the shell, the workspace cwd, a ten-second timeout, and a 64KiB buffer. The timeout is a resource limit, not a proof that a command is safe."

Xiao A: "MAX_READ_CHARS is 16000, and it takes 8000 from the head and 8000 from the tail. Which side does the marker line count on?"

"It counts toward the overflow," Lao Z said. "content.length - MAX_READ_CHARS is the number of characters left out, written into the marker line. An 18000-character file, once truncated, is 8000 head + marker line + 8000 tail. This design admits that 'truncation necessarily loses information,' so it spells out exactly how much was lost. That's better than silent truncation: the model at least knows 'there's a hole here,' and won't mistake the 8000 tail for the whole text."

Xiao A picked it up quickly: "I get bash's two numbers too — timeout governs how long a command runs, maxBuffer governs how large the output is, right?"

"Right — two dimensions, neither substitutable for the other," Lao Z said. "If either fires, it goes into the catch and returns failure(cause). But they are only resource limits: an rm -rf can finish in under a second, nowhere near triggering the timeout, and whether a command is dangerous isn't under their jurisdiction at all. The checklist writes 'timeout is a resource limit, not a proof of command safety'; what it guards against is 'a command hangs' and 'the output explodes.'"

Xiao A: "Why does the content bash returns get truncated once more with slice(0, 64 * 1024)? Doesn't execFile's maxBuffer already limit it?"

"One limits the buffer, the other clips what's returned," Lao Z said. "maxBuffer caps how much output accumulates in execFile's internal buffer; going over throws. But even within the cap, stdout plus stderr joined together can approach 64KiB. ${result.stdout}${result.stderr} — after joining, take one more slice — that puts a second safety margin on the size of the message the model sees. Both limits are there, but their duties differ: one keeps execFile from blowing up memory, the other keeps the model from receiving an overlong result. It's the same idea as read's truncation: let the model know 'this got cut' rather than let it swallow an uncontrolled volume."

bash's failure semantics ​

Xiao A: "Command not found, a timeout, the output blowing up — do these failures go down the same path?"

"The same path," Lao Z said. "execFile's timeout, maxBuffer, or a nonzero exit all enter the catch, and the tool returns failure(cause), turning cause.message into the result text: Command failed: ..., the timeout message, or the maxBuffer error — all handed to the model as an isError: true message. That lets the Agent get the error text instead of a Node exception terminating the loop outright. So the model can tell apart 'the command ran but exited nonzero' and 'the command never ran at all,' but it doesn't need to distinguish the Node mechanics underneath."

Xiao A: "Where does the shell bash uses come from? Why isn't /bin/bash hardcoded?"

"process.env.SHELL ?? "/bin/sh" — the environment variable takes priority; if it's missing, fall back to the POSIX default," Lao Z said. "There's a tradeoff here: respect the user's configuration so commands run in the environment the user knows; the cost is that a different shell may behave slightly differently. It doesn't assume the user runs bash, and it doesn't vouch for 'identical behavior on every machine.' If you're the deployer and want to pin the shell inside a sandbox, just replace the environment variable — that line is a policy, not a hardcode."

Xiao A: "What about signal? What does execFile accepting a signal actually do?"

"It pairs with cancellation," Lao Z said. "The tool interface signature is execute(input, signal?); bash passes it straight through to execFile's options, so when an external AbortSignal fires, the child process gets terminated instead of dangling there. But cancellation is cooperative: a signal can stop the process execFile is waiting on, but it can't stop the subcommands already started inside the shell or the files already written out — those don't roll back because of a signal. Cancellation docs keep emphasizing 'side effects are the caller's responsibility,' and this is why."

"What about read and write? Why doesn't their execute take a signal?" Xiao A asked.

"Because a single read or write lives too briefly to be worth scheduling cancellation for," Lao Z said. "readFile and writeFile wait for the I/O to complete the moment they're called; the payoff from cancelling them trends to zero. Cancellation has a cost, and whether to honor it comes down to whether it pays. bash has to take it, because a command might run a long time; the file tools don't, because their execution window is inherently narrow. On the interface, signal is an optional parameter — exactly what accommodates that difference."

Design benefits, costs, and what's left uncovered ​

Xiao A finished reading the whole chapter's code and let out a long breath: "So the tradeoff of this chapter is..."

"Path canonicalization makes common symlink escapes visible, at the cost of more asynchronous I/O and a race that can't be eliminated; truncation controls the context, at the cost of lost information; a shell tool can verify its changes, at the cost of the largest possible permission surface," Lao Z said. "We did not implement a file mutation queue, temp files for output, MIME handling, atomic writes, containerization, or privilege dropping. These are all things we deliberately left undone."

Xiao A: "What's a mutation queue? Why not build it?"

"It's the transactional model where 'you collect all write operations first, validate them, then commit once,'" Lao Z said. "To build it you'd need a write-operation queue, a commit point, and a rollback mechanism — each one a piece of independent complexity. In the current deliverable, write is immediate: the instant writeFile is called, the file is changed. We don't build a mutation queue because no failure evidence yet shows it's needed. The failure semantics right now are 'the write errored partway through, the model reads the error and decides for itself' — the loop closes."

"Then privilege dropping?" Xiao A pressed. "bash runs under the process's own permissions — isn't that the largest permission surface?"

"It is, and that is exactly why the approval layer exists," Lao Z said. "denyBashByDefault in approval.ts is wired into beforeToolCall in main. bash is denied by default at the production entry point, and the model receives 'bash requires an explicit approval policy.' Approval is not done inside the tool; it's handed to the call layer. The tool itself has no 'dangerous-command blacklist' — it assumes the caller has already decided 'should this bash run.' Separating approval from execution is so the tool can be tested: in tests you pass an always-approve hook; in production you pass denyBashByDefault."

Xiao A lined up the four gates in his head and asked: "Can we draw a picture showing exactly what 'model input' has to pass through before it reaches 'the filesystem'?"

Lao Z drew a vertical line on paper:

text
Model output (untrusted)
    │
    ▼
① schema declaration ── tells the model the expected shape, adds additionalProperties: false
    │
    ▼
② stringField() ─────── executor backstop, re-validates any input, returns isError on a bad one
    │
    ▼
③ workspacePath() ───── realpath + nearestExistingAncestor + isInside
    │                    an escape throws path escapes workspace
    ▼
④ truncateHead/TruncateTail, timeout/maxBuffer
    │
    ▼
File system / subprocess (the last boundary at the application layer; beyond it is the OS)

"What the four gates have in common is this: not one of them offers a guarantee 'after it stops something'; they only offer interception 'before it reaches.'" Lao Z said. "① blocks a wrong shape of the model's understanding, ② blocks a wrong shape in fact, ③ blocks the path, ④ blocks the volume. All four sit inside the application layer — which is why the checklist keeps stressing that a path boundary cannot stand in for an OS sandbox. After the fourth gate you're at the real filesystem, and there are no gates there: TOCTOU, shell permissions, network access — none of that falls under this chapter."

Summary ​

The tool layer uses four gates to control "what the model can touch": schema validation blocks parameters with the wrong shape, runtime parsing handles the actual values the model supplies, output truncation keeps a single result from blowing up the context, and the realpath boundary makes symlink escapes visible. The four gates each watch one stretch, and none can be replaced by the other three — but all of them are at the application layer, and not one of them can replace OS-level isolation. workspacePath constrains the workspace with realpath plus the nearest existing ancestor plus a relative-path check; truncateHead and truncateTail cap the volume of a tool's output; bash's execFile folds timeout, maxBuffer, and a nonzero exit into the catch, returning failure(cause) to the model rather than letting the Node process crash.

The most dangerous thing at this layer is assuming the gates are already enough. Path canonicalization has an irreducible TOCTOU race: between the check and the use, an external process can change the file; the shell, the network, and the environment variables all still inherit the process's full permissions, and the deliverable contains no "dangerous-command blacklist." read and write are let through by default; the real approval is decided at the call layer by BeforeToolCall, not inside the tool.

The division of labor between the tools and the call layer is reinforced again and again in this chapter: the tools decide "can this be executed safely," the call layer decides "should this be executed." stringField judges only the type, not the semantics; approval happens only at the call layer; bash asks only which shell it's using, not whether a command is dangerous. Every layer's boundary is narrow and clear. The direct payoff of this division is that the tools themselves are testable: a test can call createWorkspaceTools's execute directly and verify truncation, nested writes, and symlink escapes against a real filesystem — with no network or approval context, and without having to assume what policy the tool runs under.

There is one more easily overlooked detail in this chapter: every failure of every tool converges onto the single function failure(cause). read's permission error, write's escape, bash's timeout — all of them ultimately reach the model as an isError: true result, so what the model sees is a uniform "this action didn't happen, and here is why." Once the failure shape is unified, the next round of the model faces only one kind of feedback grammar, and its correction logic stays simple too. As for the real security boundary, leave it to the call layer's approval, and to the deployer's containerization and privilege dropping, to fill in; they aren't in tools.ts, but anyone who uses this deliverable in earnest should know they exist.

Stage Acceptance ​

npm test covers truncating the head and tail, nested write, and symlink read/write escapes.