refactor(core): extract and centralize sandbox path utilities (#25305)

Co-authored-by: David Pierce <davidapierce@google.com>
This commit is contained in:
Emily Hedlund
2026-04-13 11:43:13 -07:00
committed by GitHub
parent b91d177bde
commit 0d6d5d90b9
6 changed files with 121 additions and 116 deletions
@@ -10,11 +10,9 @@ import fsPromises from 'node:fs/promises';
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
import {
NoopSandboxManager,
sanitizePaths,
findSecretFiles,
isSecretFile,
resolveSandboxPaths,
getPathIdentity,
type SandboxRequest,
} from './sandboxManager.js';
import { createSandboxManager } from './sandboxManagerFactory.js';
@@ -139,64 +137,6 @@ describe('findSecretFiles', () => {
describe('SandboxManager', () => {
afterEach(() => vi.restoreAllMocks());
describe('sanitizePaths', () => {
it('should return an empty array if no paths are provided', () => {
expect(sanitizePaths(undefined)).toEqual([]);
expect(sanitizePaths(null)).toEqual([]);
expect(sanitizePaths([])).toEqual([]);
});
it('should deduplicate paths and return them', () => {
const paths = ['/workspace/foo', '/workspace/bar', '/workspace/foo'];
expect(sanitizePaths(paths)).toEqual([
'/workspace/foo',
'/workspace/bar',
]);
});
it('should deduplicate case-insensitively on Windows and macOS', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const paths = ['/workspace/foo', '/WORKSPACE/FOO'];
expect(sanitizePaths(paths)).toEqual(['/workspace/foo']);
vi.spyOn(os, 'platform').mockReturnValue('darwin');
const macPaths = ['/tmp/foo', '/tmp/FOO'];
expect(sanitizePaths(macPaths)).toEqual(['/tmp/foo']);
vi.spyOn(os, 'platform').mockReturnValue('linux');
const linuxPaths = ['/tmp/foo', '/tmp/FOO'];
expect(sanitizePaths(linuxPaths)).toEqual(['/tmp/foo', '/tmp/FOO']);
});
it('should throw an error if a path is not absolute', () => {
const paths = ['/workspace/foo', 'relative/path'];
expect(() => sanitizePaths(paths)).toThrow(
'Sandbox path must be absolute: relative/path',
);
});
});
describe('getPathIdentity', () => {
it('should normalize slashes and strip trailing slashes', () => {
expect(getPathIdentity('/foo/bar//baz/')).toBe(
path.normalize('/foo/bar/baz'),
);
});
it('should handle case sensitivity correctly per platform', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
expect(getPathIdentity('/Workspace/Foo')).toBe(
path.normalize('/workspace/foo'),
);
vi.spyOn(os, 'platform').mockReturnValue('darwin');
expect(getPathIdentity('/Tmp/Foo')).toBe(path.normalize('/tmp/foo'));
vi.spyOn(os, 'platform').mockReturnValue('linux');
expect(getPathIdentity('/Tmp/Foo')).toBe(path.normalize('/Tmp/Foo'));
});
});
describe('resolveSandboxPaths', () => {
it('should resolve allowed and forbidden paths', async () => {
const workspace = path.resolve('/workspace');
@@ -268,7 +208,7 @@ describe('SandboxManager', () => {
});
it('should handle case-insensitive conflicts on supported platforms', async () => {
vi.spyOn(os, 'platform').mockReturnValue('darwin');
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin');
const workspace = path.resolve('/workspace');
const secretUpper = path.join(workspace, 'SECRET');
const secretLower = path.join(workspace, 'secret');
+10 -42
View File
@@ -22,7 +22,11 @@ import {
} from './environmentSanitization.js';
import type { ShellExecutionResult } from './shellExecutionService.js';
import type { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js';
import { resolveToRealPath } from '../utils/paths.js';
import {
toPathKey,
deduplicateAbsolutePaths,
resolveToRealPath,
} from '../utils/paths.js';
import { resolveGitWorktreePaths } from '../sandbox/utils/fsUtils.js';
/**
@@ -369,7 +373,7 @@ export async function resolveSandboxPaths(
): 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.
* and pipes the result through deduplicateAbsolutePaths for deduplication and absolute path enforcement.
*/
const expand = (paths?: string[] | null): string[] => {
if (!paths || paths.length === 0) return [];
@@ -381,7 +385,7 @@ export async function resolveSandboxPaths(
return [p];
}
});
return sanitizePaths(expanded);
return deduplicateAbsolutePaths(expanded);
};
const forbidden = expand(await options.forbiddenPaths?.());
@@ -395,9 +399,9 @@ export async function resolveSandboxPaths(
const resolvedWorkspace = resolveToRealPath(options.workspace);
const workspaceIdentities = new Set(
[options.workspace, resolvedWorkspace].map(getPathIdentity),
[options.workspace, resolvedWorkspace].map(toPathKey),
);
const forbiddenIdentities = new Set(forbidden.map(getPathIdentity));
const forbiddenIdentities = new Set(forbidden.map(toPathKey));
const { worktreeGitDir, mainGitDir } =
await resolveGitWorktreePaths(resolvedWorkspace);
@@ -410,7 +414,7 @@ export async function resolveSandboxPaths(
*/
const filter = (paths: string[]) =>
paths.filter((p) => {
const identity = getPathIdentity(p);
const identity = toPathKey(p);
return (
!workspaceIdentities.has(identity) && !forbiddenIdentities.has(identity)
);
@@ -430,40 +434,4 @@ export async function resolveSandboxPaths(
};
}
/**
* Sanitizes an array of paths by deduplicating them and ensuring they are absolute.
* Always returns an array (empty if input is null/undefined).
*/
export function sanitizePaths(paths?: string[] | null): string[] {
if (!paths || paths.length === 0) return [];
const uniquePathsMap = new Map<string, string>();
for (const p of paths) {
if (!path.isAbsolute(p)) {
throw new Error(`Sandbox path must be absolute: ${p}`);
}
const key = getPathIdentity(p);
if (!uniquePathsMap.has(key)) {
uniquePathsMap.set(key, p);
}
}
return Array.from(uniquePathsMap.values());
}
/** Returns a normalized identity for a path, stripping trailing slashes and handling case sensitivity. */
export function getPathIdentity(p: string): string {
let norm = path.normalize(p);
// Strip trailing slashes (except for root paths)
if (norm.length > 1 && (norm.endsWith('/') || norm.endsWith('\\'))) {
norm = norm.slice(0, -1);
}
const platform = os.platform();
const isCaseInsensitive = platform === 'win32' || platform === 'darwin';
return isCaseInsensitive ? norm.toLowerCase() : norm;
}
export { createSandboxManager } from './sandboxManagerFactory.js';