The ACP fs bridge's worktree guard used an unbounded `startsWith(resolve( worktreePath))`, so a sibling path sharing the worktree as a string prefix (`<worktree>-evil/...`) escaped the scope. Since writeWorktreeTextFile hits disk directly (no pending_changes gate), a confused/buggy ACP agent could write outside its worktree. Now uses a separator-bounded check matching write_guard.ts (resolve() + `startsWith(root + sep)` / `=== root`) via a shared resolveInWorktree, with a regression test (../ traversal + the sibling-prefix bug). Symlink-swap hardening intentionally skipped — consistent with write_guard's no-realpath stance; the agent runs with host FS access so this is a containment guard, not a trust boundary. Flagged by the automated push security review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
50 lines
2.0 KiB
TypeScript
50 lines
2.0 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
|
import { dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
|
|
/**
|
|
* Resolve an ACP-supplied path against the agent worktree and reject anything
|
|
* that escapes it. Mirrors `write_guard.ts`'s check: `resolve()` to normalize
|
|
* `../` segments, then a **separator-bounded** prefix test — a bare
|
|
* `startsWith(root)` wrongly admits a sibling dir like `<root>-evil/...`.
|
|
*
|
|
* No realpath (consistent with `write_guard.ts`: the target may not exist yet on
|
|
* write). This is a containment guard for the ACP fs bridge, not a hard trust
|
|
* boundary — the agent process already runs with host FS access; symlink-swap
|
|
* hardening (`O_NOFOLLOW`/realpath) is out of scope here.
|
|
*/
|
|
function resolveInWorktree(worktreePath: string, filePath: string): string {
|
|
const root = resolve(worktreePath);
|
|
const absolute = isAbsolute(filePath) ? resolve(filePath) : resolve(root, filePath);
|
|
if (absolute !== root && !absolute.startsWith(root + sep)) {
|
|
throw new Error(`path escapes worktree: ${filePath}`);
|
|
}
|
|
return absolute;
|
|
}
|
|
|
|
/** Resolve an ACP path against the agent worktree and read a slice of lines. */
|
|
export async function readWorktreeTextFile(
|
|
worktreePath: string,
|
|
filePath: string,
|
|
line?: number | null,
|
|
limit?: number | null,
|
|
): Promise<string> {
|
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
|
const raw = await fs.readFile(absolute, 'utf8');
|
|
if (!line && !limit) return raw;
|
|
const lines = raw.split(/\r?\n/);
|
|
const start = Math.max((line ?? 1) - 1, 0);
|
|
const end = limit ? start + limit : undefined;
|
|
return lines.slice(start, end).join('\n');
|
|
}
|
|
|
|
/** Write a file inside the worktree (creates parent dirs). */
|
|
export async function writeWorktreeTextFile(
|
|
worktreePath: string,
|
|
filePath: string,
|
|
content: string,
|
|
): Promise<void> {
|
|
const absolute = resolveInWorktree(worktreePath, filePath);
|
|
await fs.mkdir(dirname(absolute), { recursive: true });
|
|
await fs.writeFile(absolute, content, 'utf8');
|
|
}
|