mirror of
https://github.com/tvytlx/ai-agent-deep-dive.git
synced 2026-04-03 07:34:50 +08:00
Add extracted source directory and README navigation
This commit is contained in:
435
extracted-source/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js
generated
vendored
Normal file
435
extracted-source/node_modules/@anthropic-ai/sandbox-runtime/dist/sandbox/sandbox-utils.js
generated
vendored
Normal file
@@ -0,0 +1,435 @@
|
||||
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
|
||||
Reference in New Issue
Block a user