diff --git a/src/capabilities/index.ts b/src/capabilities/index.ts new file mode 100644 index 0000000..413fbfd --- /dev/null +++ b/src/capabilities/index.ts @@ -0,0 +1,39 @@ +/** + * Capability access for tools (H1 — docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2). + * + * `ToolContext.caps` is optional so that the dozen existing places that build a + * ToolContext keep compiling and keep their current behaviour. Tools therefore + * never read `ctx.caps` directly — they go through `capsOf`, which supplies the + * local world when a caller has not chosen one. One import per tool, no + * `?? LOCAL` repeated at fourteen call sites where one could be forgotten. + * + * SCOPE (H1) — this seam covers the seven primitive fs/shell tools + * (read / write / edit / apply_patch / ls / grep / bash). The tools that spawn + * in the *workspace* through `src/tools/exec-util.ts` (run_checks, + * compare_agents, redeploy, dispatch_agent, and the repo/PR/review helpers) + * still use a raw `spawn` and do NOT pass through `caps.shell` yet. A sandbox + * provider swapped in at `ctx.caps` (H2) therefore bounds the primitives but + * not those — routing the exec-util family through the seam is the follow-up + * that makes "bound the world in one place" literally true. Until then, do not + * assume `capsOf` is the *only* path to the filesystem or a subprocess. + */ + +import type { ToolContext } from "../types.js"; +import { LOCAL_CAPABILITIES } from "./local.js"; +import type { Capabilities } from "./types.js"; + +export function capsOf(ctx: ToolContext): Capabilities { + return ctx.caps ?? LOCAL_CAPABILITIES; +} + +export { LOCAL_CAPABILITIES, localFs, localShell } from "./local.js"; +export { createMemoryCapabilities, createMemoryFs, refusingShell } from "./memory.js"; +export type { + Capabilities, + ExecOptions, + ExecResult, + FsCapability, + FsDirEntry, + FsStat, + ShellCapability, +} from "./types.js"; diff --git a/src/capabilities/local.ts b/src/capabilities/local.ts new file mode 100644 index 0000000..6cf8be2 --- /dev/null +++ b/src/capabilities/local.ts @@ -0,0 +1,146 @@ +/** + * Local provider — the host's own filesystem and shell. + * + * This is the behaviour every fs/shell tool had inline before H1, moved behind + * the seam verbatim. It intentionally imposes no boundary of its own: bounding + * the world is H2's sandboxed provider, and conflating "how do I reach the + * disk" with "what am I allowed to reach" is what made the old code impossible + * to bound in one place. + */ + +import fs from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { atomicWrite } from "../fs-utils.js"; +import { defaultSandboxSpec, wrapForSandbox } from "../sandbox/sandbox.js"; +import type { + Capabilities, + ExecOptions, + ExecResult, + FsCapability, + FsDirEntry, + FsStat, + ShellCapability, +} from "./types.js"; + +export const localFs: FsCapability = { + resolvePath(cwd: string, p: string): string { + return path.resolve(cwd, p); + }, + async stat(abs: string): Promise { + const s = await fs.stat(abs); + return { isFile: s.isFile(), isDirectory: s.isDirectory(), size: s.size }; + }, + async exists(abs: string): Promise { + try { + await fs.access(abs); + return true; + } catch { + return false; + } + }, + async readFile(abs: string): Promise { + return await fs.readFile(abs, "utf8"); + }, + async writeFile(abs: string, content: string): Promise { + await atomicWrite(abs, content); + }, + async readdir(abs: string): Promise { + const entries = await fs.readdir(abs, { withFileTypes: true }); + return entries.map((e) => ({ + name: e.name, + isFile: e.isFile(), + isDirectory: e.isDirectory(), + })); + }, + async unlink(abs: string): Promise { + await fs.unlink(abs); + }, +}; + +/** + * Shared child-process driver for both shell operations. Collects bounded + * output, enforces a timeout with SIGTERM→SIGKILL escalation, and resolves + * (rather than rejects) on a non-zero exit — a failing command is a result the + * model should see, not an exception. + */ +function runChild( + file: string, + args: string[], + opts: ExecOptions, +): Promise { + const maxOutput = opts.maxOutputBytes ?? 64 * 1024; + return new Promise((resolve, reject) => { + const child = spawn(file, args, { + cwd: opts.cwd, + env: process.env, + signal: opts.signal, + }); + let stdout = ""; + let stderr = ""; + let truncated = false; + const onData = (buf: Buffer, target: "stdout" | "stderr") => { + const text = buf.toString("utf8"); + if (target === "stdout") { + if (stdout.length + text.length > maxOutput) { + stdout += text.slice(0, maxOutput - stdout.length); + truncated = true; + } else { + stdout += text; + } + } else { + if (stderr.length + text.length > maxOutput) { + stderr += text.slice(0, maxOutput - stderr.length); + truncated = true; + } else { + stderr += text; + } + } + }; + child.stdout.on("data", (b: Buffer) => onData(b, "stdout")); + child.stderr.on("data", (b: Buffer) => onData(b, "stderr")); + + const timer = opts.timeoutMs + ? setTimeout(() => { + child.kill("SIGTERM"); + setTimeout(() => child.kill("SIGKILL"), 2000); + }, opts.timeoutMs) + : undefined; + + child.on("error", (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + child.on("close", (code, signal) => { + if (timer) clearTimeout(timer); + resolve({ stdout, stderr, code, signal, truncated }); + }); + }); +} + +export const localShell: ShellCapability = { + async run(command: string, opts: ExecOptions): Promise { + // The opt-in LISA_SANDBOX wrapper moved here from the bash tool: which + // confinement a command runs under is a property of the execution world, + // not of the tool that asked. H2 replaces this with a real sandboxed + // provider (and closes the hole that fs writes never went through it); + // for now it is the previous behaviour, relocated unchanged. + const wrapped = await wrapForSandbox( + defaultSandboxSpec({ cwd: opts.cwd }), + command, + ); + try { + return await runChild(wrapped.command, wrapped.args, opts); + } finally { + await wrapped.cleanup?.(); + } + }, + async exec(file: string, args: string[], opts: ExecOptions): Promise { + return await runChild(file, args, opts); + }, +}; + +export const LOCAL_CAPABILITIES: Capabilities = { + fs: localFs, + shell: localShell, +}; diff --git a/src/capabilities/memory.ts b/src/capabilities/memory.ts new file mode 100644 index 0000000..6cebda1 --- /dev/null +++ b/src/capabilities/memory.ts @@ -0,0 +1,142 @@ +/** + * In-memory provider — proof that the seam is real, and a test fixture. + * + * Its value is not that tests get faster; it is that a second provider exists + * at all. If the fs/shell tools can run unmodified against a filesystem that is + * a `Map`, they can run against a container or a remote host, which is what + * Dispatch and Cloud need from H1. Every tool test that uses this instead of a + * tmpdir is also a test that the tool did not smuggle in a direct `node:fs` + * call behind the seam's back. + * + * The shell here refuses to run anything: an in-memory world has no processes. + * Refusing is deliberate — a stub that silently returned success would let a + * test pass while the real path was broken. + */ + +import path from "node:path"; +import type { + Capabilities, + ExecOptions, + ExecResult, + FsCapability, + FsDirEntry, + FsStat, + ShellCapability, +} from "./types.js"; + +export interface MemoryFsOptions { + /** Seed files, keyed by absolute path. */ + files?: Record; + /** + * Bound the world to this prefix. Paths resolving outside it throw — the + * same contract H2's sandboxed provider will implement against the OS. + */ + root?: string; +} + +export interface MemoryFs extends FsCapability { + /** Current contents, keyed by absolute path — for assertions. */ + snapshot(): Record; +} + +export function createMemoryFs(opts: MemoryFsOptions = {}): MemoryFs { + const files = new Map(Object.entries(opts.files ?? {})); + const root = opts.root; + + const dirsOf = (): Set => { + const dirs = new Set(); + for (const file of files.keys()) { + let dir = path.dirname(file); + while (dir && dir !== path.dirname(dir)) { + dirs.add(dir); + dir = path.dirname(dir); + } + } + return dirs; + }; + + const mustExist = (abs: string): string => { + const content = files.get(abs); + if (content === undefined) { + const err = new Error(`ENOENT: no such file or directory, open '${abs}'`); + (err as NodeJS.ErrnoException).code = "ENOENT"; + throw err; + } + return content; + }; + + return { + resolvePath(cwd: string, p: string): string { + const abs = path.resolve(cwd, p); + if (root && abs !== root && !abs.startsWith(root + path.sep)) { + throw new Error(`path escapes the workspace root: ${abs} (root ${root})`); + } + return abs; + }, + async stat(abs: string): Promise { + const content = files.get(abs); + if (content !== undefined) { + return { + isFile: true, + isDirectory: false, + size: Buffer.byteLength(content, "utf8"), + }; + } + if (dirsOf().has(abs)) { + return { isFile: false, isDirectory: true, size: 0 }; + } + const err = new Error(`ENOENT: no such file or directory, stat '${abs}'`); + (err as NodeJS.ErrnoException).code = "ENOENT"; + throw err; + }, + async exists(abs: string): Promise { + return files.has(abs) || dirsOf().has(abs); + }, + async readFile(abs: string): Promise { + return mustExist(abs); + }, + async writeFile(abs: string, content: string): Promise { + files.set(abs, content); + }, + async readdir(abs: string): Promise { + const prefix = abs.endsWith(path.sep) ? abs : abs + path.sep; + const seen = new Map(); + for (const file of files.keys()) { + if (!file.startsWith(prefix)) continue; + const rest = file.slice(prefix.length); + const head = rest.split(path.sep)[0]!; + const isFile = !rest.includes(path.sep); + seen.set(head, { name: head, isFile, isDirectory: !isFile }); + } + if (seen.size === 0 && !dirsOf().has(abs)) { + const err = new Error(`ENOENT: no such file or directory, scandir '${abs}'`); + (err as NodeJS.ErrnoException).code = "ENOENT"; + throw err; + } + return [...seen.values()]; + }, + async unlink(abs: string): Promise { + mustExist(abs); + files.delete(abs); + }, + snapshot(): Record { + return Object.fromEntries(files); + }, + }; +} + +/** A shell that has no processes to run, and says so instead of pretending. */ +export const refusingShell: ShellCapability = { + async run(_command: string, _opts: ExecOptions): Promise { + throw new Error("shell is unavailable in this execution world"); + }, + async exec(_file: string, _args: string[], _opts: ExecOptions): Promise { + throw new Error("shell is unavailable in this execution world"); + }, +}; + +export function createMemoryCapabilities( + opts: MemoryFsOptions = {}, +): Capabilities & { fs: MemoryFs } { + return { fs: createMemoryFs(opts), shell: refusingShell }; +} diff --git a/src/capabilities/seam.test.ts b/src/capabilities/seam.test.ts new file mode 100644 index 0000000..e5ca867 --- /dev/null +++ b/src/capabilities/seam.test.ts @@ -0,0 +1,187 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createMemoryCapabilities, refusingShell } from "./index.js"; +import { readTool } from "../tools/read.js"; +import { writeTool } from "../tools/write.js"; +import { editTool } from "../tools/edit.js"; +import { lsTool } from "../tools/ls.js"; +import { applyPatchTool } from "../tools/apply_patch.js"; +import { bashTool } from "../tools/bash.js"; +import type { ToolContext } from "../types.js"; + +/** + * H1 acceptance (docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2): the fs/shell tools + * act on a *swappable* execution world, not on the host disk by hard-wiring. + */ + +const SRC = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +/** The tools migrated onto the seam. Adding one here without migrating it fails. */ +const SEAM_TOOLS = [ + "tools/read.ts", + "tools/write.ts", + "tools/edit.ts", + "tools/apply_patch.ts", + "tools/ls.ts", + "tools/grep.ts", + "tools/bash.ts", +]; + +describe("capability seam — no tool reaches around it", () => { + test("migrated tools import neither node:fs nor node:child_process", () => { + const offenders: string[] = []; + for (const rel of SEAM_TOOLS) { + const source = fs.readFileSync(path.join(SRC, rel), "utf8"); + for (const banned of ["node:fs", "node:child_process"]) { + // Only import statements matter — the strings may legitimately appear + // in prose, and this guard should not be defeatable by a comment. + const importing = new RegExp( + `^\\s*import[^;]*from\\s+["']${banned}["']`, + "m", + ).test(source); + if (importing) offenders.push(`${rel} imports ${banned}`); + } + } + assert.deepEqual( + offenders, + [], + "these tools bypass the capability seam, so swapping the execution world would not move them", + ); + }); + + test("every migrated tool goes through capsOf", () => { + for (const rel of SEAM_TOOLS) { + const source = fs.readFileSync(path.join(SRC, rel), "utf8"); + assert.match( + source, + /capsOf\(ctx\)/, + `${rel} never resolves its execution world`, + ); + } + }); +}); + +function memCtx(opts: Parameters[0] = {}) { + const caps = createMemoryCapabilities(opts); + const ctx: ToolContext = { + cwd: "/work", + signal: new AbortController().signal, + log: () => {}, + caps, + }; + return { ctx, caps }; +} + +describe("tools run unmodified against an in-memory world", () => { + test("read serves a file that exists only in memory", async () => { + const { ctx } = memCtx({ files: { "/work/a.txt": "hello\nworld" } }); + const out = await readTool.execute({ path: "a.txt" }, ctx); + assert.match(out, /hello/); + assert.match(out, /world/); + }); + + test("write never touches the disk", async () => { + const { ctx, caps } = memCtx(); + const out = await writeTool.execute( + { path: "notes/new.txt", content: "abc" }, + ctx, + ); + assert.match(out, /Wrote 3 chars to \/work\/notes\/new\.txt/); + assert.deepEqual(caps.fs.snapshot(), { "/work/notes/new.txt": "abc" }); + assert.equal( + fs.existsSync("/work/notes/new.txt"), + false, + "the real filesystem must be untouched", + ); + }); + + test("edit reads and rewrites through the same world", async () => { + const { ctx, caps } = memCtx({ files: { "/work/f.ts": "let x = 1;" } }); + await editTool.execute( + { path: "f.ts", old_string: "1", new_string: "2" }, + ctx, + ); + assert.equal(caps.fs.snapshot()["/work/f.ts"], "let x = 2;"); + }); + + test("ls lists in-memory directories and file sizes", async () => { + const { ctx } = memCtx({ + files: { "/work/a.txt": "12345", "/work/sub/b.txt": "x" }, + }); + const out = await lsTool.execute({}, ctx); + assert.match(out, /f {10}5 {2}a\.txt/); + assert.match(out, /d {2}- {10}sub\//); + }); + + test("apply_patch create / update / delete all land in memory", async () => { + const { ctx, caps } = memCtx({ files: { "/work/old.txt": "gone" } }); + await applyPatchTool.execute( + { + patches: [ + { path: "new.txt", action: "create", content: "fresh" }, + { path: "old.txt", action: "delete" }, + ], + }, + ctx, + ); + assert.deepEqual(caps.fs.snapshot(), { "/work/new.txt": "fresh" }); + }); + + test("apply_patch refuses to create over an existing file", async () => { + const { ctx } = memCtx({ files: { "/work/there.txt": "x" } }); + await assert.rejects( + applyPatchTool.execute( + { patches: [{ path: "there.txt", action: "create", content: "y" }] }, + ctx, + ), + /already exists/, + ); + }); +}); + +describe("a bounded world rejects escapes at resolvePath", () => { + test("writes outside the root are refused before any I/O", async () => { + const { ctx, caps } = memCtx({ root: "/work" }); + await assert.rejects( + writeTool.execute({ path: "../escaped.txt", content: "x" }, ctx), + /escapes the workspace root/, + ); + await assert.rejects( + writeTool.execute({ path: "/etc/passwd", content: "x" }, ctx), + /escapes the workspace root/, + ); + assert.deepEqual(caps.fs.snapshot(), {}, "nothing was written"); + }); + + test("reads outside the root are refused too — one choke point, both directions", async () => { + const { ctx } = memCtx({ root: "/work", files: { "/etc/passwd": "secret" } }); + await assert.rejects( + readTool.execute({ path: "/etc/passwd" }, ctx), + /escapes the workspace root/, + ); + }); + + test("paths inside the root still work", async () => { + const { ctx, caps } = memCtx({ root: "/work" }); + await writeTool.execute({ path: "deep/ok.txt", content: "fine" }, ctx); + assert.equal(caps.fs.snapshot()["/work/deep/ok.txt"], "fine"); + }); +}); + +describe("a world without processes says so", () => { + test("bash fails loudly rather than pretending to succeed", async () => { + const { ctx } = memCtx(); + await assert.rejects( + bashTool.execute({ command: "echo hi" }, ctx), + /shell is unavailable/, + ); + }); + + test("refusingShell rejects both operations", async () => { + await assert.rejects(refusingShell.run("x", { cwd: "/" }), /unavailable/); + await assert.rejects(refusingShell.exec("x", [], { cwd: "/" }), /unavailable/); + }); +}); diff --git a/src/capabilities/types.ts b/src/capabilities/types.ts new file mode 100644 index 0000000..4db0826 --- /dev/null +++ b/src/capabilities/types.ts @@ -0,0 +1,92 @@ +/** + * Capability seams — the execution world a tool acts on (H1, see + * docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2). + * + * Before this, every filesystem/shell tool reached straight for `node:fs` and + * `node:child_process`. That made "where does this tool actually operate" an + * un-swappable fact baked into fourteen call sites, with three consequences the + * roadmap keeps running into: + * + * - Dispatch cannot move execution into a container or a remote host without a + * second implementation of every tool; + * - Cloud multi-tenancy has to delete `read`/`write`/`bash` from the registry + * outright (`cloudSafeSubset`) because there is no way to bound them — a + * deny-by-omission list is a patch, a bounded execution world is the fix; + * - tests can only exercise these tools against the real disk. + * + * The shape follows dsh's seam model: a capability is an *interface* (this + * file), one or more *providers* (`local.ts`, `memory.ts`, and later a sandboxed + * one), and *consumers* (the tools). Swapping the provider swaps the execution + * world for every consumer at once, with no change to tool code. + * + * `resolvePath` is deliberately part of the fs seam rather than something each + * tool does with `path.resolve`. It is the single choke point where a policy can + * reject an escape, which is what makes H2's sandbox a provider swap instead of + * a check bolted onto seven tools — and what guarantees fs and shell cannot end + * up bounded to different roots. + */ + +export interface FsStat { + isFile: boolean; + isDirectory: boolean; + size: number; +} + +export interface FsDirEntry { + name: string; + isFile: boolean; + isDirectory: boolean; +} + +export interface FsCapability { + /** + * Resolve a model-supplied path against the execution world. Providers that + * bound the world reject escapes here by throwing, so callers can treat the + * returned path as permitted. + */ + resolvePath(cwd: string, p: string): string; + stat(abs: string): Promise; + exists(abs: string): Promise; + readFile(abs: string): Promise; + /** Atomic (temp sibling + rename) and creates missing parent directories. */ + writeFile(abs: string, content: string): Promise; + readdir(abs: string): Promise; + unlink(abs: string): Promise; +} + +export interface ExecOptions { + cwd: string; + signal?: AbortSignal; + timeoutMs?: number; + /** Truncate each of stdout/stderr at this many bytes. */ + maxOutputBytes?: number; +} + +export interface ExecResult { + stdout: string; + stderr: string; + code: number | null; + signal: string | null; + /** True when stdout or stderr hit `maxOutputBytes`. */ + truncated: boolean; +} + +export interface ShellCapability { + /** Run a shell command string (the `bash` tool's world). */ + run(command: string, opts: ExecOptions): Promise; + /** + * Spawn a program with an argv array — no shell, so no quoting hazard. + * + * dsh splits this into a separate `ctx.subprocess` seam. At LISA's size one + * seam with two operations is enough, but they must stay separate methods: + * routing an argv array through a shell string is how command injection gets + * introduced, and `grep` passes a model-supplied pattern. + */ + exec(file: string, args: string[], opts: ExecOptions): Promise; +} + +/** The execution world handed to tools through `ToolContext.caps`. */ +export interface Capabilities { + fs: FsCapability; + shell: ShellCapability; +} diff --git a/src/tools/apply_patch.ts b/src/tools/apply_patch.ts index 53b37c2..7d13eb7 100644 --- a/src/tools/apply_patch.ts +++ b/src/tools/apply_patch.ts @@ -1,6 +1,4 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { atomicWrite, ensureDir, pathExists } from "../fs-utils.js"; +import { capsOf } from "../capabilities/index.js"; import type { ToolDefinition } from "../types.js"; interface FilePatch { @@ -53,34 +51,35 @@ export const applyPatchTool: ToolDefinition = { required: ["patches"], }, async execute(input, ctx) { + const { fs } = capsOf(ctx); const summary: string[] = []; for (const patch of input.patches) { - const abs = path.resolve(ctx.cwd, patch.path); + const abs = fs.resolvePath(ctx.cwd, patch.path); if (patch.action === "create") { - if (await pathExists(abs)) { + if (await fs.exists(abs)) { throw new Error(`create: ${abs} already exists`); } if (patch.content == null) { throw new Error(`create: ${abs} requires content`); } - await ensureDir(path.dirname(abs)); - await atomicWrite(abs, patch.content); + // writeFile creates missing parents, so no separate mkdir step. + await fs.writeFile(abs, patch.content); summary.push(`create ${abs} (${patch.content.length} chars)`); } else if (patch.action === "delete") { - if (!(await pathExists(abs))) { + if (!(await fs.exists(abs))) { throw new Error(`delete: ${abs} does not exist`); } await fs.unlink(abs); summary.push(`delete ${abs}`); } else if (patch.action === "update") { - if (!(await pathExists(abs))) { + if (!(await fs.exists(abs))) { throw new Error(`update: ${abs} does not exist (use create instead)`); } if (patch.content != null) { - await atomicWrite(abs, patch.content); + await fs.writeFile(abs, patch.content); summary.push(`update ${abs} (full rewrite, ${patch.content.length} chars)`); } else if (patch.edits && patch.edits.length > 0) { - let current = await fs.readFile(abs, "utf8"); + let current = await fs.readFile(abs); for (const edit of patch.edits) { const occurrences = countOccurrences(current, edit.old_string); if (occurrences === 0) { @@ -97,7 +96,7 @@ export const applyPatchTool: ToolDefinition = { ? current.split(edit.old_string).join(edit.new_string) : current.replace(edit.old_string, edit.new_string); } - await atomicWrite(abs, current); + await fs.writeFile(abs, current); summary.push(`update ${abs} (${patch.edits.length} edits)`); } else { throw new Error(`update ${abs}: provide either content or edits`); diff --git a/src/tools/bash.ts b/src/tools/bash.ts index 4f60aca..92a97bd 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -1,8 +1,4 @@ -import { spawn } from "node:child_process"; -import { - defaultSandboxSpec, - wrapForSandbox, -} from "../sandbox/sandbox.js"; +import { capsOf } from "../capabilities/index.js"; import type { ToolDefinition } from "../types.js"; interface BashInput { @@ -30,60 +26,19 @@ export const bashTool: ToolDefinition = { required: ["command"], }, async execute(input, ctx) { - const timeout = Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT, MAX_TIMEOUT); - const wrapped = await wrapForSandbox( - defaultSandboxSpec({ cwd: ctx.cwd }), - input.command, - ); - return await new Promise((resolve, reject) => { - const child = spawn(wrapped.command, wrapped.args, { - cwd: ctx.cwd, - env: process.env, - signal: ctx.signal, - }); - let stdout = ""; - let stderr = ""; - let truncated = false; - const onData = (buf: Buffer, target: "stdout" | "stderr") => { - const text = buf.toString("utf8"); - if (target === "stdout") { - if (stdout.length + text.length > MAX_OUTPUT) { - stdout += text.slice(0, MAX_OUTPUT - stdout.length); - truncated = true; - } else { - stdout += text; - } - } else { - if (stderr.length + text.length > MAX_OUTPUT) { - stderr += text.slice(0, MAX_OUTPUT - stderr.length); - truncated = true; - } else { - stderr += text; - } - } - }; - child.stdout.on("data", (b) => onData(b, "stdout")); - child.stderr.on("data", (b) => onData(b, "stderr")); - const timer = setTimeout(() => { - child.kill("SIGTERM"); - setTimeout(() => child.kill("SIGKILL"), 2000); - }, timeout); - child.on("error", async (err) => { - clearTimeout(timer); - await wrapped.cleanup?.(); - reject(err); - }); - child.on("close", async (code, signal) => { - clearTimeout(timer); - await wrapped.cleanup?.(); - const parts = [ - `exit_code=${code ?? "null"}${signal ? ` signal=${signal}` : ""}`, - ]; - if (stdout) parts.push(`--- stdout ---\n${stdout}`); - if (stderr) parts.push(`--- stderr ---\n${stderr}`); - if (truncated) parts.push("[output truncated at 64KB]"); - resolve(parts.join("\n")); - }); + const { shell } = capsOf(ctx); + const result = await shell.run(input.command, { + cwd: ctx.cwd, + signal: ctx.signal, + timeoutMs: Math.min(input.timeout_ms ?? DEFAULT_TIMEOUT, MAX_TIMEOUT), + maxOutputBytes: MAX_OUTPUT, }); + const parts = [ + `exit_code=${result.code ?? "null"}${result.signal ? ` signal=${result.signal}` : ""}`, + ]; + if (result.stdout) parts.push(`--- stdout ---\n${result.stdout}`); + if (result.stderr) parts.push(`--- stderr ---\n${result.stderr}`); + if (result.truncated) parts.push("[output truncated at 64KB]"); + return parts.join("\n"); }, }; diff --git a/src/tools/edit.ts b/src/tools/edit.ts index 7c19c5a..925caf5 100644 --- a/src/tools/edit.ts +++ b/src/tools/edit.ts @@ -1,6 +1,4 @@ -import fs from "node:fs/promises"; -import path from "node:path"; -import { atomicWrite } from "../fs-utils.js"; +import { capsOf } from "../capabilities/index.js"; import type { ToolDefinition } from "../types.js"; interface EditInput { @@ -30,8 +28,9 @@ export const editTool: ToolDefinition = { if (input.old_string === input.new_string) { throw new Error("old_string and new_string are identical"); } - const abs = path.resolve(ctx.cwd, input.path); - const original = await fs.readFile(abs, "utf8"); + const { fs } = capsOf(ctx); + const abs = fs.resolvePath(ctx.cwd, input.path); + const original = await fs.readFile(abs); const occurrences = countOccurrences(original, input.old_string); if (occurrences === 0) { throw new Error(`old_string not found in ${abs}`); @@ -44,7 +43,7 @@ export const editTool: ToolDefinition = { const updated = input.replace_all ? original.split(input.old_string).join(input.new_string) : original.replace(input.old_string, input.new_string); - await atomicWrite(abs, updated); + await fs.writeFile(abs, updated); return `Edited ${abs}: ${input.replace_all ? occurrences : 1} replacement(s).`; }, }; diff --git a/src/tools/grep.ts b/src/tools/grep.ts index 5b73eae..0797264 100644 --- a/src/tools/grep.ts +++ b/src/tools/grep.ts @@ -1,5 +1,4 @@ -import { spawn } from "node:child_process"; -import path from "node:path"; +import { capsOf } from "../capabilities/index.js"; import type { ToolDefinition } from "../types.js"; interface GrepInput { @@ -30,36 +29,29 @@ export const grepTool: ToolDefinition = { required: ["pattern"], }, async execute(input, ctx) { - const target = path.resolve(ctx.cwd, input.path ?? "."); + const { fs, shell } = capsOf(ctx); + const target = fs.resolvePath(ctx.cwd, input.path ?? "."); const max = input.max_results ?? DEFAULT_MAX; const args = ["-RnE", "--exclude-dir=node_modules", "--exclude-dir=.git"]; if (input.ignore_case) args.push("-i"); if (input.glob) args.push(`--include=${input.glob}`); + // `exec`, not `run`: the pattern is model-supplied, and putting it through + // a shell string is how a search turns into command execution. args.push("-e", input.pattern, target); - return await new Promise((resolve, reject) => { - const child = spawn("grep", args, { cwd: ctx.cwd, signal: ctx.signal }); - let stdout = ""; - let stderr = ""; - child.stdout.on("data", (b: Buffer) => { - if (stdout.length < 256 * 1024) stdout += b.toString("utf8"); - }); - child.stderr.on("data", (b: Buffer) => { - stderr += b.toString("utf8"); - }); - child.on("error", reject); - child.on("close", (code) => { - if (code === 1) return resolve("(no matches)"); - if (code !== 0 && code !== null) { - return reject(new Error(`grep exited ${code}: ${stderr.trim()}`)); - } - const lines = stdout.split("\n").filter(Boolean); - const trimmed = lines.slice(0, max).join("\n"); - const more = - lines.length > max - ? `\n[... ${lines.length - max} more matches ...]` - : ""; - resolve(trimmed + more); - }); + const result = await shell.exec("grep", args, { + cwd: ctx.cwd, + signal: ctx.signal, + maxOutputBytes: 256 * 1024, }); + // grep's exit codes: 0 = matches, 1 = none, >1 = real error. + if (result.code === 1) return "(no matches)"; + if (result.code !== 0 && result.code !== null) { + throw new Error(`grep exited ${result.code}: ${result.stderr.trim()}`); + } + const lines = result.stdout.split("\n").filter(Boolean); + const trimmed = lines.slice(0, max).join("\n"); + const more = + lines.length > max ? `\n[... ${lines.length - max} more matches ...]` : ""; + return trimmed + more; }, }; diff --git a/src/tools/ls.ts b/src/tools/ls.ts index fdb34cf..8ec21a8 100644 --- a/src/tools/ls.ts +++ b/src/tools/ls.ts @@ -1,5 +1,5 @@ -import fs from "node:fs/promises"; import path from "node:path"; +import { capsOf } from "../capabilities/index.js"; import type { ToolDefinition } from "../types.js"; interface LsInput { @@ -18,15 +18,16 @@ export const lsTool: ToolDefinition = { }, }, async execute(input, ctx) { - const target = path.resolve(ctx.cwd, input.path ?? "."); - const entries = await fs.readdir(target, { withFileTypes: true }); + const { fs } = capsOf(ctx); + const target = fs.resolvePath(ctx.cwd, input.path ?? "."); + const entries = await fs.readdir(target); const rows: string[] = []; for (const entry of entries) { if (entry.name.startsWith(".")) continue; const abs = path.join(target, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory) { rows.push(`d - ${entry.name}/`); - } else if (entry.isFile()) { + } else if (entry.isFile) { const stat = await fs.stat(abs); rows.push(`f ${String(stat.size).padStart(9, " ")} ${entry.name}`); } else { diff --git a/src/tools/read.ts b/src/tools/read.ts index 0f05924..546ec57 100644 --- a/src/tools/read.ts +++ b/src/tools/read.ts @@ -1,5 +1,4 @@ -import fs from "node:fs/promises"; -import path from "node:path"; +import { capsOf } from "../capabilities/index.js"; import type { ToolDefinition } from "../types.js"; interface ReadInput { @@ -27,15 +26,16 @@ export const readTool: ToolDefinition = { required: ["path"], }, async execute(input, ctx) { - const abs = path.resolve(ctx.cwd, input.path); + const { fs } = capsOf(ctx); + const abs = fs.resolvePath(ctx.cwd, input.path); const stat = await fs.stat(abs); - if (!stat.isFile()) throw new Error(`not a file: ${abs}`); + if (!stat.isFile) throw new Error(`not a file: ${abs}`); if (stat.size > MAX_BYTES) { throw new Error( `file too large (${stat.size} bytes, limit ${MAX_BYTES}). Use grep or read with offset/limit.`, ); } - const raw = await fs.readFile(abs, "utf8"); + const raw = await fs.readFile(abs); const lines = raw.split(/\r?\n/); const offset = Math.max(1, input.offset ?? 1); const limit = input.limit ?? DEFAULT_LIMIT; diff --git a/src/tools/write.ts b/src/tools/write.ts index 674b199..72d73c0 100644 --- a/src/tools/write.ts +++ b/src/tools/write.ts @@ -1,5 +1,4 @@ -import path from "node:path"; -import { atomicWrite } from "../fs-utils.js"; +import { capsOf } from "../capabilities/index.js"; import type { ToolDefinition } from "../types.js"; interface WriteInput { @@ -21,8 +20,9 @@ export const writeTool: ToolDefinition = { required: ["path", "content"], }, async execute(input, ctx) { - const abs = path.resolve(ctx.cwd, input.path); - await atomicWrite(abs, input.content); + const { fs } = capsOf(ctx); + const abs = fs.resolvePath(ctx.cwd, input.path); + await fs.writeFile(abs, input.content); return `Wrote ${input.content.length} chars to ${abs}`; }, }; diff --git a/src/types.ts b/src/types.ts index aeb08a6..091eff7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,16 @@ export interface ToolContext { * before the turn is considered closed. (Phase 2.1) */ onObjection?: (o: { reason: string; refusing: boolean; userRequestSummary: string }) => void; + /** + * The execution world filesystem/shell tools act on (H1, see + * docs/PLAN_HARNESS_ALIGNMENT_v1.0.md §2). Unset means the host's own disk + * and shell — swapping it (sandboxed, per-tenant, remote) redirects every + * fs/shell tool at once without touching tool code. + * + * Tools read this through `capsOf(ctx)` from src/capabilities/, never + * directly, so the local default is applied in exactly one place. + */ + caps?: import("./capabilities/types.js").Capabilities; } export type StoredMessage = Anthropic.MessageParam;