diff options
| author | Adam Malczewski <[email protected]> | 2026-05-27 17:22:52 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-27 17:22:52 +0900 |
| commit | da57842686ebfd157396551fc76d0c18f7676335 (patch) | |
| tree | 31caa165c9c6188ee7fa27663ec832a13f6a7ac2 | |
| parent | 399e1509b93b9f3c56142f94b8fb2c30c2dedb2f (diff) | |
| download | dispatch-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.ts | 18 | ||||
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 9 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 9 | ||||
| -rw-r--r-- | packages/core/src/agent/agent.ts | 63 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 2 | ||||
| -rw-r--r-- | packages/core/src/tools/list-files.ts | 13 | ||||
| -rw-r--r-- | packages/core/src/tools/path-utils.ts | 55 | ||||
| -rw-r--r-- | packages/core/src/tools/read-file-slice.ts | 90 | ||||
| -rw-r--r-- | packages/core/src/tools/read-file.ts | 101 | ||||
| -rw-r--r-- | packages/core/src/tools/truncate.ts | 142 | ||||
| -rw-r--r-- | packages/core/src/tools/write-file.ts | 18 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 7 | ||||
| -rw-r--r-- | packages/core/tests/tools/list-files.test.ts | 55 | ||||
| -rw-r--r-- | packages/core/tests/tools/read-file.test.ts | 110 | ||||
| -rw-r--r-- | packages/core/tests/tools/write-file.test.ts | 63 |
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([]); + }); + }); }); |
