Files
oh-my-pi/packages/coding-agent/src/tools/plan-mode-guard.ts
T
can1357 ee4df538f0 fix(tools): resolved search-path parsing and local sandbox checks
- Set `FindTool` to disable recursive glob traversal so `dir/*` stays shallow.
- Added `parseSearchPathPreferringLiteral` to prefer literal paths like `apps/[id]/page.tsx` when they exist.
- Updated `resolveToolSearchScope` to reject external URLs with a clear `read` usage error.
- Expanded plan-mode sandbox checks to allow absolute paths inside the local artifact root.
2026-06-08 02:04:59 +02:00

109 lines
4.0 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import { resolveLocalRoot, resolveLocalUrlToPath, resolveVaultUrlToPath } from "../internal-urls";
import type { ToolSession } from ".";
import { normalizeLocalScheme, resolveToCwd } from "./path-utils";
import { ToolError } from "./tool-errors";
const VAULT_SCHEME_PREFIX = "vault:";
const LOCAL_SCHEME_PREFIX = "local:";
/** Resolve the absolute path of the session's `local://` artifact sandbox.
* Returns `null` when the session has no artifact wiring (e.g. tests). */
function localSandboxRoot(session: ToolSession): string | null {
try {
return path.resolve(
resolveLocalRoot({
getArtifactsDir: session.getArtifactsDir,
getSessionId: session.getSessionId,
}),
);
} catch {
return null;
}
}
/** True when `absolutePath` resolves inside `root` (== root or under it). */
function isWithinRoot(absolutePath: string, root: string): boolean {
if (absolutePath === root) return true;
const sep = `${root}${path.sep}`;
return absolutePath.startsWith(sep);
}
/** True when `targetPath` addresses the session-local artifact sandbox.
* Accepts both `local://…` URLs and absolute paths pointing inside the
* resolved sandbox root — the latter is what `read local://…` echoes back
* in the `[path#tag]` header. Those files are not part of the working tree,
* so plan mode treats them as freely writable scratch/plan space. */
function targetsLocalSandbox(session: ToolSession, targetPath: string): boolean {
const normalized = normalizeLocalScheme(targetPath);
if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) return true;
if (!path.isAbsolute(normalized)) return false;
const root = localSandboxRoot(session);
if (!root) return false;
// Compare both raw and realpath-normalized forms so that
// `/tmp/…` vs `/private/tmp/…` (macOS) and other symlink-collapsed
// roots both resolve to the same sandbox identity.
const resolved = path.resolve(normalized);
if (isWithinRoot(resolved, root)) return true;
try {
const realRoot = fs.realpathSync.native(root);
if (isWithinRoot(resolved, realRoot)) return true;
// `resolved` itself may live in `/tmp/...` while `realRoot` is `/private/tmp/...`;
// realpath the parent dir of `resolved` so we catch that direction too.
const realParent = fs.realpathSync.native(path.dirname(resolved));
return isWithinRoot(path.join(realParent, path.basename(resolved)), realRoot);
} catch {
return false;
}
}
/**
* Resolve a write/edit target to its absolute filesystem path, honoring the
* `local://` and `vault://` schemes. Plain paths resolve against the session cwd.
*/
export function resolvePlanPath(session: ToolSession, targetPath: string): string {
const normalized = normalizeLocalScheme(targetPath);
if (normalized.startsWith(LOCAL_SCHEME_PREFIX)) {
return resolveLocalUrlToPath(normalized, {
getArtifactsDir: session.getArtifactsDir,
getSessionId: session.getSessionId,
});
}
if (normalized.startsWith(VAULT_SCHEME_PREFIX)) {
return resolveVaultUrlToPath(normalized);
}
return resolveToCwd(normalized, session.cwd);
}
/**
* Plan mode keeps the working tree read-only while letting the agent draft its
* plan. Writes and edits to the `local://` artifact sandbox are allowed (that is
* where the plan and any scratch notes live); anything that would touch the
* working tree — or rename/delete a file — is rejected.
*/
export function enforcePlanModeWrite(
session: ToolSession,
targetPath: string,
options?: { move?: string; op?: "create" | "update" | "delete" },
): void {
const state = session.getPlanModeState?.();
if (!state?.enabled) return;
if (options?.move) {
throw new ToolError("Plan mode: renaming files is not allowed.");
}
if (options?.op === "delete") {
throw new ToolError("Plan mode: deleting files is not allowed.");
}
if (targetsLocalSandbox(session, targetPath)) return;
throw new ToolError(
"Plan mode: the working tree is read-only. Write your plan to a local://<slug>-plan.md file instead.",
);
}