fix(agents): block workspaceOnly apply_patch delete symlink escape

This commit is contained in:
Peter Steinberger
2026-02-15 03:23:16 +01:00
parent 683aa09b55
commit 914b9d1e79
4 changed files with 52 additions and 8 deletions

View File

@@ -50,9 +50,16 @@ export function resolveSandboxPath(params: { filePath: string; cwd: string; root
return { resolved, relative };
}
export async function assertSandboxPath(params: { filePath: string; cwd: string; root: string }) {
export async function assertSandboxPath(params: {
filePath: string;
cwd: string;
root: string;
allowFinalSymlink?: boolean;
}) {
const resolved = resolveSandboxPath(params);
await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root));
await assertNoSymlinkEscape(resolved.relative, path.resolve(params.root), {
allowFinalSymlink: params.allowFinalSymlink,
});
return resolved;
}
@@ -90,18 +97,29 @@ export async function resolveSandboxedMediaSource(params: {
return resolved.resolved;
}
async function assertNoSymlinkEscape(relative: string, root: string) {
async function assertNoSymlinkEscape(
relative: string,
root: string,
options?: { allowFinalSymlink?: boolean },
) {
if (!relative) {
return;
}
const rootReal = await tryRealpath(root);
const parts = relative.split(path.sep).filter(Boolean);
let current = root;
for (const part of parts) {
for (let idx = 0; idx < parts.length; idx += 1) {
const part = parts[idx];
const isLast = idx === parts.length - 1;
current = path.join(current, part);
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
// Unlinking a symlink itself is safe even if it points outside the root. What we
// must prevent is traversing through a symlink to reach targets outside root.
if (options?.allowFinalSymlink && isLast) {
return;
}
const target = await tryRealpath(current);
if (!isPathInside(rootReal, target)) {
throw new Error(