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:
committed by
pullfrog[bot]
parent
b80c78bdbe
commit
9a1f3bdb0a
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user