@oh-my-pi/pi-coding-agent 17.3.5 → 17.3.7

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,467 @@
1
+ /**
2
+ * In-session fallbacks for permission-denied file writes and deletes.
3
+ *
4
+ * A host that embeds the agent inside an OS sandbox can grant a path mid-session
5
+ * but cannot apply that grant to an in-process write, because the write happens
6
+ * in the agent process under a profile fixed at launch. This module gives such a
7
+ * host a seam to intercept a denied mutation and perform it through a privileged
8
+ * channel, without reimplementing `write`/`edit` semantics: the native tool still
9
+ * records its own snapshot under the real destination path once the fallback
10
+ * reports success, so a follow-up hashline `edit` on that path keeps working.
11
+ *
12
+ * Writes and deletes have SEPARATE registries. A write handler brokers `content`
13
+ * to `dst`, so a delete request reaching it with no content invites brokering an
14
+ * empty write and truncating the file it was asked to remove. Opting into deletes
15
+ * is therefore explicit; see {@link addFileDeleteFallback}.
16
+ *
17
+ * ## What is routed
18
+ *
19
+ * The byte-write that `write`, `edit` and `apply_patch` perform on an ordinary
20
+ * file path goes through the same two-line primitive
21
+ * (`file ? file.write(content) : Bun.write(dst, content)`). It has four call
22
+ * sites, and all of them route here:
23
+ *
24
+ * - `writethroughNoop` and `runLspWritethrough`'s `writeContent` (`lsp/writethrough.ts`),
25
+ * the `WritethroughCallback` that `write` and `edit` both write through.
26
+ * `apply_patch` reaches it too: `LspFileSystem.write` (`edit/modes/patch.ts`),
27
+ * which it always injects, delegates to the same callback.
28
+ * - `HashlineFilesystem.move` (`edit/hashline/filesystem.ts`) — a hashline `MV`
29
+ * destination, the one `edit` write that does not pass through the writethrough.
30
+ * - `defaultFileSystem.write` (`edit/modes/patch.ts`), only the default parameter
31
+ * for external `applyPatch` callers and tests.
32
+ *
33
+ * `apply_patch` also creates a missing parent directory before writing, via its
34
+ * filesystem's `mkdir`. That `mkdir` consults {@link hasFileWriteFallback} so a
35
+ * denial there falls through to the write and reaches a handler, instead of
36
+ * throwing before the seam is ever consulted.
37
+ *
38
+ * The unlink that `edit` and `apply_patch` perform routes to the separate delete
39
+ * seam ({@link deleteFileWithFallback}) at four sites: `HashlineFilesystem.delete`
40
+ * (`edit`'s `REM`) and `HashlineFilesystem.move`'s source unlink, plus
41
+ * `LspFileSystem.delete` and `defaultFileSystem.delete` for `apply_patch`.
42
+ *
43
+ * ## What is NOT routed
44
+ *
45
+ * This is deliberately not an exhaustive interception of every syscall the tools
46
+ * can make. A permission error from any of these surfaces as it does today:
47
+ *
48
+ * - `write` to an archive member (`foo.zip:entry`) or a SQLite row. Neither is a
49
+ * byte-write to `dst`: an archive member rewrite reads the whole archive, sets
50
+ * one entry, writes a temp file and renames over the original, so the bytes on
51
+ * disk are a whole binary container rather than the string the tool was given;
52
+ * a SQLite write is a row operation inside the database engine with no byte
53
+ * payload at all. Brokering either needs a different request shape than
54
+ * "these exact bytes belong at this path".
55
+ * - `acp-bridge.ts`'s `bridge.writeTextFile` — a remote-client transport.
56
+ * - Removing a DIRECTORY is never the intent: the delete seam refuses to divert a
57
+ * target it can confirm is one, and reports `confirmedFile: false` when the
58
+ * target's metadata is behind the same boundary and the check cannot be resolved.
59
+ * - The `lsp` tool's own writes: applying a workspace edit or a code action
60
+ * (`lsp/edits.ts`), and the Biome formatter, which writes the buffer and then
61
+ * shells out to `biome format --write` (`lsp/clients/biome-client.ts`) — a
62
+ * subprocess write no in-process seam can reach anyway.
63
+ *
64
+ * ## Diverting
65
+ *
66
+ * Only a permission boundary diverts — `EPERM`, `EACCES`, `EROFS`, plus the one
67
+ * case where Bun hides such a denial behind an `ENOENT` (see
68
+ * {@link classifyWriteFailure}). Every other error rethrows untouched.
69
+ *
70
+ * With no handler registered this module is inert: the primitive runs exactly as
71
+ * it did before, a failure rethrows from the same place, and no extra syscalls
72
+ * are performed.
73
+ *
74
+ * ## The path a handler is given
75
+ *
76
+ * A handler is more privileged than the syscall that just failed, so it is never
77
+ * handed the lexical path the tool used. A lexical path is not a destination: the
78
+ * kernel follows every component above the last, so `ws/link/file` under a
79
+ * `ws/link -> /elsewhere` link lands outside `ws` while still looking
80
+ * in-workspace. That defeats the defence a helper author reaches for first, since
81
+ * a prefix allowlist passes on the link's own path — and for writes the final
82
+ * component is followed too, so a plain `ws/link` is enough.
83
+ *
84
+ * `req.dst` is therefore resolved through {@link resolveSyscallTarget} to the path
85
+ * the failed syscall itself acted on: fully for a write, and up to the last
86
+ * component for a delete, since `unlink` removes a link rather than following it.
87
+ * Resolving rather than refusing also closes the TOCTOU window, because the
88
+ * handler no longer traverses a link the agent could re-point after the check.
89
+ *
90
+ * A path that cannot be canonicalized — a dangling final link, or an ancestor
91
+ * whose own resolution is denied — is not brokered at all. "Where would this
92
+ * land" has no answer there, and a privileged writer is the wrong place to guess.
93
+ * That narrows the seam for a sandbox that also hides the ancestors of a denied
94
+ * path, which is the honest cost of not handing over an unverifiable target.
95
+ *
96
+ * That refusal is load-bearing for more than symlink safety, and relaxing it needs
97
+ * care. `apply_patch`'s `create` and rename-destination refuse to overwrite, and
98
+ * they decide that with `Bun.file(dst).exists()`, which reports `false` when the
99
+ * parent hides the target's metadata rather than distinguishing "absent" from
100
+ * "unknown". The non-overwrite contract holds today only because the same denied
101
+ * `lstat` that fools that check also stops this seam from brokering — a privileged
102
+ * writer, the one party that could enforce exclusivity itself, is never handed the
103
+ * path. Broker an unverifiable destination and a `create` starts clobbering a
104
+ * protected file it was told not to touch; a request field carrying explicit
105
+ * exclusive-create intent would be the prerequisite for that change.
106
+ *
107
+ * ## Scope of the registry
108
+ *
109
+ * Handlers live in one process-wide list, and a process can host several sessions
110
+ * (a subagent gets its own `ExtensionRunner`). A handler is therefore consulted
111
+ * for denied mutations from ANY session in the process, not only the one whose
112
+ * extension registered it. Filtering by session here would be wrong: a subagent
113
+ * spawned with `restrictToolNames` loads no extensions of its own, so scoping
114
+ * would leave its denied writes with nothing to broker them, and a host that
115
+ * registers once in its top-level session expects subagent writes covered.
116
+ *
117
+ * So the request names its origin instead, and the policy stays with the party
118
+ * that owns it. `req.sessionId` is the session that issued the mutation (see
119
+ * {@link withFileMutationSession}); a handler compares it with
120
+ * `ctx.sessionManager.getSessionId()` to decide. That matters most for a handler
121
+ * that prompts: `ctx.ui` belongs to the session whose extension registered the
122
+ * handler, which is not necessarily the session being asked about.
123
+ *
124
+ * Each list is iterated over a snapshot, because a concurrent session shutdown
125
+ * splices the live array and a `for` over it would skip whichever handler shifted
126
+ * into the hole.
127
+ */
128
+ import { AsyncLocalStorage } from "node:async_hooks";
129
+ import * as fs from "node:fs/promises";
130
+ import * as path from "node:path";
131
+ import { isEnoent, isFsError, logger } from "@oh-my-pi/pi-utils";
132
+ import type { BunFile } from "bun";
133
+ import type { ExtensionContext } from "../extensibility/extensions/types";
134
+ import { resolveSyscallTarget } from "./path-utils";
135
+
136
+ /** A denied write, captured for a registered fallback to retry through a privileged channel. */
137
+ export interface FileWriteFallbackRequest {
138
+ /**
139
+ * Absolute, symlink-resolved path to write the bytes to.
140
+ *
141
+ * This is where the failed in-process write would itself have landed, which is
142
+ * not necessarily the path the tool was given: `open` follows every component,
143
+ * so a link anywhere in that path redirects the bytes. Resolving it here is
144
+ * what lets a handler's allowlist see the real destination instead of a
145
+ * lexically innocent path, so a handler MUST treat this as authoritative and
146
+ * MUST NOT re-derive the target from anything else.
147
+ */
148
+ dst: string;
149
+ /**
150
+ * Session the denied write was issued from, or `undefined` when the mutation
151
+ * did not happen inside a tool call (an external `applyPatch` caller, a test).
152
+ *
153
+ * The registry is process-wide, so a handler can be consulted for a write from
154
+ * a session other than the one whose extension registered it. Compare this with
155
+ * `ctx.sessionManager.getSessionId()` to tell the two apart — a handler that
156
+ * prompts through `ctx.ui` needs to, since that UI belongs to ITS session and
157
+ * not necessarily to the one being asked about.
158
+ */
159
+ sessionId: string | undefined;
160
+ /** The exact bytes the tool intended to write. */
161
+ content: string;
162
+ /**
163
+ * The error that proves the write hit a permission boundary. Usually the write's
164
+ * own `EPERM`/`EACCES`/`EROFS`; for a write into a directory the host may not
165
+ * create, the denial raised by creating that directory, in which case `dst`'s
166
+ * parent may not exist yet and the handler is responsible for creating it.
167
+ */
168
+ cause: unknown;
169
+ }
170
+
171
+ /** Extension-authored handler. Return `true` once `content` is durably on disk at `dst`. */
172
+ export type FileWriteFallbackHandler = (req: FileWriteFallbackRequest, ctx: ExtensionContext) => Promise<boolean>;
173
+
174
+ /** A handler already bound to its owning extension's live context. */
175
+ type BoundFileWriteFallbackHandler = (req: FileWriteFallbackRequest) => Promise<boolean>;
176
+
177
+ /** A denied unlink, captured for a registered fallback to perform through a privileged channel. */
178
+ export interface FileDeleteFallbackRequest {
179
+ /**
180
+ * Absolute, symlink-resolved path the unlink was denied for.
181
+ *
182
+ * Every component ABOVE the last is resolved, so a handler cannot be walked
183
+ * outside its allowed roots through a link in the path. The last component is
184
+ * deliberately NOT resolved, because `unlink` removes a link itself rather
185
+ * than its target — which is also why this may still name a symlink.
186
+ */
187
+ dst: string;
188
+ /** The `EPERM`/`EACCES`/`EROFS` that proves the unlink hit a permission boundary. */
189
+ cause: unknown;
190
+ /**
191
+ * Whether `dst` was confirmed to be a plain regular file before diverting.
192
+ *
193
+ * `false` means the seam could not establish that, either because the target's
194
+ * own metadata is behind the same boundary that denied the unlink — the common
195
+ * sandbox case, since `unlink` on a directory also reports `EPERM` on Darwin —
196
+ * or because `dst` is a symlink.
197
+ *
198
+ * A handler MUST remove `dst` with a plain unlink. It MUST NOT remove it
199
+ * recursively, and MUST NOT resolve the path first: when this is `false` the
200
+ * target may be a directory, and resolving a symlink would delete whatever it
201
+ * points at instead of the link.
202
+ */
203
+ confirmedFile: boolean;
204
+ /** See {@link FileWriteFallbackRequest.sessionId}. */
205
+ sessionId: string | undefined;
206
+ }
207
+
208
+ /** Extension-authored handler. Return `true` once `dst` is gone from disk. */
209
+ export type FileDeleteFallbackHandler = (req: FileDeleteFallbackRequest, ctx: ExtensionContext) => Promise<boolean>;
210
+
211
+ /** A handler already bound to its owning extension's live context. */
212
+ type BoundFileDeleteFallbackHandler = (req: FileDeleteFallbackRequest) => Promise<boolean>;
213
+
214
+ const PERMISSION_DENIED_CODES: Record<string, true> = { EPERM: true, EACCES: true, EROFS: true };
215
+ const PERMISSION_DENIED_MESSAGE = /\b(EPERM|EACCES|EROFS)\b/;
216
+
217
+ /** True for `EPERM`, `EACCES`, and `EROFS` — the sandbox-boundary write failures this seam exists for. */
218
+ export function isPermissionDeniedError(error: unknown): boolean {
219
+ // A structured `code` is authoritative. Checking the message as well would
220
+ // misclassify any error whose path contains one of these names, and Bun embeds
221
+ // the full path in its fs error messages (`ENOENT: ..., open '/x/EACCES/y'`).
222
+ if (isFsError(error)) return PERMISSION_DENIED_CODES[error.code] === true;
223
+ // Some write paths (e.g. a bridged transport) surface the denial as a plain
224
+ // Error with no structured `code`, leaving only the message to go on.
225
+ return error instanceof Error && PERMISSION_DENIED_MESSAGE.test(error.message);
226
+ }
227
+
228
+ const fallbackHandlers: BoundFileWriteFallbackHandler[] = [];
229
+
230
+ /** Whether any fallback is registered. Lets a caller skip work that only this seam needs. */
231
+ export function hasFileWriteFallback(): boolean {
232
+ return fallbackHandlers.length > 0;
233
+ }
234
+
235
+ /**
236
+ * Append a fallback writer, consulted in registration order when a direct write is
237
+ * permission-denied. Returns a disposer that removes this exact registration; the
238
+ * runner calls it on session shutdown so no handler outlives its session.
239
+ */
240
+ export function addFileWriteFallback(handler: BoundFileWriteFallbackHandler): () => void {
241
+ fallbackHandlers.push(handler);
242
+ return () => {
243
+ const index = fallbackHandlers.indexOf(handler);
244
+ if (index !== -1) fallbackHandlers.splice(index, 1);
245
+ };
246
+ }
247
+
248
+ const deleteFallbackHandlers: BoundFileDeleteFallbackHandler[] = [];
249
+
250
+ /** Whether any delete fallback is registered. */
251
+ export function hasFileDeleteFallback(): boolean {
252
+ return deleteFallbackHandlers.length > 0;
253
+ }
254
+
255
+ /**
256
+ * Append a fallback deleter, consulted in registration order when a direct unlink is
257
+ * permission-denied. Deliberately a separate registry from
258
+ * {@link addFileWriteFallback}: a write handler brokers `content` to `dst`, and
259
+ * handing it a request with no content would let it "broker" an empty write and
260
+ * truncate the file it was asked to remove. Opting in is explicit for that reason.
261
+ */
262
+ export function addFileDeleteFallback(handler: BoundFileDeleteFallbackHandler): () => void {
263
+ deleteFallbackHandlers.push(handler);
264
+ return () => {
265
+ const index = deleteFallbackHandlers.indexOf(handler);
266
+ if (index !== -1) deleteFallbackHandlers.splice(index, 1);
267
+ };
268
+ }
269
+
270
+ const mutationSessionStorage = new AsyncLocalStorage<string>();
271
+
272
+ /**
273
+ * Name the session whose tool call is about to run, so a denied mutation inside it
274
+ * can tell a handler where the request came from.
275
+ *
276
+ * Entered once per tool call by `ExtensionToolWrapper` (`extensibility/extensions/
277
+ * wrapper.ts`), which `sdk.ts` puts around the whole tool registry whenever an
278
+ * `ExtensionRunner` exists — so the component that owns the handlers is the one
279
+ * naming its own session, and no caller has to thread an `AgentToolContext`
280
+ * through for attribution to work.
281
+ *
282
+ * That covers the deferred LSP write batch too: a batch id belongs to one
283
+ * assistant turn of one session, and its flush is awaited inside a tool call of
284
+ * that same session, so a write performed during a later call of the group is
285
+ * still attributed to the session that issued it.
286
+ *
287
+ * Deliberately NOT a general "current session" accessor: nothing else enters this
288
+ * scope, so outside a tool call it is empty by design — an external `applyPatch`
289
+ * caller reports `undefined` rather than borrowing someone else's identity.
290
+ */
291
+ export function withFileMutationSession<T>(sessionId: string | undefined, fn: () => T): T {
292
+ // With nothing registered no scope is entered, keeping the seam's inertness
293
+ // promise: a stock host pays one length check per tool call and no more.
294
+ if (sessionId === undefined || (fallbackHandlers.length === 0 && deleteFallbackHandlers.length === 0)) return fn();
295
+ return mutationSessionStorage.run(sessionId, fn);
296
+ }
297
+
298
+ /**
299
+ * Remove a file, consulting registered delete fallbacks when the unlink is denied.
300
+ *
301
+ * Unlike the write path there is no masked-`ENOENT` case to see through: nothing is
302
+ * created on the way, so an `ENOENT` here means the file genuinely is not there and
303
+ * must propagate — `edit`'s `REM` turns it into a `NotFoundError`.
304
+ */
305
+ export async function deleteFileWithFallback(dst: string, file?: BunFile): Promise<void> {
306
+ try {
307
+ if (file) {
308
+ await file.unlink();
309
+ } else {
310
+ await fs.unlink(dst);
311
+ }
312
+ } catch (error) {
313
+ if (deleteFallbackHandlers.length === 0 || !isPermissionDeniedError(error)) throw error;
314
+ // A handler is more privileged than the unlink that just failed, so it is told
315
+ // which path really gets removed, not the lexical one the tool used. `unlink`
316
+ // follows every component ABOVE the last, so `ws/link/victim` under a
317
+ // `ws/link -> /elsewhere` link removes a file outside `ws` while a helper's
318
+ // prefix allowlist still passes. The final component is deliberately left
319
+ // unresolved: `unlink` removes the link itself, never its target.
320
+ const target = await resolveSyscallTarget(dst, false);
321
+ if (target === null) throw error;
322
+ // `unlink` on a directory reports EPERM on Darwin (EISDIR on Linux), which is
323
+ // indistinguishable from a sandbox denial by code alone, so check the target
324
+ // before diverting: asking a privileged deleter to remove a DIRECTORY on
325
+ // behalf of a tool that only ever removes one file would far exceed the
326
+ // intent. `lstat` rather than `stat`, so the link itself is judged — removing
327
+ // a symlink is a legitimate file removal, and following it here would ask the
328
+ // wrong question.
329
+ const stat = await fs.lstat(target).catch((statError: unknown) => {
330
+ // A sandbox that denies the unlink usually denies the target's metadata
331
+ // too, so a denied `lstat` is expected here and must still divert — it
332
+ // just leaves the question unresolved, which `confirmedFile` reports.
333
+ // Any OTHER `lstat` failure is not something this seam should paper over.
334
+ if (isPermissionDeniedError(statError)) return null;
335
+ throw error;
336
+ });
337
+ if (stat?.isDirectory()) throw error;
338
+ // A symlink is safe to unlink but NOT safe to resolve: a helper that
339
+ // realpaths `dst` for auditing, or removes it recursively, would act on the
340
+ // link's target instead. Only a plain regular file is a confirmed file.
341
+ const confirmedFile = stat?.isFile() ?? false;
342
+ // The process-wide registry can hand this to a handler from another session,
343
+ // so the request names the one that issued it.
344
+ const sessionId = mutationSessionStorage.getStore();
345
+ // Snapshot: a concurrent session shutdown splices the live array, and
346
+ // iterating it directly would skip whichever handler shifted into the hole.
347
+ for (const handler of [...deleteFallbackHandlers]) {
348
+ try {
349
+ if (await handler({ dst: target, cause: error, confirmedFile, sessionId })) return;
350
+ } catch (handlerError) {
351
+ logger.warn("File delete fallback handler threw; trying next handler", {
352
+ dst: target,
353
+ error: handlerError instanceof Error ? handlerError.message : String(handlerError),
354
+ });
355
+ }
356
+ }
357
+ // Always the ORIGINAL error, never a handler's, so behaviour matches a host
358
+ // with no fallback registered.
359
+ throw error;
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Outcome of inspecting a failed primitive write. `denied` diverts to the
365
+ * registered handlers, `retry` repeats the write because this call repaired the
366
+ * cause, and `rethrow` leaves the original error alone.
367
+ */
368
+ type WriteFailureKind = { kind: "denied"; cause: unknown } | { kind: "retry" } | { kind: "rethrow" };
369
+
370
+ /**
371
+ * Decide whether a failed write hit a permission boundary.
372
+ *
373
+ * `Bun.write` and `BunFile.write` create missing parent directories themselves,
374
+ * but when that `mkdir` is the thing being denied they report the subsequent
375
+ * `open()`'s `ENOENT` rather than the denial — so a sandboxed write into a new
376
+ * out-of-tree directory is indistinguishable from an ordinary missing path.
377
+ * Redoing the `mkdir` explicitly recovers the real errno, and because it runs
378
+ * through the same enforcement path as the write it sees kernel-level denials
379
+ * (Seatbelt, LSM) that a `stat`/`access` probe would report as writable.
380
+ *
381
+ * Only called with at least one handler registered, so a stock host never pays
382
+ * for this.
383
+ */
384
+ async function classifyWriteFailure(dst: string, error: unknown): Promise<WriteFailureKind> {
385
+ if (isPermissionDeniedError(error)) return { kind: "denied", cause: error };
386
+ if (!isEnoent(error)) return { kind: "rethrow" };
387
+ try {
388
+ await fs.mkdir(path.dirname(dst), { recursive: true });
389
+ } catch (mkdirError) {
390
+ // A denied `mkdir` is the boundary the write hid; anything else (`ENOTDIR`
391
+ // for a file used as a directory, ...) is a genuine bad path.
392
+ if (isPermissionDeniedError(mkdirError)) return { kind: "denied", cause: mkdirError };
393
+ return { kind: "rethrow" };
394
+ }
395
+ // The parent exists now, so the `ENOENT` was a lost race rather than a
396
+ // boundary. Any directory just created stays, matching what a permitted
397
+ // `Bun.write` would have left behind; removing it could race a concurrent
398
+ // writer that legitimately needs it.
399
+ return { kind: "retry" };
400
+ }
401
+
402
+ export async function writeFileWithFallback(dst: string, content: string, file?: BunFile): Promise<void> {
403
+ // Attempt 0 is the plain write. The single retry is reachable only when the
404
+ // first failure turned out to be a parent-directory race this call repaired,
405
+ // which bounds the loop at two writes.
406
+ for (let attempt = 0; ; attempt++) {
407
+ try {
408
+ if (file) {
409
+ await file.write(content);
410
+ } else {
411
+ await Bun.write(dst, content);
412
+ }
413
+ return;
414
+ } catch (error) {
415
+ if (fallbackHandlers.length === 0) throw error;
416
+ // On the second attempt a `retry` verdict can no longer change the
417
+ // outcome, so skip the probe and let the error stand unless it is a
418
+ // denial the handlers should see.
419
+ const failure =
420
+ attempt === 0
421
+ ? await classifyWriteFailure(dst, error)
422
+ : isPermissionDeniedError(error)
423
+ ? ({ kind: "denied", cause: error } as const)
424
+ : ({ kind: "rethrow" } as const);
425
+ if (failure.kind === "retry") continue;
426
+ if (failure.kind === "denied") {
427
+ // A handler is more privileged than the write that just failed, so it is
428
+ // told where the bytes would REALLY have landed rather than the lexical
429
+ // path the tool used. `open` follows EVERY component, so `ws/link/file`
430
+ // under a `ws/link -> /elsewhere` link writes outside `ws` while still
431
+ // looking in-workspace — which defeats the defence a helper author
432
+ // reaches for first, since a prefix allowlist passes on the link's own
433
+ // path. Resolving closes that, and closes the TOCTOU window with it: the
434
+ // helper no longer traverses a link the agent could re-point after the
435
+ // check. A path that cannot be canonicalized is not brokered at all,
436
+ // because "where would this land" then has no answer to hand over.
437
+ const target = await resolveSyscallTarget(dst, true);
438
+ // Snapshot: a concurrent session shutdown splices the live array, and
439
+ // iterating it directly would skip whichever handler shifted into the hole.
440
+ if (target !== null) {
441
+ // The process-wide registry can hand this to a handler from another
442
+ // session, so the request names the one that issued it.
443
+ const sessionId = mutationSessionStorage.getStore();
444
+ for (const handler of [...fallbackHandlers]) {
445
+ try {
446
+ if (await handler({ dst: target, content, cause: failure.cause, sessionId })) return;
447
+ } catch (handlerError) {
448
+ logger.warn("File write fallback handler threw; trying next handler", {
449
+ dst: target,
450
+ error: handlerError instanceof Error ? handlerError.message : String(handlerError),
451
+ });
452
+ }
453
+ }
454
+ }
455
+ }
456
+ // Always the ORIGINAL error, never a handler's, so behaviour matches a
457
+ // host with no fallback registered. When the real boundary was recovered
458
+ // from behind a masked `ENOENT`, attach it so the denial is not lost:
459
+ // without this the caller is told `ENOENT` for a path this code has
460
+ // already proven is `EACCES`.
461
+ if (failure.kind === "denied" && failure.cause !== error && error instanceof Error && error.cause == null) {
462
+ error.cause = failure.cause;
463
+ }
464
+ throw error;
465
+ }
466
+ }
467
+ }
@@ -86,6 +86,7 @@ export * from "./debug";
86
86
  export * from "./essential-tools";
87
87
  export * from "./eval";
88
88
  export * from "./eval-backends";
89
+ export * from "./file-write-fallback";
89
90
  export * from "./gh";
90
91
  export * from "./glob";
91
92
  export * from "./grep";
@@ -618,6 +618,85 @@ function isSymlink(target: string): boolean {
618
618
  }
619
619
  }
620
620
 
621
+ /**
622
+ * Resolve the path a syscall on `filePath` would really act on, or `null` when
623
+ * that cannot be established.
624
+ *
625
+ * A lexical path is not a destination. The kernel follows every component above
626
+ * the last, so `ws/link/file` under a `ws/link -> /elsewhere` link lands outside
627
+ * `ws` while still looking relative and `..`-free. Handing such a path to a
628
+ * privileged helper defeats the defence a helper author reaches for first — a
629
+ * prefix allowlist passes, because the link sits inside the allowed root while
630
+ * its target does not. Callers that hand a path to something more privileged
631
+ * than the syscall that just failed resolve it here first.
632
+ *
633
+ * Rejecting symlinked components outright is not an option: `/var` and `/tmp`
634
+ * are links on macOS, so every path under `os.tmpdir()` traverses one. They are
635
+ * resolved instead, and only a path whose real destination cannot be established
636
+ * is refused, because "where would this land" then has no answer to hand over.
637
+ * {@link confineToWorkspace} refuses an unresolvable link for the same reason.
638
+ *
639
+ * @param followFinal `true` for a syscall that follows a link at the final
640
+ * component (`open`, so every write), `false` for one that acts on the link
641
+ * itself (`unlink`) and therefore needs it left alone.
642
+ */
643
+ export async function resolveSyscallTarget(filePath: string, followFinal: boolean): Promise<string | null> {
644
+ const target = path.resolve(filePath);
645
+ if (followFinal) {
646
+ const real = await tryRealpathAsync(target);
647
+ if (real !== null) return real;
648
+ // `realpath` also fails on a DANGLING link, which a write follows to a place
649
+ // this cannot name, and on a path whose ancestor may not be searched. Neither
650
+ // is proof the final component is a plain name, and only proof continues.
651
+ if (!(await isProvenNotSymlink(target))) return null;
652
+ }
653
+ // Walk up to the deepest ancestor that does resolve, then re-apply the
654
+ // components below it. A resolved ancestor vouches for the ones above it, so
655
+ // re-applying them lexically matches what the kernel would have done.
656
+ const tail: string[] = [path.basename(target)];
657
+ let ancestor = path.dirname(target);
658
+ for (;;) {
659
+ const real = await tryRealpathAsync(ancestor);
660
+ if (real !== null) return path.join(real, ...tail.reverse());
661
+ // This component is about to be re-applied lexically without a resolved
662
+ // ancestor vouching for it, which is exactly the escape being closed — so it
663
+ // has to prove itself. `realpath` fails here for a component that does not
664
+ // exist yet AND for one inside a directory the caller may not search (the
665
+ // usual shape when a sandbox hides a denied path), and the second still
666
+ // permits `lstat`.
667
+ if (!(await isProvenNotSymlink(ancestor))) return null;
668
+ const parent = path.dirname(ancestor);
669
+ // Ran past the filesystem root: `realpath("/")` cannot fail, so only a
670
+ // filesystem disappearing mid-walk gets here.
671
+ if (parent === ancestor) return null;
672
+ tail.push(path.basename(ancestor));
673
+ ancestor = parent;
674
+ }
675
+ }
676
+
677
+ async function tryRealpathAsync(target: string): Promise<string | null> {
678
+ try {
679
+ // `fs.promises.realpath` has no `.native` variant under Bun, unlike its sync
680
+ // counterpart; the JS implementation resolves links identically.
681
+ return await fs.promises.realpath(target);
682
+ } catch {
683
+ return null;
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Whether `target` is known NOT to redirect. A path that does not exist cannot
689
+ * redirect anything, and nothing below it exists either; any other `lstat`
690
+ * failure leaves the question unanswered, which is not proof.
691
+ */
692
+ async function isProvenNotSymlink(target: string): Promise<boolean> {
693
+ try {
694
+ return !(await fs.promises.lstat(target)).isSymbolicLink();
695
+ } catch (error) {
696
+ return isEnoent(error);
697
+ }
698
+ }
699
+
621
700
  export function formatPathRelativeToCwd(
622
701
  filePath: string,
623
702
  cwd: string,