pi-better-subagents 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,409 @@
1
+ // Generated from packages/task-sandbox/apply-patch.ts. Do not edit directly.
2
+ /**
3
+ * A guarded `apply_patch`: the Codex patch format, applied only through the
4
+ * task's guarded file operations.
5
+ *
6
+ * Same tool name, parameter schema and patch format as the Codex tool (and
7
+ * `@vanillagreen/pi-codex-minimal-tools`, MIT), so models' habits work. The
8
+ * implementation is this harness's own: it never touches the filesystem
9
+ * directly, so the Project files / Outside project levels, credential rules and
10
+ * protected paths govern it exactly as they govern write, edit and bash.
11
+ *
12
+ * The whole patch is validated first (syntax, file rules, every hunk against
13
+ * the current content). Only then is anything written, one whole file at a
14
+ * time, so no file is ever left half-patched. A failure during the write phase
15
+ * restores what it can and reports exactly what was and was not applied.
16
+ */
17
+ import { dirname, isAbsolute, resolve } from "node:path";
18
+
19
+ export type PatchAction =
20
+ | { kind: "add"; path: string; lines: string[] }
21
+ | { kind: "delete"; path: string }
22
+ | { kind: "update"; path: string; moveTo?: string; hunks: PatchHunk[] };
23
+
24
+ export interface PatchHunk {
25
+ /** Text after `@@ `: a line to find before this hunk's context. */
26
+ anchor?: string;
27
+ /** Lines with their one-character prefix: " " context, "-" removed, "+" added. */
28
+ lines: string[];
29
+ /** `*** End of File`: the hunk's context must end at the file's last line. */
30
+ endOfFile: boolean;
31
+ }
32
+
33
+ const BEGIN = "*** Begin Patch";
34
+ const END = "*** End Patch";
35
+ const ADD = "*** Add File: ";
36
+ const DELETE = "*** Delete File: ";
37
+ const UPDATE = "*** Update File: ";
38
+ const MOVE = "*** Move to: ";
39
+ const EOF_MARKER = "*** End of File";
40
+
41
+ export class PatchError extends Error {
42
+ constructor(message: string) {
43
+ super(message);
44
+ this.name = "PatchError";
45
+ }
46
+ }
47
+
48
+ function isHeader(line: string): boolean {
49
+ const t = line.trim();
50
+ return t === END || t.startsWith(ADD) || t.startsWith(DELETE) || t.startsWith(UPDATE);
51
+ }
52
+
53
+ function headerPath(line: string, prefix: string, at: number): string {
54
+ const path = line.trim().slice(prefix.length).trim();
55
+ if (!path) throw new PatchError(`Line ${at}: '${prefix.trim()}' needs a path.`);
56
+ return path;
57
+ }
58
+
59
+ /** Parse Codex patch text. Throws a PatchError naming the offending line. */
60
+ export function parsePatch(input: string): PatchAction[] {
61
+ if (typeof input !== "string" || !input.trim()) throw new PatchError("apply_patch needs a non-empty patch in `input`.");
62
+ const lines = input.replace(/\r\n?/g, "\n").split("\n");
63
+ let i = 0;
64
+ while (i < lines.length && !lines[i]!.trim()) i++;
65
+ if (lines[i]?.trim() !== BEGIN) throw new PatchError(`The patch must start with '${BEGIN}'.`);
66
+ i++;
67
+ const actions: PatchAction[] = [];
68
+ let ended = false;
69
+ while (i < lines.length) {
70
+ const line = lines[i]!;
71
+ const at = i + 1;
72
+ const trimmed = line.trim();
73
+ if (trimmed === END) { ended = true; i++; break; }
74
+ if (!trimmed) { i++; continue; }
75
+ if (trimmed.startsWith(ADD)) {
76
+ const path = headerPath(line, ADD, at);
77
+ const body: string[] = [];
78
+ let bare = 0; // Trailing bare empty lines separate sections; inner ones are empty lines.
79
+ i++;
80
+ while (i < lines.length && !isHeader(lines[i]!)) {
81
+ const bodyLine = lines[i]!;
82
+ if (bodyLine === "") { body.push(""); bare++; i++; continue; }
83
+ if (!bodyLine.startsWith("+")) throw new PatchError(`Line ${i + 1}: every line of an added file starts with '+' (${path}).`);
84
+ body.push(bodyLine.slice(1));
85
+ bare = 0;
86
+ i++;
87
+ }
88
+ body.splice(body.length - bare);
89
+ actions.push({ kind: "add", path, lines: body });
90
+ } else if (trimmed.startsWith(DELETE)) {
91
+ actions.push({ kind: "delete", path: headerPath(line, DELETE, at) });
92
+ i++;
93
+ } else if (trimmed.startsWith(UPDATE)) {
94
+ const path = headerPath(line, UPDATE, at);
95
+ i++;
96
+ let moveTo: string | undefined;
97
+ if (i < lines.length && lines[i]!.trim().startsWith(MOVE)) {
98
+ moveTo = headerPath(lines[i]!, MOVE, i + 1);
99
+ i++;
100
+ }
101
+ const hunks: PatchHunk[] = [];
102
+ let current: PatchHunk | undefined;
103
+ let bare = 0; // Trailing bare empty lines: separators unless more hunk lines follow.
104
+ while (i < lines.length && !isHeader(lines[i]!)) {
105
+ const bodyLine = lines[i]!;
106
+ if (bodyLine.startsWith("@@")) {
107
+ if (current) current.lines.splice(current.lines.length - bare);
108
+ bare = 0;
109
+ const anchor = bodyLine.slice(2).trim();
110
+ current = { ...(anchor ? { anchor } : {}), lines: [], endOfFile: false };
111
+ hunks.push(current);
112
+ } else if (bodyLine.trim() === EOF_MARKER) {
113
+ if (!current) throw new PatchError(`Line ${i + 1}: '${EOF_MARKER}' outside a hunk in ${path}.`);
114
+ current.lines.splice(current.lines.length - bare);
115
+ bare = 0;
116
+ current.endOfFile = true;
117
+ } else {
118
+ if (!current) { current = { lines: [], endOfFile: false }; hunks.push(current); }
119
+ else if (current.endOfFile && bodyLine !== "") throw new PatchError(`Line ${i + 1}: lines after '${EOF_MARKER}' in ${path}.`);
120
+ if (bodyLine === "") {
121
+ // A bare empty line is an empty context line (models often drop the space).
122
+ if (!current.endOfFile) { current.lines.push(" "); bare++; }
123
+ } else if (" +-".includes(bodyLine[0]!)) {
124
+ current.lines.push(bodyLine);
125
+ bare = 0;
126
+ } else throw new PatchError(`Line ${i + 1}: hunk lines start with ' ', '+' or '-' (${path}).`);
127
+ }
128
+ i++;
129
+ }
130
+ if (current) current.lines.splice(current.lines.length - bare);
131
+ if (hunks.some((hunk) => hunk.lines.length === 0)) throw new PatchError(`Empty hunk in ${path}.`);
132
+ if (!hunks.length && !moveTo) throw new PatchError(`'${UPDATE.trim()} ${path}' has no changes.`);
133
+ actions.push({ kind: "update", path, ...(moveTo ? { moveTo } : {}), hunks });
134
+ } else {
135
+ throw new PatchError(`Line ${at}: expected '${ADD.trim()}', '${UPDATE.trim()}', '${DELETE.trim()}' or '${END}', got: ${line.slice(0, 80)}`);
136
+ }
137
+ }
138
+ if (!ended) throw new PatchError(`The patch must end with '${END}'.`);
139
+ if (lines.slice(i).some((line) => line.trim())) throw new PatchError(`Text after '${END}'.`);
140
+ if (!actions.length) throw new PatchError("The patch has no file operations.");
141
+ return actions;
142
+ }
143
+
144
+ // ---- content ----------------------------------------------------------------
145
+
146
+ type Text = { lines: string[]; eol: string; finalNewline: boolean };
147
+
148
+ function splitText(content: string): Text {
149
+ const eol = content.includes("\r\n") ? "\r\n" : "\n";
150
+ const lines = content.split(/\r?\n/);
151
+ const finalNewline = lines.length > 1 && lines.at(-1) === "";
152
+ if (finalNewline || content === "") lines.pop();
153
+ return { lines, eol, finalNewline };
154
+ }
155
+
156
+ function joinText(text: Text): string {
157
+ return text.lines.join(text.eol) + (text.finalNewline && text.lines.length ? text.eol : "");
158
+ }
159
+
160
+ const normalizers: ((line: string) => string)[] = [
161
+ (line) => line,
162
+ (line) => line.trimEnd(),
163
+ (line) => line.trim(),
164
+ // Typographic punctuation models sometimes substitute for ASCII.
165
+ (line) => line.trim().replace(/[‐-―−]/g, "-").replace(/[‘’‛]/g, "'")
166
+ .replace(/[“”‟]/g, "\"").replace(/[  -    ]/g, " "),
167
+ ];
168
+
169
+ /** First index at or after `from` where `needle` matches, trying stricter comparisons first. */
170
+ function seek(lines: string[], needle: string[], from: number, endOfFile: boolean): number {
171
+ if (!needle.length) return endOfFile ? lines.length : -1;
172
+ for (const normalize of normalizers) {
173
+ const want = needle.map(normalize);
174
+ const matches = (start: number) => want.every((line, k) => normalize(lines[start + k]!) === line);
175
+ if (endOfFile) {
176
+ const start = lines.length - needle.length;
177
+ if (start >= from && matches(start)) return start;
178
+ continue;
179
+ }
180
+ for (let start = from; start + needle.length <= lines.length; start++) if (matches(start)) return start;
181
+ }
182
+ return -1;
183
+ }
184
+
185
+ /** Apply every hunk to `content` in order, or throw naming the hunk that does not fit. */
186
+ export function applyHunks(content: string, hunks: readonly PatchHunk[], path: string): string {
187
+ const text = splitText(content);
188
+ let cursor = 0;
189
+ hunks.forEach((hunk, index) => {
190
+ const label = `hunk ${index + 1} of ${path}`;
191
+ if (hunk.anchor !== undefined) {
192
+ const found = seek(text.lines, [hunk.anchor], cursor, false);
193
+ if (found < 0) throw new PatchError(`${label}: could not find the '@@ ${hunk.anchor}' line.`);
194
+ cursor = found + 1;
195
+ }
196
+ const before = hunk.lines.filter((line) => line[0] !== "+").map((line) => line.slice(1));
197
+ const after = hunk.lines.filter((line) => line[0] !== "-").map((line) => line.slice(1));
198
+ let start: number;
199
+ if (!before.length) {
200
+ // Pure insertion: after the anchor, or at the end of the file.
201
+ start = hunk.anchor !== undefined && !hunk.endOfFile ? cursor : text.lines.length;
202
+ } else {
203
+ start = seek(text.lines, before, cursor, hunk.endOfFile);
204
+ if (start < 0) {
205
+ const preview = before.slice(0, 3).join("\\n");
206
+ throw new PatchError(`${label}: the context/removed lines were not found${hunk.endOfFile ? " at the end of the file" : ""} (starting "${preview.slice(0, 120)}").`);
207
+ }
208
+ }
209
+ text.lines.splice(start, before.length, ...after);
210
+ cursor = start + after.length;
211
+ });
212
+ if (!content.length && text.lines.length) text.finalNewline = true;
213
+ return joinText(text);
214
+ }
215
+
216
+ // ---- planning and applying --------------------------------------------------
217
+
218
+ /** The guarded operations the tool is allowed to use. Nothing else touches the disk. */
219
+ export interface PatchFileOperations {
220
+ /** Rejects with code ENOENT when the file does not exist. */
221
+ readFile(path: string): Promise<Buffer>;
222
+ writeFile(path: string, content: string): Promise<void>;
223
+ mkdir(path: string): Promise<void>;
224
+ remove(path: string): Promise<void>;
225
+ /** Policy pre-checks without I/O: throw the refusal the operation itself would. */
226
+ checkWrite(path: string): void;
227
+ checkRemove(path: string): void;
228
+ }
229
+
230
+ type FileState = { original: string | null; current: string | null };
231
+ type Step = { kind: "write" | "remove"; path: string; original: string | null; content?: string };
232
+
233
+ export interface ApplyPatchResult {
234
+ summary: string;
235
+ changes: { kind: "add" | "update" | "delete" | "move"; path: string; moveTo?: string }[];
236
+ }
237
+
238
+ function stripPathQuotes(path: string): string {
239
+ const quoted = /^(["'`]).*\1$/.test(path) ? path.slice(1, -1) : path;
240
+ return quoted.startsWith("@") ? quoted.slice(1) : quoted;
241
+ }
242
+
243
+ export function resolvePatchPath(cwd: string, path: string): string {
244
+ const cleaned = stripPathQuotes(path.trim());
245
+ return isAbsolute(cleaned) ? resolve(cleaned) : resolve(cwd, cleaned);
246
+ }
247
+
248
+ function describe(action: PatchAction): string {
249
+ if (action.kind === "add") return `add ${action.path}`;
250
+ if (action.kind === "delete") return `delete ${action.path}`;
251
+ return action.moveTo ? `update ${action.path} → ${action.moveTo}` : `update ${action.path}`;
252
+ }
253
+
254
+ /**
255
+ * Validate the whole patch against the guarded operations and current files,
256
+ * then write it. Throws a PatchError before any write when anything is wrong.
257
+ */
258
+ export async function applyPatch(input: string, cwd: string, ops: PatchFileOperations): Promise<ApplyPatchResult> {
259
+ const actions = parsePatch(input);
260
+ const files = new Map<string, FileState>();
261
+ const load = async (path: string): Promise<FileState> => {
262
+ let state = files.get(path);
263
+ if (state) return state;
264
+ let original: string | null;
265
+ try {
266
+ original = (await ops.readFile(path)).toString("utf8");
267
+ } catch (error) {
268
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
269
+ original = null;
270
+ }
271
+ state = { original, current: original };
272
+ files.set(path, state);
273
+ return state;
274
+ };
275
+ const fail = (action: PatchAction, error: unknown): never => {
276
+ const message = error instanceof Error ? error.message : String(error);
277
+ throw new PatchError(`apply_patch rejected (${describe(action)}): ${message} Nothing was changed.`);
278
+ };
279
+
280
+ // 1. Validate everything. No write happens in this phase.
281
+ for (const action of actions) {
282
+ try {
283
+ const path = resolvePatchPath(cwd, action.path);
284
+ if (action.kind === "add") {
285
+ ops.checkWrite(path);
286
+ const state = await load(path);
287
+ state.current = action.lines.length ? action.lines.join("\n") + "\n" : "";
288
+ } else if (action.kind === "delete") {
289
+ ops.checkRemove(path);
290
+ const state = await load(path);
291
+ if (state.current === null) throw new PatchError(`${action.path} does not exist.`);
292
+ state.current = null;
293
+ } else {
294
+ ops.checkWrite(path);
295
+ const state = await load(path);
296
+ if (state.current === null) throw new PatchError(`${action.path} does not exist.`);
297
+ const updated = action.hunks.length ? applyHunks(state.current, action.hunks, action.path) : state.current;
298
+ const destination = action.moveTo ? resolvePatchPath(cwd, action.moveTo) : path;
299
+ if (destination === path) {
300
+ state.current = updated;
301
+ } else {
302
+ ops.checkRemove(path);
303
+ ops.checkWrite(destination);
304
+ const target = await load(destination);
305
+ target.current = updated;
306
+ state.current = null;
307
+ }
308
+ }
309
+ } catch (error) {
310
+ fail(action, error);
311
+ }
312
+ }
313
+
314
+ // 2. Write whole files first, removals last: a failure part-way then leaves
315
+ // extra files behind rather than losing content.
316
+ const steps: Step[] = [];
317
+ for (const [path, state] of files) {
318
+ if (state.current === state.original) continue;
319
+ if (state.current !== null) steps.push({ kind: "write", path, original: state.original, content: state.current });
320
+ }
321
+ for (const [path, state] of files) {
322
+ if (state.current === null && state.original !== null) steps.push({ kind: "remove", path, original: state.original });
323
+ }
324
+ const done: Step[] = [];
325
+ try {
326
+ for (const step of steps) {
327
+ if (step.kind === "write") {
328
+ if (step.original === null) await ops.mkdir(dirname(step.path));
329
+ await ops.writeFile(step.path, step.content!);
330
+ } else {
331
+ await ops.remove(step.path);
332
+ }
333
+ done.push(step);
334
+ }
335
+ } catch (error) {
336
+ const failed = steps[done.length]!;
337
+ const restored: string[] = [];
338
+ const left: string[] = [];
339
+ for (const step of [...done].reverse()) {
340
+ try {
341
+ if (step.original === null) await ops.remove(step.path);
342
+ else await ops.writeFile(step.path, step.original);
343
+ restored.push(step.path);
344
+ } catch (undo) {
345
+ left.push(`${step.kind === "write" ? (step.original === null ? "created" : "rewrote") : "removed"} ${step.path} (${undo instanceof Error ? undo.message : String(undo)})`);
346
+ }
347
+ }
348
+ const pending = steps.slice(done.length + 1).map((step) => `${step.kind} ${step.path}`);
349
+ throw new PatchError([
350
+ `apply_patch failed to ${failed.kind} ${failed.path}: ${error instanceof Error ? error.message : String(error)}`,
351
+ `Applied then restored: ${restored.join(", ") || "none"}.`,
352
+ `Still applied (could not restore): ${left.join("; ") || "none"}.`,
353
+ `Not applied: ${[`${failed.kind} ${failed.path}`, ...pending].join(", ")}.`,
354
+ ].join("\n"));
355
+ }
356
+
357
+ const changes = actions.map((action) => action.kind === "update" && action.moveTo
358
+ ? { kind: "move" as const, path: action.path, moveTo: action.moveTo }
359
+ : { kind: action.kind, path: action.path });
360
+ return { summary: `Applied patch: ${actions.map(describe).join(", ")}.`, changes };
361
+ }
362
+
363
+ // ---- tool definition --------------------------------------------------------
364
+
365
+ export const APPLY_PATCH_TOOL = "apply_patch";
366
+
367
+ /** Same schema as the Codex tool: one `input` string. */
368
+ export const applyPatchParameters = {
369
+ type: "object",
370
+ additionalProperties: false,
371
+ properties: {
372
+ input: { type: "string", description: "Codex apply_patch text beginning with *** Begin Patch and ending with *** End Patch." },
373
+ },
374
+ required: ["input"],
375
+ } as const;
376
+
377
+ /** Every path a patch reads or changes, for serializing with other file tools. */
378
+ export function patchTargets(input: string, cwd: string): string[] {
379
+ const paths = new Set<string>();
380
+ for (const action of parsePatch(input)) {
381
+ paths.add(resolvePatchPath(cwd, action.path));
382
+ if (action.kind === "update" && action.moveTo) paths.add(resolvePatchPath(cwd, action.moveTo));
383
+ }
384
+ return [...paths].sort();
385
+ }
386
+
387
+ type MutationQueue = <T>(path: string, fn: () => Promise<T>) => Promise<T>;
388
+
389
+ export function createApplyPatchToolDefinition(cwd: string, ops: PatchFileOperations, queue?: MutationQueue) {
390
+ return {
391
+ name: APPLY_PATCH_TOOL,
392
+ label: "Apply Patch",
393
+ description: "Apply a Codex-style patch (*** Begin Patch … *** End Patch) with Add File, Update File (optionally *** Move to), and Delete File operations. " +
394
+ "Paths are relative to the working directory. The patch follows the same sandbox file rules as write and edit, is validated in full before anything is written, and never leaves a file half-patched.",
395
+ promptSnippet: "Apply Codex-style multi-file patches using the input argument.",
396
+ promptGuidelines: ["Use apply_patch for multi-file or multi-hunk edits when a Codex-style patch is clearer than separate edit/write calls."],
397
+ parameters: applyPatchParameters,
398
+ async execute(_toolCallId: string, params: { input?: unknown }) {
399
+ if (!params || typeof params.input !== "string") throw new PatchError("apply_patch requires an `input` string.");
400
+ const input = params.input;
401
+ // Hold every target's mutation slot so a parallel edit/write cannot interleave.
402
+ const run = (targets: string[]): Promise<ApplyPatchResult> => queue && targets.length
403
+ ? queue(targets[0]!, () => run(targets.slice(1)))
404
+ : applyPatch(input, cwd, ops);
405
+ const result = await run(patchTargets(input, cwd));
406
+ return { content: [{ type: "text" as const, text: `${result.summary}\nFiles changed: ${result.changes.length}` }], details: result };
407
+ },
408
+ };
409
+ }
@@ -2,12 +2,13 @@
2
2
  /** Kernel-confined operations for Pi's built-in file tools. */
3
3
  import { spawn } from "node:child_process";
4
4
  import { randomUUID } from "node:crypto";
5
- import { constants } from "node:fs";
6
- import { access, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
5
+ import { constants, realpathSync } from "node:fs";
6
+ import { access, lstat, mkdir, open, readFile, stat, unlink, writeFile } from "node:fs/promises";
7
+ import { basename, dirname, join } from "node:path";
7
8
  import type { ReadOperations, WriteOperations, EditOperations } from "@earendil-works/pi-coding-agent";
8
9
  import {
9
- canonicalizePath, compileWritePolicy, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
10
- type SandboxWritePolicy,
10
+ canonicalizePath, compileWritePolicy, evaluateDeleteAccess, evaluateReadAccess, evaluateWriteAccess, maybeBuildSandboxCommand,
11
+ type CompiledSandboxWritePolicy, type SandboxSeams, type SandboxWritePolicy, type WriteAccessDecision,
11
12
  } from "./shared-sandbox-core.ts";
12
13
 
13
14
  export interface TaskFileController {
@@ -73,6 +74,9 @@ process.stdin.on('end', async () => {
73
74
  if (typeof content !== 'string' || Buffer.byteLength(content, 'utf8') > MAX)
74
75
  throw Object.assign(new Error('File exceeds 8 MiB operation limit'), {code:'EFBIG'});
75
76
  await fs.writeFile(path, content, 'utf8'); break;
77
+ case 'unlink':
78
+ if ((await fs.lstat(path)).isDirectory()) throw Object.assign(new Error('Refusing to remove a directory'), {code:'EISDIR'});
79
+ await fs.unlink(path); break;
76
80
  default: throw new Error('Unknown file operation');
77
81
  }
78
82
  process.stdout.write(JSON.stringify({ok:true, data}));
@@ -82,7 +86,48 @@ process.stdin.on('end', async () => {
82
86
  }
83
87
  });`;
84
88
 
85
- type Operation = "read" | "mime" | "access-read" | "access-edit" | "mkdir" | "existing-directory" | "write";
89
+ type Operation = "read" | "mime" | "access-read" | "access-edit" | "mkdir" | "existing-directory" | "write" | "unlink";
90
+
91
+ /** Guarded removal of one file (never a directory). Follows the same launch policy as writes. */
92
+ export interface TaskRemoveOperations {
93
+ remove(path: string): Promise<void>;
94
+ }
95
+
96
+ /**
97
+ * Policy pre-checks with no I/O, so a multi-file tool can validate a whole
98
+ * request before changing anything. Each throws the same refusal the operation
99
+ * itself would; unconfined plans allow everything.
100
+ */
101
+ export interface TaskAccessChecks {
102
+ write(path: string): void;
103
+ remove(path: string): void;
104
+ }
105
+
106
+ function refusal(verb: string, decision: WriteAccessDecision): Error {
107
+ const detail = !decision.allowed && decision.deniedBy ? ` (protected by ${decision.deniedBy})` : "";
108
+ return new Error(`Task sandbox refused to ${verb} ${decision.path}: ${decision.allowed ? "" : decision.reason}${detail}.`);
109
+ }
110
+
111
+ /**
112
+ * Removing an entry is judged on both its resolved target and its own directory
113
+ * entry, so a link can neither remove a protected file nor hide a protected entry.
114
+ * The entry is evaluated without following its final component: the parent is
115
+ * canonicalized, the leaf is taken literally. Otherwise a link planted in
116
+ * `~/.ssh` or a sibling repository would be judged by its (deletable) target.
117
+ */
118
+ export function removeDecision(path: string, policy: CompiledSandboxWritePolicy): WriteAccessDecision {
119
+ const target = evaluateDeleteAccess(path, policy);
120
+ if (!target.allowed) return target;
121
+ const entry = join(canonicalizePath(dirname(path)), basename(path));
122
+ if (entry === target.path) return target;
123
+ const literalLeaf: SandboxSeams = {
124
+ canonicalize: (candidate) => {
125
+ if (candidate === entry) throw new Error("the leaf entry is judged literally");
126
+ return realpathSync(candidate);
127
+ },
128
+ };
129
+ return evaluateDeleteAccess(entry, policy, literalLeaf);
130
+ }
86
131
 
87
132
  function mimeType(data: Buffer): string | null {
88
133
  const ascii = (at: number, text: string) => data.toString("ascii", at, at + text.length) === text;
@@ -117,7 +162,7 @@ function mimeType(data: Buffer): string | null {
117
162
  }
118
163
 
119
164
  export function createTaskFileOperations(controller: TaskFileController): {
120
- read: ReadOperations; write: WriteOperations; edit: EditOperations;
165
+ read: ReadOperations; write: WriteOperations; edit: EditOperations; remove: TaskRemoveOperations; check: TaskAccessChecks;
121
166
  } {
122
167
  async function run(operation: Operation, path: string, content?: string): Promise<Buffer | void> {
123
168
  const plan = controller.requireLaunchPlan();
@@ -139,11 +184,18 @@ export function createTaskFileOperations(controller: TaskFileController): {
139
184
  return;
140
185
  case "mkdir": await mkdir(path, { recursive: true }); return;
141
186
  case "write": return writeFile(path, content!, "utf8");
187
+ case "unlink":
188
+ if ((await lstat(path)).isDirectory()) throw Object.assign(new Error("Refusing to remove a directory"), { code: "EISDIR" });
189
+ return unlink(path);
142
190
  }
143
191
  }
144
192
 
145
193
  const policy = compileWritePolicy(plan.policy);
146
- const writing = operation === "write" || operation === "mkdir" || operation === "access-edit";
194
+ if (operation === "unlink") {
195
+ const decision = removeDecision(path, policy);
196
+ if (!decision.allowed) throw refusal("remove", decision);
197
+ }
198
+ const writing = operation === "write" || operation === "mkdir" || operation === "access-edit" || operation === "unlink";
147
199
  const decision = writing ? evaluateWriteAccess(path, policy) : evaluateReadAccess(path, policy);
148
200
  if (!decision.allowed && operation !== "existing-directory") {
149
201
  if (operation === "mkdir" && decision.reason === "permission-denied") {
@@ -227,7 +279,17 @@ export function createTaskFileOperations(controller: TaskFileController): {
227
279
  });
228
280
  }
229
281
 
282
+ const check = (verb: "write" | "remove") => (path: string) => {
283
+ const plan = controller.requireLaunchPlan();
284
+ if (!plan.confined) return;
285
+ const policy = compileWritePolicy(plan.policy);
286
+ const decision = verb === "write" ? evaluateWriteAccess(path, policy) : removeDecision(path, policy);
287
+ if (!decision.allowed) throw refusal(verb, decision);
288
+ };
289
+
230
290
  return {
291
+ remove: { remove: (path) => run("unlink", path) as Promise<void> },
292
+ check: { write: check("write"), remove: check("remove") },
231
293
  read: {
232
294
  readFile: (path) => run("read", path) as Promise<Buffer>,
233
295
  access: (path) => run("access-read", path) as Promise<void>,
@@ -11,8 +11,11 @@ import { homedir, tmpdir } from "node:os";
11
11
  import { basename, dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
12
12
  import { canonicalizePath, compileWritePolicy, isRemovableUnderWrite, maybeBuildSandboxCommand, type SandboxPermissions } from "./shared-sandbox-core.ts";
13
13
  import { createTaskFileOperations, type TaskFileController } from "./shared-task-files.ts";
14
+ import { APPLY_PATCH_TOOL, createApplyPatchToolDefinition } from "./shared-task-apply-patch.ts";
14
15
 
15
16
  export const TASK_BUILTINS = Object.freeze(["read", "write", "edit", "bash"] as const);
17
+ /** Harness adapters that follow the file rules; each is a task builtin only when the profile enables it. */
18
+ export const GUARDED_TASK_TOOLS = Object.freeze([APPLY_PATCH_TOOL] as const);
16
19
 
17
20
  /** Loaded code and its installed dependencies must remain task-read-only. */
18
21
  export function runtimeCodeRoot(path: string): string {
@@ -165,6 +168,8 @@ export function installTaskTools(pi: ExtensionAPI, options: {
165
168
  shellPath?: () => string | undefined;
166
169
  trustedSources: readonly string[];
167
170
  admitExtensionTool?: (name: string, input: unknown, sourcePath: string | undefined) => boolean;
171
+ /** Register the guarded apply_patch adapter as a task builtin. */
172
+ applyPatch?: boolean;
168
173
  /** Build the admitted bash definition from the confined operations (default: the SDK bash tool). */
169
174
  bashDefinition?: (cwd: string, operations: BashOperations) => ReturnType<typeof createBashToolDefinition>;
170
175
  }) {
@@ -188,6 +193,12 @@ export function installTaskTools(pi: ExtensionAPI, options: {
188
193
  pi.registerTool(own(createWriteToolDefinition(cwd, { operations: files.write })));
189
194
  pi.registerTool(own(createEditToolDefinition(cwd, { operations: files.edit })));
190
195
  pi.registerTool(own(options.bashDefinition ? options.bashDefinition(cwd, bash) : createBashToolDefinition(cwd, { operations: bash })));
196
+ if (options.applyPatch) {
197
+ pi.registerTool(own(createApplyPatchToolDefinition(cwd, {
198
+ readFile: files.read.readFile, writeFile: files.write.writeFile, mkdir: files.write.mkdir,
199
+ remove: files.remove.remove, checkWrite: files.check.write, checkRemove: files.check.remove,
200
+ }, PiCodingAgent.withFileMutationQueue)) as any);
201
+ }
191
202
  };
192
203
  register(options.cwd);
193
204
  pi.on("user_bash", () => ({ operations: bash }));