relax filesystem permissions: reads allow temp dir, writes conditional on bash (#308)

reads (file_read, list_directory) now allow paths within the repo OR
PULLFROG_TEMP_DIR. this fixes the bug where agents with full permissions
couldn't read PR diffs, CI logs, review threads, or background bash
output because those files live under /tmp/pullfrog-xxx/.

writes (file_write, file_edit, file_delete) now only enforce repo-scoping
when bash !== "enabled". when bash=enabled the agent can write anywhere
via native bash, so restricting file_write was security theater. .git/
stays blocked in all modes as defense-in-depth.

replaced the conflated resolveAndValidatePath/validateWritePath helpers
with separate resolveReadPath and resolveWritePath functions that cleanly
separate path resolution from permission enforcement.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Colin McDonnell
2026-02-14 04:02:45 +00:00
committed by pullfrog[bot]
parent b80c78bdbe
commit 9a1f3bdb0a
2 changed files with 158 additions and 135 deletions
+55 -47
View File
@@ -142782,46 +142782,63 @@ var FileDeleteParams = type({
var ListDirectoryParams = type({
path: "string"
});
function resolveAndValidatePath(filePath) {
var GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
function resolveReadPath(filePath) {
const cwd = realpathSync2(process.cwd());
const resolved = resolve(cwd, filePath);
const tempDir = process.env.PULLFROG_TEMP_DIR;
if (tempDir && (resolved === tempDir || resolved.startsWith(tempDir + "/"))) {
return resolved;
}
if (existsSync4(resolved)) {
const real = realpathSync2(resolved);
if (real !== cwd && !real.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
if (real === cwd || real.startsWith(cwd + "/")) {
return real;
}
return real;
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
let ancestor = dirname(resolved);
while (!existsSync4(ancestor)) {
const parent = dirname(ancestor);
if (parent === ancestor) break;
ancestor = parent;
if (resolved === cwd || resolved.startsWith(cwd + "/")) {
return resolved;
}
if (existsSync4(ancestor)) {
const realAncestor = realpathSync2(ancestor);
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
}
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository: ${filePath}`);
}
return resolved;
throw new Error(`path must be within the repository or temp directory: ${filePath}`);
}
var GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
function validateWritePath(params) {
const resolved = resolveAndValidatePath(params.filePath);
function resolveWritePath(filePath, bashPermission) {
const cwd = realpathSync2(process.cwd());
const relative = resolved.slice(cwd.length + 1);
if (relative === ".git" || relative.startsWith(".git/")) {
throw new Error(`writing to .git is not allowed: ${params.filePath}`);
const resolved = resolve(cwd, filePath);
if (bashPermission !== "enabled") {
if (existsSync4(resolved)) {
const real = realpathSync2(resolved);
if (real !== cwd && !real.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
}
} else {
let ancestor = dirname(resolved);
while (!existsSync4(ancestor)) {
const parent = dirname(ancestor);
if (parent === ancestor) break;
ancestor = parent;
}
if (existsSync4(ancestor)) {
const realAncestor = realpathSync2(ancestor);
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
throw new Error(
`path must be within the repository (symlink escape blocked): ${filePath}`
);
}
}
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
throw new Error(`path must be within the repository: ${filePath}`);
}
}
}
if (params.bashPermission === "disabled") {
const basename2 = relative.split("/").pop() || "";
if (resolved.includes("/.git/") || resolved.endsWith("/.git")) {
throw new Error(`writing to .git is not allowed: ${filePath}`);
}
if (bashPermission === "disabled") {
const basename2 = resolved.split("/").pop() || "";
if (GIT_INTERPRETED_FILES.includes(basename2)) {
throw new Error(
`writing to ${basename2} is not allowed when bash is ${params.bashPermission} (can trigger code execution via git filter drivers): ${params.filePath}`
`writing to ${basename2} is not allowed when bash is ${bashPermission} (can trigger code execution via git filter drivers): ${filePath}`
);
}
}
@@ -142830,10 +142847,10 @@ function validateWritePath(params) {
function FileReadTool(_ctx) {
return tool({
name: "file_read",
description: `Read a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed.`,
description: "Read a file. Path is relative to the repository root, or an absolute path to read tool result files (diffs, CI logs, etc.) from the temp directory.",
parameters: FileReadParams,
execute: execute(async (params) => {
const resolved = resolveAndValidatePath(params.path);
const resolved = resolveReadPath(params.path);
const raw = readFileSync3(resolved, "utf-8");
const lines = raw.split("\n");
const offset = params.offset;
@@ -142852,13 +142869,10 @@ function FileReadTool(_ctx) {
function FileWriteTool(ctx) {
return tool({
name: "file_write",
description: `Write content to a file in the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Writes to .git/ are blocked. Creates parent directories if needed.`,
description: "Write content to a file. Path is relative to the repository root. Writes to .git/ are blocked. Creates parent directories if needed.",
parameters: FileWriteParams,
execute: execute(async (params) => {
const resolved = validateWritePath({
filePath: params.path,
bashPermission: ctx.payload.bash
});
const resolved = resolveWritePath(params.path, ctx.payload.bash);
const dir = dirname(resolved);
mkdirSync2(dir, { recursive: true });
writeFileSync5(resolved, params.content, "utf-8");
@@ -142869,7 +142883,7 @@ function FileWriteTool(ctx) {
function FileEditTool(ctx) {
return tool({
name: "file_edit",
description: `Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence \u2014 set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.`,
description: "Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence \u2014 set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.",
parameters: FileEditParams,
execute: execute(async (params) => {
if (params.old_string.length === 0) {
@@ -142878,10 +142892,7 @@ function FileEditTool(ctx) {
if (params.old_string === params.new_string) {
throw new Error("old_string and new_string are identical");
}
const resolved = validateWritePath({
filePath: params.path,
bashPermission: ctx.payload.bash
});
const resolved = resolveWritePath(params.path, ctx.payload.bash);
const content = readFileSync3(resolved, "utf-8");
const count = content.split(params.old_string).length - 1;
if (count === 0) {
@@ -142901,13 +142912,10 @@ function FileEditTool(ctx) {
function FileDeleteTool(ctx) {
return tool({
name: "file_delete",
description: `Delete a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Deletes to .git/ are blocked. Cannot delete directories.`,
description: "Delete a file. Path is relative to the repository root. Deletes to .git/ are blocked. Cannot delete directories.",
parameters: FileDeleteParams,
execute: execute(async (params) => {
const resolved = validateWritePath({
filePath: params.path,
bashPermission: ctx.payload.bash
});
const resolved = resolveWritePath(params.path, ctx.payload.bash);
unlinkSync(resolved);
return { path: params.path, deleted: true };
})
@@ -142916,10 +142924,10 @@ function FileDeleteTool(ctx) {
function ListDirectoryTool(_ctx) {
return tool({
name: "list_directory",
description: `List files and directories. Path is relative to the repository root. Only paths within the current repository are allowed. Returns entries sorted with directories first, then alphabetically.`,
description: "List files and directories. Path is relative to the repository root, or an absolute path to list tool result files from the temp directory. Returns entries sorted with directories first, then alphabetically.",
parameters: ListDirectoryParams,
execute: execute(async (params) => {
const resolved = resolveAndValidatePath(params.path);
const resolved = resolveReadPath(params.path);
const entries = readdirSync(resolved, { withFileTypes: true });
const sorted = entries.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;