/** * Workspace file tools — bounded read/write/list against the project's * curriculum engineering-file tree (ADR-0007 directory tree). * * All paths are **confined to `ctx.workspaceDir`**: the handler resolves the * requested relative path against the workspace root and rejects any path that * escapes it (via `..` or absolute paths). This is the agent's only file * surface — there is no general bash escape hatch, by design (the Hub agent is * narrower than a general "Claude Code"). */ import { readFile, writeFile, readdir, mkdir } from "node:fs/promises"; import { join, resolve, relative, isAbsolute } from "node:path"; import type { RegisteredTool } from "./tools.js"; /** Thrown when a requested path escapes the project workspace root. */ export class PathEscape extends Error { constructor(readonly requested: string, readonly workspaceDir: string) { super(`path escapes workspace: ${requested} (root ${workspaceDir})`); this.name = "PathEscape"; } } /** Resolve a tool-supplied path against the workspace root, rejecting escapes. */ function confine(requestedPath: string, workspaceDir: string): string { if (isAbsolute(requestedPath)) { // Allow absolute paths only if they're already inside the workspace. const rel = relative(workspaceDir, requestedPath); if (rel.startsWith("..") || rel === "") { throw new PathEscape(requestedPath, workspaceDir); } return requestedPath; } const resolved = resolve(workspaceDir, requestedPath); const rel = relative(workspaceDir, resolved); if (rel.startsWith("..")) { throw new PathEscape(requestedPath, workspaceDir); } return resolved; } interface ReadFileArgs { readonly path: string; } export function readFileTool(): RegisteredTool { return { spec: { name: "read_file", description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.", parameters: { type: "object", properties: { path: { type: "string", description: "Relative path within the workspace." } }, required: ["path"], }, }, async execute(args, ctx): Promise { const a = args as ReadFileArgs; try { const full = confine(a.path, ctx.workspaceDir); return await readFile(full, "utf8"); } catch (e) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, }; } interface WriteFileArgs { readonly path: string; readonly content: string; } export function writeFileTool(): RegisteredTool { return { spec: { name: "write_file", description: "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.", parameters: { type: "object", properties: { path: { type: "string", description: "Relative path within the workspace." }, content: { type: "string", description: "The full file content to write." }, }, required: ["path", "content"], }, }, async execute(args, ctx): Promise { const a = args as WriteFileArgs; try { const full = confine(a.path, ctx.workspaceDir); await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, a.content, "utf8"); return JSON.stringify({ ok: true, path: a.path, bytes: a.content.length }); } catch (e) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, }; } interface ListFilesArgs { readonly path?: string; } interface Entry { readonly name: string; readonly kind: "file" | "dir"; } export function listFilesTool(): RegisteredTool { return { spec: { name: "list_files", description: "List files and directories at a path within the workspace. Defaults to the workspace root.", parameters: { type: "object", properties: { path: { type: "string", description: "Relative path within the workspace; defaults to root." } }, }, }, async execute(args, ctx): Promise { const a = (args as ListFilesArgs) ?? {}; const sub = a.path ?? "."; try { const full = confine(sub, ctx.workspaceDir); const entries = await readdir(full, { withFileTypes: true }); const result: Entry[] = entries.map((e) => ({ name: e.name, kind: e.isDirectory() ? "dir" : "file", })); return JSON.stringify(result); } catch (e) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, }; }