summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-27 17:22:52 +0900
committerAdam Malczewski <[email protected]>2026-05-27 17:22:52 +0900
commitda57842686ebfd157396551fc76d0c18f7676335 (patch)
tree31caa165c9c6188ee7fa27663ec832a13f6a7ac2
parent399e1509b93b9f3c56142f94b8fb2c30c2dedb2f (diff)
downloaddispatch-da57842686ebfd157396551fc76d0c18f7676335.tar.gz
dispatch-da57842686ebfd157396551fc76d0c18f7676335.zip
feat: tool-output truncation+spill, read_file pagination, read_file_slice, symlink-safe path resolution
-rw-r--r--packages/api/src/agent-manager.ts18
-rw-r--r--packages/api/tests/agent-manager.test.ts9
-rw-r--r--packages/api/tests/routes.test.ts9
-rw-r--r--packages/core/src/agent/agent.ts63
-rw-r--r--packages/core/src/index.ts2
-rw-r--r--packages/core/src/tools/list-files.ts13
-rw-r--r--packages/core/src/tools/path-utils.ts55
-rw-r--r--packages/core/src/tools/read-file-slice.ts90
-rw-r--r--packages/core/src/tools/read-file.ts101
-rw-r--r--packages/core/src/tools/truncate.ts142
-rw-r--r--packages/core/src/tools/write-file.ts18
-rw-r--r--packages/core/src/types/index.ts7
-rw-r--r--packages/core/tests/tools/list-files.test.ts55
-rw-r--r--packages/core/tests/tools/read-file.test.ts110
-rw-r--r--packages/core/tests/tools/write-file.test.ts63
15 files changed, 719 insertions, 36 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 73b65f5..487b09f 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -7,9 +7,11 @@ import {
BackgroundShellStore,
BackgroundTranscriptStore,
type ClaudeAccount,
+ clearSpillForTab,
configToRuleset,
createConfigWatcher,
createListFilesTool,
+ createReadFileSliceTool,
createReadFileTool,
createRetrieveTool,
createRunShellTool,
@@ -41,6 +43,8 @@ import { setTabsAgentManager } from "./routes/tabs.js";
const TOOL_DESCRIPTIONS: Record<string, string> = {
read_file: "Read the contents of a file",
+ read_file_slice:
+ "Read a character-range slice of a single line in a file (for inspecting long lines that read_file truncated)",
list_files: "List files and directories",
write_file: "Write content to a file (creates parent directories if needed)",
run_shell:
@@ -369,6 +373,12 @@ export class AgentManager {
const allowed = new Set(tabAgent.toolsOverride);
if (allowed.has("read_file")) {
toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
+ // read_file_slice is a companion to read_file — only useful for
+ // inspecting long lines that read_file truncated. Ship them together.
+ toolEntries.push({
+ name: "read_file_slice",
+ tool: createReadFileSliceTool(workingDirectory),
+ });
// list_files is bundled with read access
if (allowed.has("list_files")) {
toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
@@ -432,6 +442,10 @@ export class AgentManager {
// Parent agent: use permission settings from DB
if (permRead) {
toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
+ toolEntries.push({
+ name: "read_file_slice",
+ tool: createReadFileSliceTool(workingDirectory),
+ });
toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
}
if (permEdit) {
@@ -607,6 +621,7 @@ export class AgentManager {
permissionChecker: this.permissionManager ?? undefined,
ruleset,
provider,
+ tabId,
...(claudeCredentials ? { claudeCredentials } : {}),
},
{
@@ -670,6 +685,9 @@ export class AgentManager {
deleteTab(tabId: string): void {
this.stopTab(tabId);
this.tabAgents.delete(tabId);
+ // Drop any spilled tool-output files this tab accumulated. Best-effort —
+ // errors are swallowed inside the helper. See packages/core/src/tools/truncate.ts.
+ clearSpillForTab(tabId);
}
/**
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index b426c4e..a1f2d75 100644
--- a/packages/api/tests/agent-manager.test.ts
+++ b/packages/api/tests/agent-manager.test.ts
@@ -35,6 +35,15 @@ vi.mock("@dispatch/core", () => ({
execute: async () => "mock file content",
};
},
+ createReadFileSliceTool(_wd: string): ToolDefinition {
+ return {
+ name: "read_file_slice",
+ description: "read a char slice of a single line",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => "mock slice",
+ };
+ },
+ clearSpillForTab(_tabId: string) {},
createWriteFileTool(_wd: string): ToolDefinition {
return {
name: "write_file",
diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts
index c3c7eaa..69a6676 100644
--- a/packages/api/tests/routes.test.ts
+++ b/packages/api/tests/routes.test.ts
@@ -36,6 +36,15 @@ vi.mock("@dispatch/core", () => ({
execute: async () => "mock file content",
};
},
+ createReadFileSliceTool(_wd: string): ToolDefinition {
+ return {
+ name: "read_file_slice",
+ description: "read a char slice of a single line",
+ parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"],
+ execute: async () => "mock slice",
+ };
+ },
+ clearSpillForTab(_tabId: string) {},
createWriteFileTool(_wd: string): ToolDefinition {
return {
name: "write_file",
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 3cd8a5b..ec83cad 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -1,11 +1,12 @@
-import { realpathSync } from "node:fs";
-import { dirname, isAbsolute, relative, resolve } from "node:path";
+import { dirname } from "node:path";
import type { CoreMessage, CoreSystemMessage } from "ai";
import { streamText } from "ai";
import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js";
import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js";
+import { canonicalize } from "../tools/path-utils.js";
import { createToolRegistry } from "../tools/registry.js";
import { analyzeCommand } from "../tools/shell-analyze.js";
+import { applyTruncation, SPILL_ROOT } from "../tools/truncate.js";
import type {
AgentConfig,
AgentEvent,
@@ -177,23 +178,36 @@ export class Agent {
// Permission check for file tools accessing paths outside workspace
if (
this.config.permissionChecker &&
- (tc.name === "read_file" || tc.name === "write_file" || tc.name === "list_files")
+ (tc.name === "read_file" ||
+ tc.name === "read_file_slice" ||
+ tc.name === "write_file" ||
+ tc.name === "list_files")
) {
const pathArg = typeof tc.arguments.path === "string" ? tc.arguments.path : ".";
- let resolvedPath: string;
- try {
- resolvedPath = realpathSync(resolve(this.config.workingDirectory, pathArg));
- } catch {
- // Path doesn't exist yet (e.g. write_file creating a new file) — fall back
- resolvedPath = resolve(this.config.workingDirectory, pathArg);
- }
-
- // Check if outside workspace
- const rel = relative(this.config.workingDirectory, resolvedPath);
- const isOutside =
- rel.startsWith("../") || rel.startsWith("..\\") || rel === ".." || isAbsolute(rel);
- if (isOutside) {
+ // Canonicalize all three so symlink-in-workdir escapes are detected
+ // at the permission gate (not just relative `../` traversal). The
+ // helper walks up to the nearest existing ancestor when the leaf
+ // doesn't exist (write_file creating new files), so a parent
+ // symlink pointing outside the workdir is still caught. The same
+ // helper is used inside the tool implementations — keeping the
+ // two layers consistent so a path that looks external here also
+ // looks external in the tool, and vice versa.
+ const resolvedPath = await canonicalize(this.config.workingDirectory, pathArg);
+ const resolvedWorkDir = await canonicalize(this.config.workingDirectory);
+ const resolvedSpillRoot = await canonicalize(SPILL_ROOT);
+
+ const isUnderWorkdir =
+ resolvedPath === resolvedWorkDir || resolvedPath.startsWith(`${resolvedWorkDir}/`);
+ // Dispatch's own tool-output spill directory is implicitly allowed —
+ // the AI receives a truncation notice pointing here and is expected
+ // to read it without prompting the user. Bypassing the external-
+ // directory check here keeps the inspection flow frictionless.
+ const isSpillPath =
+ resolvedPath === resolvedSpillRoot ||
+ resolvedPath.startsWith(`${resolvedSpillRoot}/`);
+
+ if (!isUnderWorkdir && !isSpillPath) {
const permissionType =
tc.name === "read_file" ? "read" : tc.name === "write_file" ? "edit" : "list";
@@ -238,11 +252,24 @@ export class Agent {
const rawResult = await execPromise;
const resultStr = typeof rawResult === "string" ? rawResult : JSON.stringify(rawResult);
+
+ // Compute isError on the raw (untruncated) string so an `Error:` prefix
+ // that lives anywhere — including beyond the head excerpt — is still
+ // detected. The display result goes through universal truncation so
+ // oversized outputs don't blow context. The full content lives in the
+ // spill file the truncation notice points to.
+ const isError = resultStr.startsWith("Error:");
+ const { displayResult } = applyTruncation(resultStr, {
+ tabId: this.config.tabId ?? "default",
+ callId: tc.id,
+ toolName: tc.name,
+ });
+
return {
toolCallId: tc.id,
toolName: tc.name,
- result: resultStr,
- isError: resultStr.startsWith("Error:"),
+ result: displayResult,
+ isError,
};
} catch (err) {
return {
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1e55425..a68bfb9 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -49,12 +49,14 @@ export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
// Tools
export { createListFilesTool } from "./tools/list-files.js";
export { createReadFileTool } from "./tools/read-file.js";
+export { createReadFileSliceTool } from "./tools/read-file-slice.js";
export { createToolRegistry } from "./tools/registry.js";
export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js";
export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js";
export { analyzeCommand } from "./tools/shell-analyze.js";
export { createSummonTool, type SummonCallbacks } from "./tools/summon.js";
export { createTaskListTool, TaskList } from "./tools/task-list.js";
+export { clearSpillForTab } from "./tools/truncate.js";
export { createWebSearchTool } from "./tools/web-search.js";
export { createWriteFileTool } from "./tools/write-file.js";
export {
diff --git a/packages/core/src/tools/list-files.ts b/packages/core/src/tools/list-files.ts
index 360ac98..bf21046 100644
--- a/packages/core/src/tools/list-files.ts
+++ b/packages/core/src/tools/list-files.ts
@@ -1,7 +1,7 @@
import { readdir } from "node:fs/promises";
-import { join, resolve } from "node:path";
import { z } from "zod";
import type { ToolDefinition } from "../types/index.js";
+import { canonicalize } from "./path-utils.js";
export function createListFilesTool(workingDirectory: string): ToolDefinition {
return {
@@ -15,10 +15,15 @@ export function createListFilesTool(workingDirectory: string): ToolDefinition {
}),
execute: async (args: Record<string, unknown>): Promise<string> => {
const relPath = (args.path as string | undefined) ?? ".";
- const absolutePath = resolve(join(workingDirectory, relPath));
- const absoluteWorkDir = resolve(workingDirectory);
+ // Canonicalize so a symlink-in-workdir pointing outside is detected.
+ // See `canonicalize` in ./path-utils.ts for the resolution semantics.
+ const absolutePath = await canonicalize(workingDirectory, relPath);
+ const absoluteWorkDir = await canonicalize(workingDirectory);
- if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ if (
+ absolutePath !== absoluteWorkDir &&
+ !absolutePath.startsWith(`${absoluteWorkDir}/`)
+ ) {
return `Error: Path "${relPath}" is outside the working directory.`;
}
diff --git a/packages/core/src/tools/path-utils.ts b/packages/core/src/tools/path-utils.ts
new file mode 100644
index 0000000..3bba0d3
--- /dev/null
+++ b/packages/core/src/tools/path-utils.ts
@@ -0,0 +1,55 @@
+import { realpath } from "node:fs/promises";
+import { basename, dirname, join, resolve } from "node:path";
+
+/**
+ * Resolve a path to its canonical absolute form, following symlinks at
+ * every level.
+ *
+ * When the leaf does not exist (common for `write_file` creating a new
+ * file), walks up to the nearest existing ancestor, canonicalizes that,
+ * then re-appends the missing trailing segments — ensuring a symlink in
+ * the *middle* of the path is still resolved. Without this, a
+ * `workdir/escape-link/new-file.txt` write where `escape-link` points
+ * outside the workdir would slip the containment check.
+ *
+ * Used everywhere we compare a user-supplied path against a trusted
+ * root (workdir, SPILL_ROOT). Resolving symlinks consistently is the
+ * only reliable way to detect a symlink-in-workdir-pointing-outside
+ * escape; lexical-only checks let those through.
+ *
+ * Argument semantics match `path.resolve(...paths)`: later absolute
+ * segments override earlier ones, relative segments are joined.
+ */
+export async function canonicalize(...paths: string[]): Promise<string> {
+ const lexical = resolve(...paths);
+
+ // Fast path: full path exists, realpath resolves all symlinks.
+ try {
+ return await realpath(lexical);
+ } catch {
+ // Path doesn't exist — fall through to ancestor walk.
+ }
+
+ // Walk up until we hit an existing ancestor we can realpath, then
+ // re-append the missing trailing segments. This handles cases like
+ // write_file creating /workdir/symlink/new/dir/file.txt where the
+ // "symlink" segment exists but the rest doesn't.
+ let current = lexical;
+ const trailing: string[] = [];
+ while (true) {
+ const parent = dirname(current);
+ if (parent === current) break; // hit filesystem root
+ trailing.unshift(basename(current));
+ try {
+ const realParent = await realpath(parent);
+ return join(realParent, ...trailing);
+ } catch {
+ current = parent;
+ }
+ }
+
+ // No existing ancestor (pathological — e.g. the entire mount is gone).
+ // Return the lexical path; downstream containment checks will still
+ // catch obvious escapes via `..` etc.
+ return lexical;
+}
diff --git a/packages/core/src/tools/read-file-slice.ts b/packages/core/src/tools/read-file-slice.ts
new file mode 100644
index 0000000..0f83bcb
--- /dev/null
+++ b/packages/core/src/tools/read-file-slice.ts
@@ -0,0 +1,90 @@
+import { readFile } from "node:fs/promises";
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+import { canonicalize } from "./path-utils.js";
+import { SPILL_ROOT } from "./truncate.js";
+
+const DEFAULT_LENGTH = 5000;
+
+export function createReadFileSliceTool(workingDirectory: string): ToolDefinition {
+ return {
+ name: "read_file_slice",
+ description:
+ "Read a character-range slice of a single line in a file. Use this when read_file returns a truncated line marker like '[line N truncated, total X chars; use read_file_slice ...]', or when you need precise byte-ish access into a minified file (huge JSON, base64 blob, etc.). For normal line-oriented reading use read_file with offset/limit instead.",
+ parameters: z.object({
+ path: z.string().describe("Path to the file, relative to the working directory."),
+ line: z.number().int().min(1).describe("1-indexed line number to slice into."),
+ charOffset: z
+ .number()
+ .int()
+ .min(0)
+ .optional()
+ .describe("0-indexed character offset within the line. Default: 0 (start of line)."),
+ charLength: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe(
+ `Max characters to return from the slice. Default: ${DEFAULT_LENGTH}. The universal tool-output truncator will still spill if the result is huge.`,
+ ),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const filePath = args.path as string;
+ const lineNumber = args.line as number;
+ const charOffset =
+ typeof args.charOffset === "number" ? Math.max(0, Math.floor(args.charOffset)) : 0;
+ const charLength =
+ typeof args.charLength === "number"
+ ? Math.max(1, Math.floor(args.charLength))
+ : DEFAULT_LENGTH;
+
+ // Canonicalize all three so symlink-in-workdir escapes are detected.
+ // See `canonicalize` in ./path-utils.ts for the resolution semantics.
+ const absolutePath = await canonicalize(workingDirectory, filePath);
+ const absoluteWorkDir = await canonicalize(workingDirectory);
+ const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
+ const isUnderWorkdir =
+ absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
+ const isSpillFile =
+ absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
+
+ if (!isUnderWorkdir && !isSpillFile) {
+ return `Error: Path "${filePath}" is outside the working directory.`;
+ }
+
+ let raw: string;
+ try {
+ raw = await readFile(absolutePath, "utf8");
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === "ENOENT") {
+ return `Error: File "${filePath}" not found.`;
+ }
+ return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
+ }
+
+ const lines = raw.split("\n");
+ const trailingNewline = raw.endsWith("\n");
+ const totalLines = trailingNewline ? lines.length - 1 : lines.length;
+
+ if (lineNumber > totalLines) {
+ return `Error: line ${lineNumber} exceeds file length (${totalLines} lines).`;
+ }
+
+ const line = lines[lineNumber - 1] ?? "";
+ const lineLength = line.length;
+
+ if (charOffset >= lineLength) {
+ return `Error: charOffset ${charOffset} exceeds line length (${lineLength} chars).`;
+ }
+
+ const sliceEnd = Math.min(charOffset + charLength, lineLength);
+ const slice = line.slice(charOffset, sliceEnd);
+ const remaining = lineLength - sliceEnd;
+
+ const header = `[file: ${filePath} — line ${lineNumber}, chars ${charOffset}-${sliceEnd} of ${lineLength}${remaining > 0 ? ` (${remaining.toLocaleString()} chars remain after this slice)` : ""}]`;
+ return `${header}\n${slice}`;
+ },
+ };
+}
diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts
index 476f243..2c10be4 100644
--- a/packages/core/src/tools/read-file.ts
+++ b/packages/core/src/tools/read-file.ts
@@ -1,26 +1,76 @@
import { readFile } from "node:fs/promises";
-import { join, resolve } from "node:path";
import { z } from "zod";
import type { ToolDefinition } from "../types/index.js";
+import { canonicalize } from "./path-utils.js";
+import { MAX_LINES, SPILL_ROOT } from "./truncate.js";
+
+// Per-line truncation: any single line longer than MAX_LINE_CHARS is cut and
+// replaced with a marker indicating the total length. This protects against
+// minified files / base64 blobs / massive single-line JSON. The AI can use
+// `read_file_slice` to inspect a specific char range within a long line.
+const MAX_LINE_CHARS = 2000;
+
+// Aligned with the universal truncator's MAX_LINES so a default-args read
+// returns a response that fits under the truncator's line ceiling. Without
+// this alignment, every default read of a >500-line file got returned by
+// the tool and then immediately spilled by the truncator — wasted work.
+// Char-dense files can still spill via MAX_CHARS, but that's content-
+// dependent rather than guaranteed.
+const DEFAULT_LIMIT = MAX_LINES;
+// Hard cap on lines per request even if `limit` is larger or omitted.
+// Prevents a `read_file(huge.log)` with no params from returning a million
+// lines. The universal truncator at the agent level will spill anything
+// over its own threshold, but this is a tighter first line of defense
+// scoped to the read tool itself.
+const HARD_LIMIT = 5000;
export function createReadFileTool(workingDirectory: string): ToolDefinition {
return {
name: "read_file",
- description: "Read the contents of a file relative to the working directory.",
+ description:
+ "Read a file relative to the working directory. Returns up to `limit` lines starting at line `offset` (1-indexed). Lines longer than 2000 chars are truncated mid-line with a marker showing the total length — use the `read_file_slice` tool to read a specific char range within a long line. If the response is still too large, the dispatch tool-output truncator may spill the full content to /tmp/dispatch/tool-results/.",
parameters: z.object({
path: z.string().describe("Path to the file, relative to the working directory"),
+ offset: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe("1-indexed start line. Default: 1 (start of file)."),
+ limit: z
+ .number()
+ .int()
+ .min(1)
+ .optional()
+ .describe(
+ `Max lines to return. Default: ${DEFAULT_LIMIT}. Hard cap: ${HARD_LIMIT}. Use a small limit when exploring a large file.`,
+ ),
}),
execute: async (args: Record<string, unknown>): Promise<string> => {
const filePath = args.path as string;
- const absolutePath = resolve(join(workingDirectory, filePath));
- const absoluteWorkDir = resolve(workingDirectory);
+ const offset = typeof args.offset === "number" ? Math.max(1, Math.floor(args.offset)) : 1;
+ const requestedLimit =
+ typeof args.limit === "number" ? Math.max(1, Math.floor(args.limit)) : DEFAULT_LIMIT;
+ const limit = Math.min(requestedLimit, HARD_LIMIT);
- if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ // Canonicalize all three so symlink-in-workdir escapes are detected:
+ // a workdir-relative path that resolves through symlinks to /etc must
+ // fail the containment check below.
+ const absolutePath = await canonicalize(workingDirectory, filePath);
+ const absoluteWorkDir = await canonicalize(workingDirectory);
+ const absoluteSpillRoot = await canonicalize(SPILL_ROOT);
+ const isUnderWorkdir =
+ absolutePath === absoluteWorkDir || absolutePath.startsWith(`${absoluteWorkDir}/`);
+ const isSpillFile =
+ absolutePath === absoluteSpillRoot || absolutePath.startsWith(`${absoluteSpillRoot}/`);
+
+ if (!isUnderWorkdir && !isSpillFile) {
return `Error: Path "${filePath}" is outside the working directory.`;
}
+ let raw: string;
try {
- return await readFile(absolutePath, "utf8");
+ raw = await readFile(absolutePath, "utf8");
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
@@ -28,6 +78,45 @@ export function createReadFileTool(workingDirectory: string): ToolDefinition {
}
return `Error reading file: ${err instanceof Error ? err.message : String(err)}`;
}
+
+ const allLines = raw.split("\n");
+ // `split("\n")` produces an extra empty entry when the file ends with a
+ // newline. The total line count we report to the caller should match
+ // the human-visible line count (lines that have content or terminate
+ // with \n).
+ const trailingNewline = raw.endsWith("\n");
+ const totalLines = trailingNewline ? allLines.length - 1 : allLines.length;
+
+ if (totalLines === 0) {
+ return `(empty file: ${filePath})`;
+ }
+
+ if (offset > totalLines) {
+ return `Error: offset ${offset} exceeds file length (${totalLines} lines).`;
+ }
+
+ const startIdx = offset - 1; // 0-indexed
+ const endIdx = Math.min(startIdx + limit, totalLines);
+ const slice = allLines.slice(startIdx, endIdx);
+
+ // Apply per-line truncation. We tag truncated lines with the line
+ // number and total chars so the AI knows how to call read_file_slice.
+ const rendered: string[] = [];
+ for (let i = 0; i < slice.length; i++) {
+ const lineNumber = startIdx + i + 1;
+ const line = slice[i] ?? "";
+ if (line.length > MAX_LINE_CHARS) {
+ const visible = line.slice(0, MAX_LINE_CHARS);
+ rendered.push(
+ `${visible}...[line ${lineNumber} truncated, total ${line.length.toLocaleString()} chars; use read_file_slice with path="${filePath}" line=${lineNumber} to read more]`,
+ );
+ } else {
+ rendered.push(line);
+ }
+ }
+
+ const header = `[file: ${filePath} — lines ${offset}-${endIdx} of ${totalLines}]`;
+ return `${header}\n${rendered.join("\n")}`;
},
};
}
diff --git a/packages/core/src/tools/truncate.ts b/packages/core/src/tools/truncate.ts
new file mode 100644
index 0000000..2204a3f
--- /dev/null
+++ b/packages/core/src/tools/truncate.ts
@@ -0,0 +1,142 @@
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+
+// ─── Constants ───────────────────────────────────────────────────
+//
+// A tool result that exceeds *either* MAX_CHARS or MAX_LINES is treated
+// as oversized: the full content is written to a spill file under
+// /tmp/dispatch/tool-results/<tabId>/<callId>.txt and the model receives
+// HEAD_CHARS from the start + TAIL_CHARS from the end with a notice
+// in between. These are deliberate hardcoded defaults — see the design
+// discussion in plan.md for the rationale.
+
+export const MAX_CHARS = 10_000;
+export const MAX_LINES = 500;
+export const HEAD_CHARS = 1500;
+export const TAIL_CHARS = 1500;
+
+/** Base directory for all tool-result spill files. Per-tab subdirectories live inside. */
+export const SPILL_ROOT = "/tmp/dispatch/tool-results";
+
+// ─── Public API ──────────────────────────────────────────────────
+
+export interface TruncationContext {
+ /** Tab the tool call belongs to. Used to scope the spill directory. */
+ tabId: string;
+ /** Tool call ID, used as the spill file basename. */
+ callId: string;
+ /** Tool name, included in the truncation notice for human-readable hints. */
+ toolName: string;
+}
+
+export interface TruncationResult {
+ /** Final string sent to the model. Either the original (when under threshold) or the head+notice+tail excerpt. */
+ displayResult: string;
+ /** When truncation happened, the absolute path the full output was spilled to. Undefined otherwise. */
+ spillPath?: string;
+}
+
+/**
+ * Apply universal truncation to a tool result string.
+ *
+ * If the result is under both the character and line caps, returns it
+ * unchanged with no side effects.
+ *
+ * If the result exceeds either cap:
+ * 1. Writes the full content to `<SPILL_ROOT>/<tabId>/<callId>.txt`.
+ * 2. Builds a display string consisting of HEAD_CHARS from the start,
+ * a multi-line truncation notice that includes the spill path, and
+ * TAIL_CHARS from the end.
+ *
+ * The notice instructs the model to use `read_file` (with offset/limit)
+ * or `read_file_slice` to inspect the full content. Every tool result
+ * flows through this function via `Agent.executeToolWithStreaming`, so
+ * any new tool that returns a string automatically gets the protection.
+ */
+export function applyTruncation(result: string, ctx: TruncationContext): TruncationResult {
+ const totalChars = result.length;
+ const totalLines = countLines(result);
+
+ if (totalChars <= MAX_CHARS && totalLines <= MAX_LINES) {
+ return { displayResult: result };
+ }
+
+ const spillPath = join(SPILL_ROOT, ctx.tabId, `${ctx.callId}.txt`);
+ try {
+ mkdirSync(dirname(spillPath), { recursive: true, mode: 0o700 });
+ writeFileSync(spillPath, result, { encoding: "utf-8", mode: 0o600 });
+ } catch (err) {
+ // If we can't spill (disk full, perms, etc.) fall back to hard-truncating
+ // the head + tail without a spill path reference. The model loses the
+ // ability to inspect the middle but won't be blocked outright.
+ const message = err instanceof Error ? err.message : String(err);
+ return {
+ displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
+ spillPath: null,
+ spillError: message,
+ }),
+ };
+ }
+
+ return {
+ displayResult: buildExcerpt(result, totalChars, totalLines, ctx, {
+ spillPath,
+ spillError: null,
+ }),
+ spillPath,
+ };
+}
+
+/** Delete the entire spill directory for a tab. Best-effort, errors swallowed. */
+export function clearSpillForTab(tabId: string): void {
+ const dir = join(SPILL_ROOT, tabId);
+ try {
+ rmSync(dir, { recursive: true, force: true });
+ } catch {
+ // Ignore — tab close should not fail on cleanup errors
+ }
+}
+
+// ─── Internal helpers ────────────────────────────────────────────
+
+function countLines(s: string): number {
+ if (s.length === 0) return 0;
+ let count = 1;
+ for (let i = 0; i < s.length; i++) {
+ if (s.charCodeAt(i) === 10 /* \n */) count++;
+ }
+ return count;
+}
+
+function buildExcerpt(
+ result: string,
+ totalChars: number,
+ totalLines: number,
+ ctx: TruncationContext,
+ spill: { spillPath: string | null; spillError: string | null },
+): string {
+ // Guard against pathological case where HEAD+TAIL overlap. If the result
+ // is between MAX_CHARS and HEAD+TAIL (rare), slice cleanly so we don't
+ // emit overlapping content.
+ const head = result.slice(0, HEAD_CHARS);
+ const tailStart = Math.max(HEAD_CHARS, totalChars - TAIL_CHARS);
+ const tail = result.slice(tailStart);
+
+ const omittedChars = Math.max(0, totalChars - head.length - tail.length);
+
+ const notice: string[] = [
+ "",
+ `[output truncated by dispatch — tool=${ctx.toolName}, total ${totalChars.toLocaleString()} chars / ${totalLines.toLocaleString()} lines; showing first ${head.length.toLocaleString()} and last ${tail.length.toLocaleString()}, ${omittedChars.toLocaleString()} omitted]`,
+ ];
+ if (spill.spillPath) {
+ notice.push(
+ `[full output saved to: ${spill.spillPath}]`,
+ `[use read_file with offset/limit (lines), or read_file_slice (chars within a single line), to inspect specific sections]`,
+ );
+ } else if (spill.spillError) {
+ notice.push(`[failed to spill full output to disk: ${spill.spillError}]`);
+ }
+ notice.push("");
+
+ return `${head}${notice.join("\n")}${tail}`;
+}
diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts
index 23bc72a..763b083 100644
--- a/packages/core/src/tools/write-file.ts
+++ b/packages/core/src/tools/write-file.ts
@@ -1,7 +1,8 @@
import { mkdir, writeFile } from "node:fs/promises";
-import { dirname, join, resolve } from "node:path";
+import { dirname } from "node:path";
import { z } from "zod";
import type { ToolDefinition } from "../types/index.js";
+import { canonicalize } from "./path-utils.js";
export function createWriteFileTool(workingDirectory: string): ToolDefinition {
return {
@@ -14,10 +15,19 @@ export function createWriteFileTool(workingDirectory: string): ToolDefinition {
execute: async (args: Record<string, unknown>): Promise<string> => {
const filePath = args.path as string;
const content = args.content as string;
- const absolutePath = resolve(join(workingDirectory, filePath));
- const absoluteWorkDir = resolve(workingDirectory);
+ // Canonicalize so a workdir-relative path that resolves through
+ // symlinks to outside the workdir is detected and blocked. The
+ // canonicalize walks up to the nearest existing ancestor when the
+ // leaf doesn't exist (typical for write_file), so a path like
+ // `workdir/escape-link/new-file.txt` where `escape-link` symlinks
+ // to /etc still resolves through the symlink and is caught here.
+ const absolutePath = await canonicalize(workingDirectory, filePath);
+ const absoluteWorkDir = await canonicalize(workingDirectory);
- if (!absolutePath.startsWith(`${absoluteWorkDir}/`) && absolutePath !== absoluteWorkDir) {
+ if (
+ absolutePath !== absoluteWorkDir &&
+ !absolutePath.startsWith(`${absoluteWorkDir}/`)
+ ) {
return `Error: Path "${filePath}" is outside the working directory.`;
}
diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts
index 1c3090f..65576cf 100644
--- a/packages/core/src/types/index.ts
+++ b/packages/core/src/types/index.ts
@@ -87,6 +87,13 @@ export interface AgentConfig {
claudeCredentials?: {
accessToken: string;
};
+ /**
+ * Tab ID the agent runs on. Used to scope per-tab side effects, namely
+ * the tool-output spill directory (`/tmp/dispatch/tool-results/<tabId>/`).
+ * Optional so legacy callers and tests can construct an Agent without one;
+ * a fallback ID is generated when absent.
+ */
+ tabId?: string;
}
// ─── Config Types (dispatch.toml) ────────────────────────────────
diff --git a/packages/core/tests/tools/list-files.test.ts b/packages/core/tests/tools/list-files.test.ts
index ead1df3..f371717 100644
--- a/packages/core/tests/tools/list-files.test.ts
+++ b/packages/core/tests/tools/list-files.test.ts
@@ -1,4 +1,4 @@
-import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -38,4 +38,57 @@ describe("list_files tool", () => {
const result = await tool.execute({ path: "../" });
expect(result).toMatch(/outside the working directory/i);
});
+
+ // Regression for `resolve(join(workingDirectory, relPath))` — when relPath
+ // is absolute, `join` does NOT short-circuit. The old code silently
+ // rewrote `/some/path` to `<workdir>/some/path` and either returned an
+ // ENOENT-style error or, worse, listed an unrelated path. After the fix,
+ // absolute paths resolve to themselves and the workdir gate behaves correctly.
+ describe("absolute path handling", () => {
+ it("lists an absolute path that lives under the workdir", async () => {
+ const tool = createListFilesTool(workDir);
+ await writeFile(join(workDir, "alpha.txt"), "a");
+ await writeFile(join(workDir, "beta.txt"), "b");
+ const result = await tool.execute({ path: workDir });
+ expect(result).toContain("alpha.txt");
+ expect(result).toContain("beta.txt");
+ // "Error listing files" would indicate the path was mangled into a
+ // non-existent location.
+ expect(result).not.toMatch(/error listing/i);
+ });
+
+ it("rejects absolute paths outside the workdir with the workdir error (not a generic ENOENT)", async () => {
+ const tool = createListFilesTool(workDir);
+ // Use a tmpdir path that's definitely not under workDir. Under the
+ // bug, this got rewritten to `<workdir>/tmp/...` and produced an
+ // `Error listing files` ENOENT message instead of the workdir error.
+ const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}`);
+ const result = await tool.execute({ path: evilPath });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+ });
+
+ // A directory symlink inside the workdir pointing to an external
+ // directory is the classic escape vector for a `ls` style tool.
+ // `canonicalize` must resolve the symlink so the listing is denied.
+ describe("symlink handling", () => {
+ let externalDir: string;
+
+ beforeEach(async () => {
+ externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
+ await writeFile(join(externalDir, "secret.txt"), "secret");
+ });
+
+ afterEach(async () => {
+ await rm(externalDir, { recursive: true, force: true });
+ });
+
+ it("blocks listing through a symlinked directory that escapes the workdir", async () => {
+ const tool = createListFilesTool(workDir);
+ await symlink(externalDir, join(workDir, "peek"));
+ const result = await tool.execute({ path: "peek" });
+ expect(result).toMatch(/outside the working directory/i);
+ expect(result).not.toContain("secret.txt");
+ });
+ });
});
diff --git a/packages/core/tests/tools/read-file.test.ts b/packages/core/tests/tools/read-file.test.ts
index ce65b37..90165d8 100644
--- a/packages/core/tests/tools/read-file.test.ts
+++ b/packages/core/tests/tools/read-file.test.ts
@@ -1,8 +1,10 @@
-import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { randomBytes } from "node:crypto";
+import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createReadFileTool } from "../../src/tools/read-file.js";
+import { SPILL_ROOT } from "../../src/tools/truncate.js";
describe("read_file tool", () => {
let workDir: string;
@@ -19,7 +21,8 @@ describe("read_file tool", () => {
const tool = createReadFileTool(workDir);
await writeFile(join(workDir, "hello.txt"), "Hello, world!");
const result = await tool.execute({ path: "hello.txt" });
- expect(result).toBe("Hello, world!");
+ expect(result).toContain("Hello, world!");
+ expect(result).toContain("[file: hello.txt — lines 1-1 of 1]");
});
it("returns error for non-existent file", async () => {
@@ -33,4 +36,107 @@ describe("read_file tool", () => {
const result = await tool.execute({ path: "../etc/passwd" });
expect(result).toMatch(/outside the working directory/i);
});
+
+ it("respects offset and limit", async () => {
+ const tool = createReadFileTool(workDir);
+ await writeFile(join(workDir, "multi.txt"), "line1\nline2\nline3\nline4\nline5");
+ const result = await tool.execute({ path: "multi.txt", offset: 2, limit: 2 });
+ expect(result).toContain("line2");
+ expect(result).toContain("line3");
+ expect(result).not.toContain("line1");
+ expect(result).not.toContain("line4");
+ expect(result).toContain("[file: multi.txt — lines 2-3 of 5]");
+ });
+
+ it("truncates long lines and points to read_file_slice", async () => {
+ const tool = createReadFileTool(workDir);
+ const longLine = "x".repeat(3000);
+ await writeFile(join(workDir, "wide.txt"), longLine);
+ const result = await tool.execute({ path: "wide.txt" });
+ expect(result).toContain("[line 1 truncated, total 3,000 chars");
+ expect(result).toContain("use read_file_slice");
+ });
+
+ // The universal truncator writes oversized tool output to
+ // `${SPILL_ROOT}/<tabId>/<callId>.txt` and the truncation notice tells
+ // the AI to read that absolute path back. A previous implementation
+ // used `resolve(join(workingDirectory, filePath))` which silently
+ // concatenated the absolute spill path *under* the workdir, producing
+ // a non-existent path and ENOENT — breaking the entire spill-and-resume
+ // flow. These tests guard that contract.
+ describe("absolute path handling (spill-file regression)", () => {
+ let spillSubdir: string;
+
+ beforeEach(async () => {
+ spillSubdir = join(SPILL_ROOT, `test-${Date.now()}-${randomBytes(4).toString("hex")}`);
+ await mkdir(spillSubdir, { recursive: true });
+ });
+
+ afterEach(async () => {
+ await rm(spillSubdir, { recursive: true, force: true });
+ });
+
+ it("reads a spill file via its absolute path", async () => {
+ const tool = createReadFileTool(workDir);
+ const spillFile = join(spillSubdir, "call-abc.txt");
+ const payload = "spilled output line 1\nspilled output line 2";
+ await writeFile(spillFile, payload);
+
+ const result = await tool.execute({ path: spillFile });
+
+ expect(result).toContain("spilled output line 1");
+ expect(result).toContain("spilled output line 2");
+ expect(result).not.toMatch(/not found/i);
+ expect(result).not.toMatch(/outside the working directory/i);
+ });
+
+ it("still rejects absolute paths that are neither in the workdir nor the spill root", async () => {
+ const tool = createReadFileTool(workDir);
+ // Path check happens before file read, so /etc/hostname existing
+ // (or not) is irrelevant — we just need an absolute path outside
+ // both the workdir and SPILL_ROOT.
+ const result = await tool.execute({ path: "/etc/hostname" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+ });
+
+ // Symlinks must resolve consistently across the agent permission gate
+ // and the tool itself. The containment check operates on the canonical
+ // path — so a symlink-in-workdir that points outside is treated as
+ // "outside" and gated like any other external path. Lexical-only
+ // checks would let these slip through silently.
+ describe("symlink handling", () => {
+ let externalDir: string;
+
+ beforeEach(async () => {
+ externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
+ });
+
+ afterEach(async () => {
+ await rm(externalDir, { recursive: true, force: true });
+ });
+
+ it("follows symlinks that stay inside the workdir", async () => {
+ const tool = createReadFileTool(workDir);
+ await writeFile(join(workDir, "real.txt"), "real content");
+ await symlink(join(workDir, "real.txt"), join(workDir, "link.txt"));
+ const result = await tool.execute({ path: "link.txt" });
+ expect(result).toContain("real content");
+ expect(result).not.toMatch(/outside the working directory/i);
+ });
+
+ it("blocks symlinks that escape the workdir", async () => {
+ const tool = createReadFileTool(workDir);
+ const secret = join(externalDir, "secret.txt");
+ await writeFile(secret, "leaked secret");
+ // Create a symlink *inside* workDir pointing to a file *outside*
+ // workDir. Lexical-only path validation would see "workdir/trap.txt"
+ // (under workdir) and allow it. Canonical resolution sees the
+ // symlink's target and correctly rejects.
+ await symlink(secret, join(workDir, "trap.txt"));
+ const result = await tool.execute({ path: "trap.txt" });
+ expect(result).toMatch(/outside the working directory/i);
+ expect(result).not.toContain("leaked secret");
+ });
+ });
});
diff --git a/packages/core/tests/tools/write-file.test.ts b/packages/core/tests/tools/write-file.test.ts
index 01d7253..6853a8e 100644
--- a/packages/core/tests/tools/write-file.test.ts
+++ b/packages/core/tests/tools/write-file.test.ts
@@ -1,4 +1,4 @@
-import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { access, mkdtemp, readFile, readdir, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
@@ -42,4 +42,65 @@ describe("write_file tool", () => {
const result = await tool.execute({ path: "../evil.txt", content: "bad" });
expect(result).toMatch(/outside the working directory/i);
});
+
+ // Regression for `resolve(join(workingDirectory, filePath))` — when filePath
+ // is absolute, `join` does NOT short-circuit, it concatenates. The old code
+ // silently rewrote `/etc/foo` to `<workdir>/etc/foo` and "succeeded" by
+ // writing to the wrong location. After the fix, absolute paths resolve
+ // to themselves and the workdir gate behaves correctly.
+ describe("absolute path handling", () => {
+ it("writes an absolute path that lives under the workdir to the expected location", async () => {
+ const tool = createWriteFileTool(workDir);
+ const absoluteTarget = join(workDir, "abs.txt");
+ const result = await tool.execute({ path: absoluteTarget, content: "abs content" });
+ expect(result).toMatch(/successfully wrote/i);
+ // File must exist at exactly `absoluteTarget`, NOT at
+ // `<workdir>/<workdir>/abs.txt` (the old mangled location).
+ const written = await readFile(absoluteTarget, "utf8");
+ expect(written).toBe("abs content");
+ });
+
+ it("rejects absolute paths outside the workdir instead of silently mangling them", async () => {
+ const tool = createWriteFileTool(workDir);
+ // Pick a path under tmpdir that's definitely not under workDir.
+ // Under the bug, this got rewritten to `<workdir>/tmp/...` and the
+ // write "succeeded" at the wrong location.
+ const evilPath = join(tmpdir(), `dispatch-evil-${Date.now()}.txt`);
+ const result = await tool.execute({ path: evilPath, content: "should not land" });
+ expect(result).toMatch(/outside the working directory/i);
+ });
+ });
+
+ // Symlink containment: even when the *leaf* doesn't exist yet (the
+ // common case for write_file creating a new file), `canonicalize`
+ // must walk up to the nearest existing ancestor and resolve symlinks
+ // there. Otherwise, a directory symlink inside workdir pointing
+ // outside lets a write escape the workspace.
+ describe("symlink handling", () => {
+ let externalDir: string;
+
+ beforeEach(async () => {
+ externalDir = await mkdtemp(join(tmpdir(), "dispatch-external-"));
+ });
+
+ afterEach(async () => {
+ await rm(externalDir, { recursive: true, force: true });
+ });
+
+ it("blocks writes that escape through a parent symlink (leaf does not exist yet)", async () => {
+ const tool = createWriteFileTool(workDir);
+ // `escape` is a symlink *inside* workdir to a directory *outside*.
+ await symlink(externalDir, join(workDir, "escape"));
+ const result = await tool.execute({
+ path: "escape/payload.txt",
+ content: "malicious payload",
+ });
+ expect(result).toMatch(/outside the working directory/i);
+ // And the file must NOT exist in externalDir.
+ await expect(access(join(externalDir, "payload.txt"))).rejects.toThrow();
+ // And externalDir should be empty (nothing leaked through).
+ const entries = await readdir(externalDir);
+ expect(entries).toEqual([]);
+ });
+ });
});