fix(security): add optional workspace-only path guards for fs tools

This commit is contained in:
Peter Steinberger
2026-02-14 23:50:04 +01:00
parent 55a25f9875
commit 5e7c3250cb
14 changed files with 201 additions and 25 deletions

View File

@@ -1,10 +1,11 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { SandboxFsBridge } from "./sandbox/fs-bridge.js";
import { applyUpdateHunk } from "./apply-patch-update.js";
import { assertSandboxPath } from "./sandbox-paths.js";
import { assertSandboxPath, resolveSandboxPath } from "./sandbox-paths.js";
const BEGIN_PATCH_MARKER = "*** Begin Patch";
const END_PATCH_MARKER = "*** End Patch";
@@ -66,6 +67,8 @@ type SandboxApplyPatchConfig = {
type ApplyPatchOptions = {
cwd: string;
sandbox?: SandboxApplyPatchConfig;
/** When true, restrict patch paths to the workspace root (cwd). Default: false. */
workspaceOnly?: boolean;
signal?: AbortSignal;
};
@@ -76,10 +79,11 @@ const applyPatchSchema = Type.Object({
});
export function createApplyPatchTool(
options: { cwd?: string; sandbox?: SandboxApplyPatchConfig } = {},
options: { cwd?: string; sandbox?: SandboxApplyPatchConfig; workspaceOnly?: boolean } = {},
): AgentTool<typeof applyPatchSchema, ApplyPatchToolDetails> {
const cwd = options.cwd ?? process.cwd();
const sandbox = options.sandbox;
const workspaceOnly = options.workspaceOnly === true;
return {
name: "apply_patch",
@@ -102,6 +106,7 @@ export function createApplyPatchTool(
const result = await applyPatch(input, {
cwd,
sandbox,
workspaceOnly,
signal,
});
@@ -150,7 +155,7 @@ export async function applyPatch(
}
if (hunk.kind === "delete") {
const target = await resolvePatchPath(hunk.path, options);
const target = await resolvePatchPath(hunk.path, options, "unlink");
await fileOps.remove(target.resolved);
recordSummary(summary, seen, "deleted", target.display);
continue;
@@ -249,6 +254,7 @@ async function ensureDir(filePath: string, ops: PatchFileOps) {
async function resolvePatchPath(
filePath: string,
options: ApplyPatchOptions,
purpose: "readWrite" | "unlink" = "readWrite",
): Promise<{ resolved: string; display: string }> {
if (options.sandbox) {
const resolved = options.sandbox.bridge.resolvePath({
@@ -261,17 +267,48 @@ async function resolvePatchPath(
};
}
const resolved = await assertSandboxPath({
filePath,
cwd: options.cwd,
root: options.cwd,
});
const resolved = options.workspaceOnly
? purpose === "unlink"
? resolveSandboxPath({ filePath, cwd: options.cwd, root: options.cwd }).resolved
: (
await assertSandboxPath({
filePath,
cwd: options.cwd,
root: options.cwd,
})
).resolved
: resolvePathFromCwd(filePath, options.cwd);
return {
resolved: resolved.resolved,
display: toDisplayPath(resolved.resolved, options.cwd),
resolved,
display: toDisplayPath(resolved, options.cwd),
};
}
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
function normalizeUnicodeSpaces(value: string): string {
return value.replace(UNICODE_SPACES, " ");
}
function expandPath(filePath: string): string {
const normalized = normalizeUnicodeSpaces(filePath);
if (normalized === "~") {
return os.homedir();
}
if (normalized.startsWith("~/")) {
return os.homedir() + normalized.slice(1);
}
return normalized;
}
function resolvePathFromCwd(filePath: string, cwd: string): string {
const expanded = expandPath(filePath);
if (path.isAbsolute(expanded)) {
return path.normalize(expanded);
}
return path.resolve(cwd, expanded);
}
function toDisplayPath(resolved: string, cwd: string): string {
const relative = path.relative(cwd, resolved);
if (!relative || relative === "") {