mirror of
https://github.com/tvytlx/ai-agent-deep-dive.git
synced 2026-04-03 23:54:48 +08:00
435 lines
19 KiB
JavaScript
435 lines
19 KiB
JavaScript
import { homedir } from 'os';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import { getPlatform } from '../utils/platform.js';
|
|
import { logForDebugging } from '../utils/debug.js';
|
|
/**
|
|
* Dangerous files that should be protected from writes.
|
|
* These files can be used for code execution or data exfiltration.
|
|
*/
|
|
export const DANGEROUS_FILES = [
|
|
'.gitconfig',
|
|
'.gitmodules',
|
|
'.bashrc',
|
|
'.bash_profile',
|
|
'.zshrc',
|
|
'.zprofile',
|
|
'.profile',
|
|
'.ripgreprc',
|
|
'.mcp.json',
|
|
];
|
|
/**
|
|
* Dangerous directories that should be protected from writes.
|
|
* These directories contain sensitive configuration or executable files.
|
|
*/
|
|
export const DANGEROUS_DIRECTORIES = ['.git', '.vscode', '.idea'];
|
|
/**
|
|
* Get the list of dangerous directories to deny writes to.
|
|
* Excludes .git since we need it writable for git operations -
|
|
* instead we block specific paths within .git (hooks and config).
|
|
*/
|
|
export function getDangerousDirectories() {
|
|
return [
|
|
...DANGEROUS_DIRECTORIES.filter(d => d !== '.git'),
|
|
'.claude/commands',
|
|
'.claude/agents',
|
|
];
|
|
}
|
|
/**
|
|
* Normalizes a path for case-insensitive comparison.
|
|
* This prevents bypassing security checks using mixed-case paths on case-insensitive
|
|
* filesystems (macOS/Windows) like `.cLauDe/Settings.locaL.json`.
|
|
*
|
|
* We always normalize to lowercase regardless of platform for consistent security.
|
|
* @param path The path to normalize
|
|
* @returns The lowercase path for safe comparison
|
|
*/
|
|
export function normalizeCaseForComparison(pathStr) {
|
|
return pathStr.toLowerCase();
|
|
}
|
|
/**
|
|
* Check if a path pattern contains glob characters
|
|
*/
|
|
export function containsGlobChars(pathPattern) {
|
|
return (pathPattern.includes('*') ||
|
|
pathPattern.includes('?') ||
|
|
pathPattern.includes('[') ||
|
|
pathPattern.includes(']'));
|
|
}
|
|
/**
|
|
* Remove trailing /** glob suffix from a path pattern
|
|
* Used to normalize path patterns since /** just means "directory and everything under it"
|
|
*/
|
|
export function removeTrailingGlobSuffix(pathPattern) {
|
|
const stripped = pathPattern.replace(/\/\*\*$/, '');
|
|
return stripped || '/';
|
|
}
|
|
/**
|
|
* Check if a symlink resolution crosses expected path boundaries.
|
|
*
|
|
* When resolving symlinks for sandbox path normalization, we need to ensure
|
|
* the resolved path doesn't unexpectedly broaden the scope. This function
|
|
* returns true if the resolved path is an ancestor of the original path
|
|
* or resolves to a system root, which would indicate the symlink points
|
|
* outside expected boundaries.
|
|
*
|
|
* @param originalPath - The original path before symlink resolution
|
|
* @param resolvedPath - The path after fs.realpathSync() resolution
|
|
* @returns true if the resolved path is outside expected boundaries
|
|
*/
|
|
export function isSymlinkOutsideBoundary(originalPath, resolvedPath) {
|
|
const normalizedOriginal = path.normalize(originalPath);
|
|
const normalizedResolved = path.normalize(resolvedPath);
|
|
// Same path after normalization - OK
|
|
if (normalizedResolved === normalizedOriginal) {
|
|
return false;
|
|
}
|
|
// Handle macOS /tmp -> /private/tmp canonical resolution
|
|
// This is a legitimate system symlink that should be allowed
|
|
// /tmp/claude -> /private/tmp/claude is OK
|
|
// /var/folders/... -> /private/var/folders/... is OK
|
|
if (normalizedOriginal.startsWith('/tmp/') &&
|
|
normalizedResolved === '/private' + normalizedOriginal) {
|
|
return false;
|
|
}
|
|
if (normalizedOriginal.startsWith('/var/') &&
|
|
normalizedResolved === '/private' + normalizedOriginal) {
|
|
return false;
|
|
}
|
|
// Also handle the reverse: /private/tmp/... resolving to itself
|
|
if (normalizedOriginal.startsWith('/private/tmp/') &&
|
|
normalizedResolved === normalizedOriginal) {
|
|
return false;
|
|
}
|
|
if (normalizedOriginal.startsWith('/private/var/') &&
|
|
normalizedResolved === normalizedOriginal) {
|
|
return false;
|
|
}
|
|
// If resolved path is "/" it's outside expected boundaries
|
|
if (normalizedResolved === '/') {
|
|
return true;
|
|
}
|
|
// If resolved path is very short (single component like /tmp, /usr, /var),
|
|
// it's likely outside expected boundaries
|
|
const resolvedParts = normalizedResolved.split('/').filter(Boolean);
|
|
if (resolvedParts.length <= 1) {
|
|
return true;
|
|
}
|
|
// If original path starts with resolved path, the resolved path is an ancestor
|
|
// e.g., /tmp/claude -> /tmp means the symlink points to a broader scope
|
|
if (normalizedOriginal.startsWith(normalizedResolved + '/')) {
|
|
return true;
|
|
}
|
|
// Also check the canonical form of the original path for macOS
|
|
// e.g., /tmp/claude should also be checked as /private/tmp/claude
|
|
let canonicalOriginal = normalizedOriginal;
|
|
if (normalizedOriginal.startsWith('/tmp/')) {
|
|
canonicalOriginal = '/private' + normalizedOriginal;
|
|
}
|
|
else if (normalizedOriginal.startsWith('/var/')) {
|
|
canonicalOriginal = '/private' + normalizedOriginal;
|
|
}
|
|
if (canonicalOriginal !== normalizedOriginal &&
|
|
canonicalOriginal.startsWith(normalizedResolved + '/')) {
|
|
return true;
|
|
}
|
|
// STRICT CHECK: Only allow resolutions that stay within the expected path tree
|
|
// The resolved path must either:
|
|
// 1. Start with the original path (deeper/same) - already covered by returning false below
|
|
// 2. Start with the canonical original (deeper/same under canonical form)
|
|
// 3. BE the canonical form of the original (e.g., /tmp/x -> /private/tmp/x)
|
|
// Any other resolution (e.g., /tmp/claude -> /Users/dworken) is outside expected bounds
|
|
const resolvedStartsWithOriginal = normalizedResolved.startsWith(normalizedOriginal + '/');
|
|
const resolvedStartsWithCanonical = canonicalOriginal !== normalizedOriginal &&
|
|
normalizedResolved.startsWith(canonicalOriginal + '/');
|
|
const resolvedIsCanonical = canonicalOriginal !== normalizedOriginal &&
|
|
normalizedResolved === canonicalOriginal;
|
|
const resolvedIsSame = normalizedResolved === normalizedOriginal;
|
|
// If resolved path is not within expected tree, it's outside boundary
|
|
if (!resolvedIsSame &&
|
|
!resolvedIsCanonical &&
|
|
!resolvedStartsWithOriginal &&
|
|
!resolvedStartsWithCanonical) {
|
|
return true;
|
|
}
|
|
// Allow resolution to same directory level or deeper within expected tree
|
|
return false;
|
|
}
|
|
/**
|
|
* Normalize a path for use in sandbox configurations
|
|
* Handles:
|
|
* - Tilde (~) expansion for home directory
|
|
* - Relative paths (./foo, ../foo, etc.) converted to absolute
|
|
* - Absolute paths remain unchanged
|
|
* - Symlinks are resolved to their real paths for non-glob patterns
|
|
* - Glob patterns preserve wildcards after path normalization
|
|
*
|
|
* Returns the absolute path with symlinks resolved (or normalized glob pattern)
|
|
*/
|
|
export function normalizePathForSandbox(pathPattern) {
|
|
const cwd = process.cwd();
|
|
let normalizedPath = pathPattern;
|
|
// Expand ~ to home directory
|
|
if (pathPattern === '~') {
|
|
normalizedPath = homedir();
|
|
}
|
|
else if (pathPattern.startsWith('~/')) {
|
|
normalizedPath = homedir() + pathPattern.slice(1);
|
|
}
|
|
else if (pathPattern.startsWith('./') || pathPattern.startsWith('../')) {
|
|
// Convert relative to absolute based on current working directory
|
|
normalizedPath = path.resolve(cwd, pathPattern);
|
|
}
|
|
else if (!path.isAbsolute(pathPattern)) {
|
|
// Handle other relative paths (e.g., ".", "..", "foo/bar")
|
|
normalizedPath = path.resolve(cwd, pathPattern);
|
|
}
|
|
// For glob patterns, resolve symlinks for the directory portion only
|
|
if (containsGlobChars(normalizedPath)) {
|
|
// Extract the static directory prefix before glob characters
|
|
const staticPrefix = normalizedPath.split(/[*?[\]]/)[0];
|
|
if (staticPrefix && staticPrefix !== '/') {
|
|
// Get the directory containing the glob pattern
|
|
// If staticPrefix ends with /, remove it to get the directory
|
|
const baseDir = staticPrefix.endsWith('/')
|
|
? staticPrefix.slice(0, -1)
|
|
: path.dirname(staticPrefix);
|
|
// Try to resolve symlinks for the base directory
|
|
try {
|
|
const resolvedBaseDir = fs.realpathSync(baseDir);
|
|
// Validate that resolution stays within expected boundaries
|
|
if (!isSymlinkOutsideBoundary(baseDir, resolvedBaseDir)) {
|
|
// Reconstruct the pattern with the resolved directory
|
|
const patternSuffix = normalizedPath.slice(baseDir.length);
|
|
return resolvedBaseDir + patternSuffix;
|
|
}
|
|
// If resolution would broaden scope, keep original pattern
|
|
}
|
|
catch {
|
|
// If directory doesn't exist or can't be resolved, keep the original pattern
|
|
}
|
|
}
|
|
return normalizedPath;
|
|
}
|
|
// Resolve symlinks to real paths to avoid bwrap issues
|
|
// Validate that the resolution stays within expected boundaries
|
|
try {
|
|
const resolvedPath = fs.realpathSync(normalizedPath);
|
|
// Only use resolved path if it doesn't cross boundary (e.g., symlink to parent dir)
|
|
if (isSymlinkOutsideBoundary(normalizedPath, resolvedPath)) {
|
|
// Symlink points outside expected boundaries - keep original path
|
|
}
|
|
else {
|
|
normalizedPath = resolvedPath;
|
|
}
|
|
}
|
|
catch {
|
|
// If path doesn't exist or can't be resolved, keep the normalized path
|
|
}
|
|
return normalizedPath;
|
|
}
|
|
/**
|
|
* Get recommended system paths that should be writable for commands to work properly
|
|
*
|
|
* WARNING: These default paths are intentionally broad for compatibility but may
|
|
* allow access to files from other processes. In highly security-sensitive
|
|
* environments, you should configure more restrictive write paths.
|
|
*/
|
|
export function getDefaultWritePaths() {
|
|
const homeDir = homedir();
|
|
const recommendedPaths = [
|
|
'/dev/stdout',
|
|
'/dev/stderr',
|
|
'/dev/null',
|
|
'/dev/tty',
|
|
'/dev/dtracehelper',
|
|
'/dev/autofs_nowait',
|
|
'/tmp/claude',
|
|
'/private/tmp/claude',
|
|
path.join(homeDir, '.npm/_logs'),
|
|
path.join(homeDir, '.claude/debug'),
|
|
];
|
|
return recommendedPaths;
|
|
}
|
|
/**
|
|
* Generate proxy environment variables for sandboxed processes
|
|
*/
|
|
export function generateProxyEnvVars(httpProxyPort, socksProxyPort) {
|
|
// Respect CLAUDE_TMPDIR if set, otherwise default to /tmp/claude
|
|
const tmpdir = process.env.CLAUDE_TMPDIR || '/tmp/claude';
|
|
const envVars = [`SANDBOX_RUNTIME=1`, `TMPDIR=${tmpdir}`];
|
|
// If no proxy ports provided, return minimal env vars
|
|
if (!httpProxyPort && !socksProxyPort) {
|
|
return envVars;
|
|
}
|
|
// Always set NO_PROXY to exclude localhost and private networks from proxying
|
|
const noProxyAddresses = [
|
|
'localhost',
|
|
'127.0.0.1',
|
|
'::1',
|
|
'*.local',
|
|
'.local',
|
|
'169.254.0.0/16', // Link-local
|
|
'10.0.0.0/8', // Private network
|
|
'172.16.0.0/12', // Private network
|
|
'192.168.0.0/16', // Private network
|
|
].join(',');
|
|
envVars.push(`NO_PROXY=${noProxyAddresses}`);
|
|
envVars.push(`no_proxy=${noProxyAddresses}`);
|
|
if (httpProxyPort) {
|
|
envVars.push(`HTTP_PROXY=http://localhost:${httpProxyPort}`);
|
|
envVars.push(`HTTPS_PROXY=http://localhost:${httpProxyPort}`);
|
|
// Lowercase versions for compatibility with some tools
|
|
envVars.push(`http_proxy=http://localhost:${httpProxyPort}`);
|
|
envVars.push(`https_proxy=http://localhost:${httpProxyPort}`);
|
|
}
|
|
if (socksProxyPort) {
|
|
// Use socks5h:// for proper DNS resolution through proxy
|
|
envVars.push(`ALL_PROXY=socks5h://localhost:${socksProxyPort}`);
|
|
envVars.push(`all_proxy=socks5h://localhost:${socksProxyPort}`);
|
|
// Configure Git to use SSH through the proxy so DNS resolution happens outside the sandbox
|
|
const platform = getPlatform();
|
|
if (platform === 'macos') {
|
|
// macOS: use BSD nc SOCKS5 proxy support (-X 5 -x)
|
|
envVars.push(`GIT_SSH_COMMAND=ssh -o ProxyCommand='nc -X 5 -x localhost:${socksProxyPort} %h %p'`);
|
|
}
|
|
else if (platform === 'linux' && httpProxyPort) {
|
|
// Linux: use socat HTTP CONNECT via the HTTP proxy bridge.
|
|
// socat is already a required Linux sandbox dependency, and PROXY: is
|
|
// portable across all socat versions (unlike SOCKS5-CONNECT which needs >= 1.8.0).
|
|
envVars.push(`GIT_SSH_COMMAND=ssh -o ProxyCommand='socat - PROXY:localhost:%h:%p,proxyport=${httpProxyPort}'`);
|
|
}
|
|
// FTP proxy support (use socks5h for DNS resolution through proxy)
|
|
envVars.push(`FTP_PROXY=socks5h://localhost:${socksProxyPort}`);
|
|
envVars.push(`ftp_proxy=socks5h://localhost:${socksProxyPort}`);
|
|
// rsync proxy support
|
|
envVars.push(`RSYNC_PROXY=localhost:${socksProxyPort}`);
|
|
// Database tools NOTE: Most database clients don't have built-in proxy support
|
|
// You typically need to use SSH tunneling or a SOCKS wrapper like tsocks/proxychains
|
|
// Docker CLI uses HTTP for the API
|
|
// This makes Docker use the HTTP proxy for registry operations
|
|
envVars.push(`DOCKER_HTTP_PROXY=http://localhost:${httpProxyPort || socksProxyPort}`);
|
|
envVars.push(`DOCKER_HTTPS_PROXY=http://localhost:${httpProxyPort || socksProxyPort}`);
|
|
// Kubernetes kubectl - uses standard HTTPS_PROXY
|
|
// kubectl respects HTTPS_PROXY which we already set above
|
|
// AWS CLI - uses standard HTTPS_PROXY (v2 supports it well)
|
|
// AWS CLI v2 respects HTTPS_PROXY which we already set above
|
|
// Google Cloud SDK - has specific proxy settings
|
|
// Use HTTPS proxy to match other HTTP-based tools
|
|
if (httpProxyPort) {
|
|
envVars.push(`CLOUDSDK_PROXY_TYPE=https`);
|
|
envVars.push(`CLOUDSDK_PROXY_ADDRESS=localhost`);
|
|
envVars.push(`CLOUDSDK_PROXY_PORT=${httpProxyPort}`);
|
|
}
|
|
// Azure CLI - uses HTTPS_PROXY
|
|
// Azure CLI respects HTTPS_PROXY which we already set above
|
|
// Terraform - uses standard HTTP/HTTPS proxy vars
|
|
// Terraform respects HTTP_PROXY/HTTPS_PROXY which we already set above
|
|
// gRPC-based tools - use standard proxy vars
|
|
envVars.push(`GRPC_PROXY=socks5h://localhost:${socksProxyPort}`);
|
|
envVars.push(`grpc_proxy=socks5h://localhost:${socksProxyPort}`);
|
|
}
|
|
// WARNING: Do not set HTTP_PROXY/HTTPS_PROXY to SOCKS URLs when only SOCKS proxy is available
|
|
// Most HTTP clients do not support SOCKS URLs in these variables and will fail, and we want
|
|
// to avoid overriding the client otherwise respecting the ALL_PROXY env var which points to SOCKS.
|
|
return envVars;
|
|
}
|
|
/**
|
|
* Encode a command for sandbox monitoring
|
|
* Truncates to 100 chars and base64 encodes to avoid parsing issues
|
|
*/
|
|
export function encodeSandboxedCommand(command) {
|
|
const truncatedCommand = command.slice(0, 100);
|
|
return Buffer.from(truncatedCommand).toString('base64');
|
|
}
|
|
/**
|
|
* Decode a base64-encoded command from sandbox monitoring
|
|
*/
|
|
export function decodeSandboxedCommand(encodedCommand) {
|
|
return Buffer.from(encodedCommand, 'base64').toString('utf8');
|
|
}
|
|
/**
|
|
* Convert a glob pattern to a regular expression
|
|
*
|
|
* This implements gitignore-style pattern matching to match the behavior of the
|
|
* `ignore` library used by the permission system.
|
|
*
|
|
* Supported patterns:
|
|
* - * matches any characters except / (e.g., *.ts matches foo.ts but not foo/bar.ts)
|
|
* - ** matches any characters including / (e.g., src/**\/*.ts matches all .ts files in src/)
|
|
* - ? matches any single character except / (e.g., file?.txt matches file1.txt)
|
|
* - [abc] matches any character in the set (e.g., file[0-9].txt matches file3.txt)
|
|
*
|
|
* Exported for testing and shared between macOS sandbox profiles and Linux glob expansion.
|
|
*/
|
|
export function globToRegex(globPattern) {
|
|
return ('^' +
|
|
globPattern
|
|
// Escape regex special characters (except glob chars * ? [ ])
|
|
.replace(/[.^$+{}()|\\]/g, '\\$&')
|
|
// Escape unclosed brackets (no matching ])
|
|
.replace(/\[([^\]]*?)$/g, '\\[$1')
|
|
// Convert glob patterns to regex (order matters - ** before *)
|
|
.replace(/\*\*\//g, '__GLOBSTAR_SLASH__') // Placeholder for **/
|
|
.replace(/\*\*/g, '__GLOBSTAR__') // Placeholder for **
|
|
.replace(/\*/g, '[^/]*') // * matches anything except /
|
|
.replace(/\?/g, '[^/]') // ? matches single character except /
|
|
// Restore placeholders
|
|
.replace(/__GLOBSTAR_SLASH__/g, '(.*/)?') // **/ matches zero or more dirs
|
|
.replace(/__GLOBSTAR__/g, '.*') + // ** matches anything including /
|
|
'$');
|
|
}
|
|
/**
|
|
* Expand a glob pattern into concrete file paths.
|
|
*
|
|
* Used on Linux where bubblewrap doesn't support glob patterns natively.
|
|
* Resolves the static directory prefix, lists files recursively, and filters
|
|
* using globToRegex().
|
|
*
|
|
* @param globPath - A path pattern containing glob characters (e.g., ~/test/*.env)
|
|
* @returns Array of absolute paths matching the glob pattern
|
|
*/
|
|
export function expandGlobPattern(globPath) {
|
|
const normalizedPattern = normalizePathForSandbox(globPath);
|
|
// Extract the static directory prefix before any glob characters
|
|
const staticPrefix = normalizedPattern.split(/[*?[\]]/)[0];
|
|
if (!staticPrefix || staticPrefix === '/') {
|
|
logForDebugging(`[Sandbox] Glob pattern too broad, skipping: ${globPath}`);
|
|
return [];
|
|
}
|
|
// Get the base directory from the static prefix
|
|
const baseDir = staticPrefix.endsWith('/')
|
|
? staticPrefix.slice(0, -1)
|
|
: path.dirname(staticPrefix);
|
|
if (!fs.existsSync(baseDir)) {
|
|
logForDebugging(`[Sandbox] Base directory for glob does not exist: ${baseDir}`);
|
|
return [];
|
|
}
|
|
// Build regex from the normalized glob pattern
|
|
const regex = new RegExp(globToRegex(normalizedPattern));
|
|
// List all entries recursively under the base directory
|
|
const results = [];
|
|
try {
|
|
const entries = fs.readdirSync(baseDir, {
|
|
recursive: true,
|
|
withFileTypes: true,
|
|
});
|
|
for (const entry of entries) {
|
|
// Build the full path for this entry
|
|
// entry.parentPath is the directory containing this entry (available in Node 20+/Bun)
|
|
// For compatibility, fall back to entry.path if parentPath is not available
|
|
const parentDir = entry.parentPath ??
|
|
entry.path ??
|
|
baseDir;
|
|
const fullPath = path.join(parentDir, entry.name);
|
|
if (regex.test(fullPath)) {
|
|
results.push(fullPath);
|
|
}
|
|
}
|
|
}
|
|
catch (err) {
|
|
logForDebugging(`[Sandbox] Error expanding glob pattern ${globPath}: ${err}`);
|
|
}
|
|
return results;
|
|
}
|
|
//# sourceMappingURL=sandbox-utils.js.map
|