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 /packages/core/src | |
| 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
Diffstat (limited to 'packages/core/src')
| -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 |
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) ──────────────────────────────── |
