Files
stack/apps/gateway/src/agent/tools/path-guard.ts
T
marcieandorch-01 736b0affc1
ci/woodpecker/push/publish Pipeline was successful
compose: add wrapper-first dogfood workspace (#1488)
Co-authored-by: marcie <[email protected]>
2026-08-30 22:29:30 +00:00

75 lines
2.6 KiB
TypeScript

import path from 'node:path';
import fs from 'node:fs';
function isContained(candidate: string, root: string): boolean {
return candidate === root || candidate.startsWith(root + path.sep);
}
function assertLexicalContainment(userPath: string, sandboxDir: string): string {
const resolved = path.resolve(sandboxDir, userPath);
const sandboxAbsolute = path.resolve(sandboxDir);
if (!isContained(resolved, sandboxAbsolute)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolved);
}
return resolved;
}
/**
* Resolve an existing path and verify both its lexical path and real symlink
* target remain inside the sandbox.
*/
export function guardPath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
const resolvedReal = fs.realpathSync.native(resolved);
if (!isContained(resolvedReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, resolvedReal);
}
return resolvedReal;
}
/**
* Resolve a writable file path whose parent already exists. Existing targets
* are resolved fully. New targets use the real parent directory, which blocks
* writes through a parent symlink that leaves the sandbox.
*/
export function guardWritePath(userPath: string, sandboxDir: string): string {
const resolved = assertLexicalContainment(userPath, sandboxDir);
const sandboxReal = fs.realpathSync.native(sandboxDir);
let writableReal: string;
try {
writableReal = fs.realpathSync.native(resolved);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT') throw error;
const parentReal = fs.realpathSync.native(path.dirname(resolved));
writableReal = path.join(parentReal, path.basename(resolved));
}
if (!isContained(writableReal, sandboxReal)) {
throw new SandboxEscapeError(userPath, sandboxDir, writableReal);
}
return writableReal;
}
/**
* Lexical-only validation for non-filesystem pathspecs such as `git diff --`
* targets, where the path may name a deleted file and Git does not dereference
* a tracked symlink.
*/
export function guardPathUnsafe(userPath: string, sandboxDir: string): string {
return assertLexicalContainment(userPath, sandboxDir);
}
export class SandboxEscapeError extends Error {
constructor(
public readonly userPath: string,
public readonly sandboxDir: string,
public readonly resolvedPath: string,
) {
super(
`Path escape attempt blocked: "${userPath}" resolves to "${resolvedPath}" which is outside sandbox "${sandboxDir}"`,
);
this.name = 'SandboxEscapeError';
}
}