@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.
- package/CHANGELOG.md +20 -0
- package/dist/{CHANGELOG-tt9k4jpr.md → CHANGELOG-1hmwwt45.md} +20 -0
- package/dist/cli.js +2839 -2839
- package/dist/docs-index.generated.txt +1 -1
- package/dist/types/cli/stats-cli.d.ts +1 -6
- package/dist/types/commands/stats.d.ts +4 -0
- package/dist/types/extensibility/extensions/runner.d.ts +21 -3
- package/dist/types/extensibility/extensions/types.d.ts +58 -0
- package/dist/types/slash-commands/helpers/stats-dashboard.d.ts +1 -0
- package/dist/types/tools/file-write-fallback.d.ts +124 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/path-utils.d.ts +23 -0
- package/package.json +13 -13
- package/src/cli/stats-cli.ts +6 -72
- package/src/commands/stats.ts +7 -4
- package/src/edit/hashline/filesystem.ts +9 -3
- package/src/edit/modes/patch.ts +31 -5
- package/src/extensibility/extensions/loader.ts +11 -0
- package/src/extensibility/extensions/runner.ts +115 -3
- package/src/extensibility/extensions/types.ts +60 -0
- package/src/extensibility/extensions/wrapper.ts +10 -1
- package/src/lsp/writethrough.ts +20 -10
- package/src/slash-commands/builtin-session.ts +1 -1
- package/src/slash-commands/helpers/stats-dashboard.ts +23 -9
- package/src/tools/file-write-fallback.ts +467 -0
- package/src/tools/index.ts +1 -0
- package/src/tools/path-utils.ts +79 -0
|
@@ -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
|
+
}
|
package/src/tools/index.ts
CHANGED
package/src/tools/path-utils.ts
CHANGED
|
@@ -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,
|