Files
oh-my-pi/packages/coding-agent/src/edit/hashline/filesystem.ts
T
Larry Gordon 6e4334c003 feat(extensions): broker denied file writes and deletes
A host that runs omp inside an OS sandbox can grant a path mid-session but cannot
apply that grant to an in-process write: `write` and `edit` do their I/O in the
agent process, so an out-of-workspace write fails and stays failed until the
process restarts under a wider profile.

Nothing available today closes that. A `tool_call` handler can block and a
`tool_result` handler can rewrite content, but neither can re-run a tool.
`ctx.invokeTool` delegates execution, but the delegated native tool runs in the
same process under the same restrictions. And the failure lands AT the write
syscall - after the tool computed the final content, before it returned - so the
bytes are gone with the throw, and reconstructing them means reimplementing
`edit`'s hashline protocol and the snapshot bookkeeping.

The byte-write that `write`, `edit` and `apply_patch` perform on an ordinary file
path already funnels through one two-line primitive
(`file ? file.write(content) : Bun.write(dst, content)`) at four call sites.
Routing that primitive through `writeFileWithFallback` gives an embedder a single
seam to intercept a permission-denied write: the native tool still records its own
snapshot under the real destination path once a handler reports success, so a
follow-up hashline `edit` on that path keeps working.

Only a permission boundary diverts - `EPERM`/`EACCES`/`EROFS`. Two cases needed
more than that:

- `Bun.write` creates missing parents itself, and when that `mkdir` is the denied
  operation it reports the subsequent `open()`'s `ENOENT` instead of the denial -
  making a sandboxed write into a new out-of-tree directory indistinguishable from
  an ordinary bad path. Redoing the `mkdir` explicitly recovers the real errno, and
  because it runs through the same enforcement path as the write it also sees
  kernel-level denials (Seatbelt, LSM) that a `stat`/`access` probe reports as
  writable. If no handler takes the write, the original `ENOENT` is still what
  propagates, with the recovered denial attached as its `cause`.
- `apply_patch` creates the parent as a separate step before writing, so a denial
  there threw before the seam was ever reached. That `mkdir` now tolerates a
  permission denial when a fallback is registered, letting the write report it.

A denial reached through a SYMLINK is never brokered. The in-process write follows
the link, so the kernel denied the link's TARGET, but a handler receives `dst` and
a privileged helper opening it with ordinary follow semantics would land the bytes
wherever the link points. That also defeats the obvious helper-side defence, since
a prefix allowlist passes when the link sits inside the allowed root while its
target does not. omp cannot vouch for the destination, so it refuses rather than
hand the ambiguity to a privileged writer - the same answer `confineToWorkspace`
already gives an unresolvable link.

Removing a file is a different primitive, so it gets its own seam
(`deleteFileWithFallback`, `registerFileDeleteFallback`) covering `edit`'s `REM`,
a hashline `MV`'s source unlink, and `apply_patch`'s delete op. Two differences
from the write path: `ENOENT` is never diverted, since nothing is created on the
way to an unlink and `REM` needs it to become a not-found error; and the seam
refuses a target it can confirm is a directory, because `unlink` on a directory
reports `EPERM` on Darwin and is otherwise indistinguishable from a sandbox
denial. That check cannot always run - a sandbox denying the unlink usually denies
the target's metadata too - so the request carries `confirmedFile`, and a handler
is required to use a plain unlink rather than resolving or recursing.

The two registries are deliberately separate. A write handler brokers `content` to
`dst`, so a delete request reaching it with no content invites brokering an empty
write and truncating the file it was asked to remove.

With nothing registered both seams are inert: the primitives run exactly as
before, a failure rethrows from the same place, and no extra syscalls are
performed.

Scope is deliberately narrow. Archive-member and SQLite writes are unchanged -
neither is a byte-write to a path, so brokering them needs a different request
shape - along with the ACP bridge's `writeTextFile`, the `lsp` tool's own
workspace-edit and formatter writes, and directory removal.
2026-08-14 08:52:34 -07:00

254 lines
10 KiB
TypeScript

/**
* Coding-agent specific {@link Filesystem} adapter for the hashline patcher.
*
* Wires hashline's storage abstraction to the agent runtime:
*
* - Section paths are resolved through the plan-mode redirect so a bare
* `PLAN.md` lands at the canonical session artifact location.
* - Reads go through `readEditFileText` (notebook-aware) and the
* auto-generated-file guard.
* - Writes go through `serializeEditFileText` (notebook-aware) and the
* LSP writethrough, with FS-scan cache invalidation on success. The
* resulting `FileDiagnosticsResult` is captured per-path so the
* orchestrator can attach it to the tool result.
*
* Construct one per `executeHashlineSingle` call: per-section state
* (batch request, diagnostics) lives on the instance and isn't safe to
* share across concurrent edit tools.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { Filesystem, NotFoundError, type PreflightWriteOptions, type WriteResult } from "@oh-my-pi/hashline";
import { isEnoent } from "@oh-my-pi/pi-utils";
import type { FileDiagnosticsResult, WritethroughCallback, WritethroughDeferredHandle } from "../../lsp";
import { FileChangeType, notifyWorkspaceWatchedFiles } from "../../lsp/client";
import type { ToolSession } from "../../tools";
import { routeWriteThroughBridge } from "../../tools/acp-bridge";
import { assertEditableFileContent } from "../../tools/auto-generated-guard";
import { deleteFileWithFallback, writeFileWithFallback } from "../../tools/file-write-fallback";
import { invalidateFsScanAfterWrite } from "../../tools/fs-cache-invalidation";
import { isInternalUrlPath } from "../../tools/path-utils";
import { enforcePlanModeWrite, resolvePlanPath, targetsLocalSandbox } from "../../tools/plan-mode-guard";
import { canonicalSnapshotKey } from "../file-snapshot-store";
import { isNotebookPath } from "../notebook";
import { readEditFileText, serializeEditFileText } from "../read-file";
import type { LspBatchRequest } from "../renderer";
export interface HashlineFilesystemOptions {
session: ToolSession;
writethrough: WritethroughCallback;
beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
signal?: AbortSignal;
/**
* Outer LSP batch request inherited from the tool-call context. The
* orchestrator narrows this per-section (flush only on the final write)
* via {@link HashlineFilesystem.setBatchRequest}.
*/
batchRequest?: LspBatchRequest;
}
export class HashlineFilesystem extends Filesystem {
readonly session: ToolSession;
readonly #writethrough: WritethroughCallback;
readonly #beginDeferredDiagnosticsForPath: (path: string) => WritethroughDeferredHandle;
readonly #signal: AbortSignal | undefined;
#batchRequest: LspBatchRequest | undefined;
#diagnosticsByPath = new Map<string, FileDiagnosticsResult | undefined>();
constructor(options: HashlineFilesystemOptions) {
super();
this.session = options.session;
this.#writethrough = options.writethrough;
this.#beginDeferredDiagnosticsForPath = options.beginDeferredDiagnosticsForPath;
this.#signal = options.signal;
this.#batchRequest = options.batchRequest;
}
/**
* Set the LSP batch request used for the next {@link writeText} call.
* Multi-section orchestrators flip the `flush` flag to true before the
* final section so LSP diagnostics flush in one round-trip.
*/
setBatchRequest(batchRequest: LspBatchRequest | undefined): void {
this.#batchRequest = batchRequest;
}
/**
* Look up (and clear) the diagnostics captured by the most-recent
* {@link writeText} call for `path`. Returns `undefined` if no write
* has happened or the writethrough returned no diagnostics.
*/
consumeDiagnostics(path: string): FileDiagnosticsResult | undefined {
const value = this.#diagnosticsByPath.get(path);
this.#diagnosticsByPath.delete(path);
return value;
}
resolveAbsolute(relativePath: string): string {
return resolvePlanPath(this.session, relativePath);
}
override canonicalPath(relativePath: string): string {
return canonicalSnapshotKey(this.resolveAbsolute(relativePath));
}
override allowTagPathRecovery(authoredPath: string, resolvedPath: string): boolean {
// Internal-URL authored targets (`local://`, `vault://`, …) are approved
// at the lower "read" privilege; never let one redirect onto a "write".
if (isInternalUrlPath(authoredPath)) return false;
// Recovery rebinds a bare/mis-typed authored path onto the file its
// snapshot tag uniquely names. Confine the redirect to locations a plain
// "write" may legitimately target:
// 1. the working tree (the model dropped the directory), or
// 2. the session `local://` sandbox where plan/scratch artifacts live —
// the snapshot tag proves the model wrote/read that exact file this
// session, so a bare `plan.md#tag` should land on `local://plan.md`.
// The secret vault and any other out-of-tree path stay refused.
const root = canonicalSnapshotKey(this.session.cwd);
if (resolvedPath === root || resolvedPath.startsWith(`${root}${path.sep}`)) return true;
return targetsLocalSandbox(this.session, resolvedPath);
}
async readText(relativePath: string): Promise<string> {
const absolutePath = this.resolveAbsolute(relativePath);
let content: string;
try {
content = await readEditFileText(absolutePath, relativePath);
} catch (error) {
if (isEnoent(error)) throw new NotFoundError(relativePath, error);
if (error instanceof Error && error.message === `File not found: ${relativePath}`) {
throw new NotFoundError(relativePath, error);
}
throw error;
}
// Refuse edits against generated files (lockfiles, models.json, …).
assertEditableFileContent(content, relativePath, this.session.settings);
return content;
}
override async readBinary(relativePath: string): Promise<Uint8Array | undefined> {
const absolutePath = this.resolveAbsolute(relativePath);
if (isNotebookPath(absolutePath)) return undefined;
try {
return await fs.readFile(absolutePath);
} catch (error) {
if (isEnoent(error)) throw new NotFoundError(relativePath, error);
throw error;
}
}
override async preflightWrite(relativePath: string, options?: PreflightWriteOptions): Promise<void> {
const fileOp = options?.fileOp;
if (fileOp?.kind === "rem") {
enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
return;
}
if (fileOp?.kind === "move") {
enforcePlanModeWrite(this.session, relativePath, { op: "update", move: fileOp.dest });
return;
}
enforcePlanModeWrite(this.session, relativePath, { op: "update" });
}
override async delete(relativePath: string): Promise<void> {
enforcePlanModeWrite(this.session, relativePath, { op: "delete" });
const absolutePath = this.resolveAbsolute(relativePath);
try {
await deleteFileWithFallback(absolutePath);
} catch (error) {
if (isEnoent(error)) throw new NotFoundError(relativePath, error);
throw error;
}
if (this.session.enableLsp ?? true) {
await notifyWorkspaceWatchedFiles(
this.session.cwd,
[{ filePath: absolutePath, type: FileChangeType.Deleted }],
this.#signal,
);
}
invalidateFsScanAfterWrite(absolutePath);
}
override async move(fromRelative: string, toRelative: string, content?: string): Promise<void> {
enforcePlanModeWrite(this.session, fromRelative, { op: "update", move: toRelative });
const fromAbsolute = this.resolveAbsolute(fromRelative);
const toAbsolute = this.resolveAbsolute(toRelative);
if (content !== undefined) {
// The one `edit` write that does not pass through the writethrough, so it
// routes to the fallback seam directly. `patcher.ts` always supplies
// `content` for a hashline `MV`, making this the live branch. The source
// unlink is a separate primitive with its own seam, so a move out of the
// workspace and a move out of denied territory both complete.
await writeFileWithFallback(toAbsolute, content);
await deleteFileWithFallback(fromAbsolute);
} else {
await fs.rename(fromAbsolute, toAbsolute);
}
if (this.session.enableLsp ?? true) {
await notifyWorkspaceWatchedFiles(
this.session.cwd,
[
{ filePath: fromAbsolute, type: FileChangeType.Deleted },
{ filePath: toAbsolute, type: FileChangeType.Created },
],
this.#signal,
);
}
invalidateFsScanAfterWrite(fromAbsolute);
invalidateFsScanAfterWrite(toAbsolute);
}
async writeText(relativePath: string, content: string): Promise<WriteResult> {
await this.preflightWrite(relativePath);
const absolutePath = this.resolveAbsolute(relativePath);
const finalContent = await serializeEditFileText(absolutePath, relativePath, content);
// Route through ACP bridge when available; skips internal artifacts.
// `finalContent` is storage-space (e.g. a notebook's full JSON); the
// bridge may also report content that diverges from it (e.g. the
// client reformatted on save). `WriteResult.text` must stay in
// view-space — the same space `readText` returns — so a follow-up
// `readText` sees exactly what this write reports.
const bridgeResult = await routeWriteThroughBridge(
this.session,
relativePath,
absolutePath,
finalContent,
this.#signal,
);
if (bridgeResult) {
this.#diagnosticsByPath.set(relativePath, undefined);
if (!bridgeResult.driftedFromRequest) {
// No client-side transform: the view we sent is what's on disk.
return { text: content };
}
// Drifted (e.g. format-on-save): re-derive the view from what
// actually landed on disk instead of assuming `content` still
// matches. Falls back to `content` if the drifted file can't be
// re-read as a valid view (e.g. a formatter broke notebook JSON).
try {
return { text: await readEditFileText(absolutePath, relativePath) };
} catch {
return { text: content };
}
}
const diagnostics = await this.#writethrough(
absolutePath,
finalContent,
this.#signal,
Bun.file(absolutePath),
this.#batchRequest,
dst => (dst === absolutePath ? this.#beginDeferredDiagnosticsForPath(absolutePath) : undefined),
);
invalidateFsScanAfterWrite(absolutePath);
this.#diagnosticsByPath.set(relativePath, diagnostics);
return { text: content };
}
override async exists(relativePath: string): Promise<boolean> {
const absolutePath = this.resolveAbsolute(relativePath);
return Bun.file(absolutePath).exists();
}
}