/** * 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; the Hub agent is narrower than a general coding agent. */ import { readFile, writeFile, readdir, mkdir } from "node:fs/promises"; import { createHash } from "node:crypto"; import { join, resolve, relative, isAbsolute } from "node:path"; import { tool } from "ai"; import { z } from "zod"; import type { ToolContext, ToolDefinition } 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; } export function readFileTool(ctx: ToolContext): ToolDefinition { return tool({ description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.", inputSchema: z.object({ path: z.string().describe("Relative path within the workspace."), }), execute: async (args): Promise => { try { const full = confine(args.path, ctx.workspaceDir); return await readFile(full, "utf8"); } catch (e) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, }); } export function writeFileTool(ctx: ToolContext): ToolDefinition { return tool({ description: "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.", inputSchema: z.object({ path: z.string().describe("Relative path within the workspace."), content: z.string().describe("The full file content to write."), }), execute: async (args): Promise => { try { const full = confine(args.path, ctx.workspaceDir); // A-3 file-change capture: compute before/after hashes around the write. // A hashing failure must not block the write — skip the sink in that case. let beforeHash: string | null = null; let afterHash: string | null = null; let operation: "CREATE" | "UPDATE" | null = null; try { let oldContent: string | null; try { oldContent = await readFile(full, "utf8"); } catch (e) { if (e !== null && typeof e === "object" && "code" in e && e.code === "ENOENT") { oldContent = null; } else { throw e; } } afterHash = createHash("sha256").update(args.content).digest("hex"); beforeHash = oldContent === null ? null : createHash("sha256").update(oldContent).digest("hex"); if (beforeHash === null) { operation = "CREATE"; } else if (beforeHash === afterHash) { operation = null; } else { operation = "UPDATE"; } } catch { operation = null; } await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, args.content, "utf8"); if (operation !== null && ctx.onFileChange !== undefined) { ctx.onFileChange({ path: args.path, operation, beforeHash, afterHash, diff: null }); } return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length }); } catch (e) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, }); } interface Entry { readonly name: string; readonly kind: "file" | "dir"; } export function listFilesTool(ctx: ToolContext): ToolDefinition { return tool({ description: "List files and directories at a path within the workspace. Defaults to the workspace root.", inputSchema: z.object({ path: z.string().describe("Relative path within the workspace; defaults to root.").optional(), }), execute: async (args): Promise => { const sub = args.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) }); } }, }); }