summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
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 /packages/core/src
parent399e1509b93b9f3c56142f94b8fb2c30c2dedb2f (diff)
downloaddispatch-da57842686ebfd157396551fc76d0c18f7676335.tar.gz
dispatch-da57842686ebfd157396551fc76d0c18f7676335.zip
feat: tool-output truncation+spill, read_file pagination, read_file_slice, symlink-safe path resolution
Diffstat (limited to 'packages/core/src')
-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
9 files changed, 459 insertions, 32 deletions
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) ────────────────────────────────