src / pathSecurity.ts
src / pathSecurity.ts
/**
* Centralized path security helpers.
* All file tools should go through validatePath / validatePathAsync.
*/
import { resolve, relative, isAbsolute, normalize } from "path";
import { realpath } from "fs/promises";
export class PathSecurityError extends Error {
constructor(message: string) {
super(message);
this.name = "PathSecurityError";
}
}
/**
* Master switch: when true, ALL path sandboxing is disabled.
* Set via setUnrestrictedMode() from toolsProvider based on the
* `general.unrestrictedMode` plugin setting. When ON, validatePath /
* validatePathAsync / assert helpers resolve paths freely (any absolute
* path, any drive, including outside the workspace).
*
* Null-byte / control-char rejection is ALWAYS kept (not a sandbox, just hygiene).
*/
let unrestrictedMode = false;
export function setUnrestrictedMode(enabled: boolean): void {
unrestrictedMode = !!enabled;
if (unrestrictedMode) {
console.log("[pathSecurity] ⚠️ UNRESTRICTED MODE ON — workspace sandbox disabled, any path allowed.");
}
}
export function isUnrestrictedMode(): boolean {
return unrestrictedMode;
}
/** Resolve freely without any workspace boundary (still rejects control chars). */
function resolveUnrestricted(cwd: string, requestedPath: string): string {
const base = normalize(resolve(cwd || process.cwd()));
return normalize(
isAbsolute(requestedPath) || /^[A-Za-z]:[\\/]/.test(requestedPath)
? resolve(requestedPath)
: resolve(base, requestedPath),
);
}
/**
* Resolve a user-supplied path against the current working directory and
* guarantee it cannot escape workspaceRoot (including via .. or drive letters).
*
* @param workspaceRoot Security boundary (absolute)
* @param cwd Current working directory (must be inside workspaceRoot)
* @param requestedPath User-supplied relative or absolute path
*/
export function validatePath(
workspaceRoot: string,
cwd: string,
requestedPath: string,
): string {
if (!requestedPath || typeof requestedPath !== "string") {
throw new PathSecurityError("Path must be a non-empty string.");
}
if (/[\0\r\n]/.test(requestedPath)) {
throw new PathSecurityError("Path contains illegal control characters.");
}
// ⚠️ Master switch: no sandbox at all — any absolute path, any drive.
if (unrestrictedMode) {
return resolveUnrestricted(cwd || workspaceRoot, requestedPath);
}
const root = normalize(resolve(workspaceRoot));
const base = normalize(resolve(cwd));
const resolved = normalize(
isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(base, requestedPath),
);
const rel = relative(root, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new PathSecurityError(
`Access Denied: Path '${requestedPath}' is outside the workspace.`,
);
}
if (process.platform === "win32") {
const rootDrive = root.slice(0, 2).toUpperCase();
const resolvedDrive = resolved.slice(0, 2).toUpperCase();
if (
/^[A-Z]:$/.test(rootDrive) &&
/^[A-Z]:$/.test(resolvedDrive) &&
rootDrive !== resolvedDrive
) {
throw new PathSecurityError(
`Access Denied: Path '${requestedPath}' is on a different drive.`,
);
}
}
return resolved;
}
/**
* Async version that also resolves symlinks when the path exists.
*/
export async function validatePathAsync(
workspaceRoot: string,
cwd: string,
requestedPath: string,
): Promise<string> {
// ⚠️ Master switch: skip symlink-root check too.
if (unrestrictedMode) {
if (!requestedPath || typeof requestedPath !== "string") {
throw new PathSecurityError("Path must be a non-empty string.");
}
if (/[\0\r\n]/.test(requestedPath)) {
throw new PathSecurityError("Path contains illegal control characters.");
}
const free = resolveUnrestricted(cwd || workspaceRoot, requestedPath);
try {
return await realpath(free).catch(() => free);
} catch {
return free;
}
}
const resolved = validatePath(workspaceRoot, cwd, requestedPath);
const root = normalize(resolve(workspaceRoot));
try {
const realRoot = await realpath(root);
const realCandidate = await realpath(resolved).catch(() => resolved);
const rel = relative(realRoot, realCandidate);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new PathSecurityError(
`Access Denied: Path '${requestedPath}' resolves outside the workspace (symlink).`,
);
}
return realCandidate;
} catch (e) {
if (e instanceof PathSecurityError) throw e;
return resolved;
}
}
/** Soft boolean check. */
export function isPathInsideWorkspace(
workspaceRoot: string,
candidatePath: string,
): boolean {
if (unrestrictedMode) return true;
try {
const root = normalize(resolve(workspaceRoot));
const resolved = normalize(resolve(candidatePath));
const rel = relative(root, resolved);
return !rel.startsWith("..") && !isAbsolute(rel);
} catch {
return false;
}
}
/**
* Reject absolute paths for write tools that should only accept relative names.
* Still validate relative paths with validatePath afterwards.
* No-op when unrestricted mode is ON.
*/
export function rejectAbsolutePath(pathValue: string, toolName: string): void {
if (unrestrictedMode) return;
if (isAbsolute(pathValue) || /^[A-Za-z]:[\\/]/.test(pathValue)) {
throw new PathSecurityError(
`Tool '${toolName}' rejected absolute path: '${pathValue}'. ` +
`SECURITY: Use a relative path within the workspace.`,
);
}
}
export function sanitizePathForLog(fullPath: string, workspaceRoot: string): string {
try {
return relative(workspaceRoot, fullPath) || ".";
} catch {
return "[path]";
}
}
/**
* Backward-compatible wrapper matching the old signature
* validatePath(baseDir, requestedPath) used throughout toolsProvider.
* Treats baseDir as both cwd and workspace root (safe default for gradual migration).
*/
export function validatePathLegacy(baseDir: string, requestedPath: string): string {
return validatePath(baseDir, baseDir, requestedPath);
}
/**
* Centralized path security helpers.
* All file tools should go through validatePath / validatePathAsync.
*/
import { resolve, relative, isAbsolute, normalize } from "path";
import { realpath } from "fs/promises";
export class PathSecurityError extends Error {
constructor(message: string) {
super(message);
this.name = "PathSecurityError";
}
}
/**
* Master switch: when true, ALL path sandboxing is disabled.
* Set via setUnrestrictedMode() from toolsProvider based on the
* `general.unrestrictedMode` plugin setting. When ON, validatePath /
* validatePathAsync / assert helpers resolve paths freely (any absolute
* path, any drive, including outside the workspace).
*
* Null-byte / control-char rejection is ALWAYS kept (not a sandbox, just hygiene).
*/
let unrestrictedMode = false;
export function setUnrestrictedMode(enabled: boolean): void {
unrestrictedMode = !!enabled;
if (unrestrictedMode) {
console.log("[pathSecurity] ⚠️ UNRESTRICTED MODE ON — workspace sandbox disabled, any path allowed.");
}
}
export function isUnrestrictedMode(): boolean {
return unrestrictedMode;
}
/** Resolve freely without any workspace boundary (still rejects control chars). */
function resolveUnrestricted(cwd: string, requestedPath: string): string {
const base = normalize(resolve(cwd || process.cwd()));
return normalize(
isAbsolute(requestedPath) || /^[A-Za-z]:[\\/]/.test(requestedPath)
? resolve(requestedPath)
: resolve(base, requestedPath),
);
}
/**
* Resolve a user-supplied path against the current working directory and
* guarantee it cannot escape workspaceRoot (including via .. or drive letters).
*
* @param workspaceRoot Security boundary (absolute)
* @param cwd Current working directory (must be inside workspaceRoot)
* @param requestedPath User-supplied relative or absolute path
*/
export function validatePath(
workspaceRoot: string,
cwd: string,
requestedPath: string,
): string {
if (!requestedPath || typeof requestedPath !== "string") {
throw new PathSecurityError("Path must be a non-empty string.");
}
if (/[\0\r\n]/.test(requestedPath)) {
throw new PathSecurityError("Path contains illegal control characters.");
}
// ⚠️ Master switch: no sandbox at all — any absolute path, any drive.
if (unrestrictedMode) {
return resolveUnrestricted(cwd || workspaceRoot, requestedPath);
}
const root = normalize(resolve(workspaceRoot));
const base = normalize(resolve(cwd));
const resolved = normalize(
isAbsolute(requestedPath) ? resolve(requestedPath) : resolve(base, requestedPath),
);
const rel = relative(root, resolved);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new PathSecurityError(
`Access Denied: Path '${requestedPath}' is outside the workspace.`,
);
}
if (process.platform === "win32") {
const rootDrive = root.slice(0, 2).toUpperCase();
const resolvedDrive = resolved.slice(0, 2).toUpperCase();
if (
/^[A-Z]:$/.test(rootDrive) &&
/^[A-Z]:$/.test(resolvedDrive) &&
rootDrive !== resolvedDrive
) {
throw new PathSecurityError(
`Access Denied: Path '${requestedPath}' is on a different drive.`,
);
}
}
return resolved;
}
/**
* Async version that also resolves symlinks when the path exists.
*/
export async function validatePathAsync(
workspaceRoot: string,
cwd: string,
requestedPath: string,
): Promise<string> {
// ⚠️ Master switch: skip symlink-root check too.
if (unrestrictedMode) {
if (!requestedPath || typeof requestedPath !== "string") {
throw new PathSecurityError("Path must be a non-empty string.");
}
if (/[\0\r\n]/.test(requestedPath)) {
throw new PathSecurityError("Path contains illegal control characters.");
}
const free = resolveUnrestricted(cwd || workspaceRoot, requestedPath);
try {
return await realpath(free).catch(() => free);
} catch {
return free;
}
}
const resolved = validatePath(workspaceRoot, cwd, requestedPath);
const root = normalize(resolve(workspaceRoot));
try {
const realRoot = await realpath(root);
const realCandidate = await realpath(resolved).catch(() => resolved);
const rel = relative(realRoot, realCandidate);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new PathSecurityError(
`Access Denied: Path '${requestedPath}' resolves outside the workspace (symlink).`,
);
}
return realCandidate;
} catch (e) {
if (e instanceof PathSecurityError) throw e;
return resolved;
}
}
/** Soft boolean check. */
export function isPathInsideWorkspace(
workspaceRoot: string,
candidatePath: string,
): boolean {
if (unrestrictedMode) return true;
try {
const root = normalize(resolve(workspaceRoot));
const resolved = normalize(resolve(candidatePath));
const rel = relative(root, resolved);
return !rel.startsWith("..") && !isAbsolute(rel);
} catch {
return false;
}
}
/**
* Reject absolute paths for write tools that should only accept relative names.
* Still validate relative paths with validatePath afterwards.
* No-op when unrestricted mode is ON.
*/
export function rejectAbsolutePath(pathValue: string, toolName: string): void {
if (unrestrictedMode) return;
if (isAbsolute(pathValue) || /^[A-Za-z]:[\\/]/.test(pathValue)) {
throw new PathSecurityError(
`Tool '${toolName}' rejected absolute path: '${pathValue}'. ` +
`SECURITY: Use a relative path within the workspace.`,
);
}
}
export function sanitizePathForLog(fullPath: string, workspaceRoot: string): string {
try {
return relative(workspaceRoot, fullPath) || ".";
} catch {
return "[path]";
}
}
/**
* Backward-compatible wrapper matching the old signature
* validatePath(baseDir, requestedPath) used throughout toolsProvider.
* Treats baseDir as both cwd and workspace root (safe default for gradual migration).
*/
export function validatePathLegacy(baseDir: string, requestedPath: string): string {
return validatePath(baseDir, baseDir, requestedPath);
}