fix(core): resolve windows symlink bypass and stabilize sandbox integration tests (#24834)

This commit is contained in:
Emily Hedlund
2026-04-08 15:00:50 -07:00
committed by GitHub
parent c7b920717f
commit af3638640c
8 changed files with 586 additions and 503 deletions
+77 -15
View File
@@ -23,6 +23,33 @@ import {
} from './environmentSanitization.js';
import type { ShellExecutionResult } from './shellExecutionService.js';
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { resolveToRealPath } from '../utils/paths.js';
/**
* A structured result of fully resolved sandbox paths.
* All paths in this object are absolute, deduplicated, and expanded to include
* both the original path and its real target (if it is a symlink).
*/
export interface ResolvedSandboxPaths {
/** The primary workspace directory. */
workspace: {
/** The original path provided in the sandbox options. */
original: string;
/** The real path. */
resolved: string;
};
/** Explicitly denied paths. */
forbidden: string[];
/** Directories included globally across all commands in this sandbox session. */
globalIncludes: string[];
/** Paths explicitly allowed by the policy of the currently executing command. */
policyAllowed: string[];
/** Paths granted temporary read access by the current command's dynamic permissions. */
policyRead: string[];
/** Paths granted temporary write access by the current command's dynamic permissions. */
policyWrite: string[];
}
export interface SandboxPermissions {
/** Filesystem permissions. */
fileSystem?: {
@@ -326,33 +353,68 @@ export class LocalSandboxManager implements SandboxManager {
}
/**
* Resolves sanitized allowed and forbidden paths for a request.
* Filters the workspace from allowed paths and ensures forbidden paths take precedence.
* Resolves and sanitizes all path categories for a sandbox request.
*/
export async function resolveSandboxPaths(
options: GlobalSandboxOptions,
req: SandboxRequest,
): Promise<{
allowed: string[];
forbidden: string[];
}> {
const forbidden = sanitizePaths(await options.forbiddenPaths?.());
const allowed = sanitizePaths(req.policy?.allowedPaths);
overridePermissions?: SandboxPermissions,
): Promise<ResolvedSandboxPaths> {
/**
* Helper that expands each path to include its realpath (if it's a symlink)
* and pipes the result through sanitizePaths for deduplication and absolute path enforcement.
*/
const expand = (paths?: string[] | null): string[] => {
if (!paths || paths.length === 0) return [];
const expanded = paths.flatMap((p) => {
try {
const resolved = resolveToRealPath(p);
return resolved === p ? [p] : [p, resolved];
} catch {
return [p];
}
});
return sanitizePaths(expanded);
};
const workspaceIdentity = getPathIdentity(options.workspace);
const forbidden = expand(await options.forbiddenPaths?.());
const globalIncludes = expand(options.includeDirectories);
const policyAllowed = expand(req.policy?.allowedPaths);
const policyRead = expand(overridePermissions?.fileSystem?.read);
const policyWrite = expand(overridePermissions?.fileSystem?.write);
const resolvedWorkspace = resolveToRealPath(options.workspace);
const workspaceIdentities = new Set(
[options.workspace, resolvedWorkspace].map(getPathIdentity),
);
const forbiddenIdentities = new Set(forbidden.map(getPathIdentity));
const filteredAllowed = allowed.filter((p) => {
const identity = getPathIdentity(p);
return identity !== workspaceIdentity && !forbiddenIdentities.has(identity);
});
/**
* Filters out any paths that are explicitly forbidden or match the workspace root (original or resolved).
*/
const filter = (paths: string[]) =>
paths.filter((p) => {
const identity = getPathIdentity(p);
return (
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
);
});
return {
allowed: filteredAllowed,
workspace: {
original: options.workspace,
resolved: resolvedWorkspace,
},
forbidden,
globalIncludes: filter(globalIncludes),
policyAllowed: filter(policyAllowed),
policyRead: filter(policyRead),
policyWrite: filter(policyWrite),
};
}
/**
* Sanitizes an array of paths by deduplicating them and ensuring they are absolute.
* Always returns an array (empty if input is null/undefined).