486 lines
16 KiB
TypeScript
486 lines
16 KiB
TypeScript
import memoize from 'lodash-es/memoize.js'
|
||
import { homedir } from 'os'
|
||
import { dirname, isAbsolute, resolve } from 'path'
|
||
import type { ToolPermissionContext } from '../../Tool.js'
|
||
import { getPlatform } from '../../utils/platform.js'
|
||
import {
|
||
getFsImplementation,
|
||
getPathsForPermissionCheck,
|
||
safeResolvePath,
|
||
} from '../fsOperations.js'
|
||
import { containsPathTraversal } from '../path.js'
|
||
import { SandboxManager } from '../sandbox/sandbox-adapter.js'
|
||
import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js'
|
||
import {
|
||
checkEditableInternalPath,
|
||
checkPathSafetyForAutoEdit,
|
||
checkReadableInternalPath,
|
||
matchingRuleForInput,
|
||
pathInAllowedWorkingPath,
|
||
pathInWorkingPath,
|
||
} from './filesystem.js'
|
||
import type { PermissionDecisionReason } from './PermissionResult.js'
|
||
|
||
const MAX_DIRS_TO_LIST = 5
|
||
const GLOB_PATTERN_REGEX = /[*?[\]{}]/
|
||
|
||
export type FileOperationType = 'read' | 'write' | 'create'
|
||
|
||
export type PathCheckResult = {
|
||
allowed: boolean
|
||
decisionReason?: PermissionDecisionReason
|
||
}
|
||
|
||
export type ResolvedPathCheckResult = PathCheckResult & {
|
||
resolvedPath: string
|
||
}
|
||
|
||
export function formatDirectoryList(directories: string[]): string {
|
||
const dirCount = directories.length
|
||
|
||
if (dirCount <= MAX_DIRS_TO_LIST) {
|
||
return directories.map(dir => `'${dir}'`).join(', ')
|
||
}
|
||
|
||
const firstDirs = directories
|
||
.slice(0, MAX_DIRS_TO_LIST)
|
||
.map(dir => `'${dir}'`)
|
||
.join(', ')
|
||
|
||
return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more`
|
||
}
|
||
|
||
/**
|
||
* Extracts the base directory from a glob pattern for validation.
|
||
* For example: "/path/to/*.txt" returns "/path/to"
|
||
*/
|
||
export function getGlobBaseDirectory(path: string): string {
|
||
const globMatch = path.match(GLOB_PATTERN_REGEX)
|
||
if (!globMatch || globMatch.index === undefined) {
|
||
return path
|
||
}
|
||
|
||
// Get everything before the first glob character
|
||
const beforeGlob = path.substring(0, globMatch.index)
|
||
|
||
// Find the last directory separator
|
||
const lastSepIndex =
|
||
getPlatform() === 'windows'
|
||
? Math.max(beforeGlob.lastIndexOf('/'), beforeGlob.lastIndexOf('\\'))
|
||
: beforeGlob.lastIndexOf('/')
|
||
if (lastSepIndex === -1) return '.'
|
||
|
||
return beforeGlob.substring(0, lastSepIndex) || '/'
|
||
}
|
||
|
||
/**
|
||
* Expands tilde (~) at the start of a path to the user's home directory.
|
||
* Note: ~username expansion is not supported for security reasons.
|
||
*/
|
||
export function expandTilde(path: string): string {
|
||
if (
|
||
path === '~' ||
|
||
path.startsWith('~/') ||
|
||
(process.platform === 'win32' && path.startsWith('~\\'))
|
||
) {
|
||
return homedir() + path.slice(1)
|
||
}
|
||
return path
|
||
}
|
||
|
||
/**
|
||
* Checks if a resolved path is writable according to the sandbox write allowlist.
|
||
* When the sandbox is enabled, the user has explicitly configured which directories
|
||
* are writable. We treat these as additional allowed write directories for path
|
||
* validation purposes, so commands like `echo foo > /tmp/claude/x.txt` don't
|
||
* prompt for permission when /tmp/claude/ is already in the sandbox allowlist.
|
||
*
|
||
* Respects the deny-within-allow list: paths in denyWithinAllow (like
|
||
* .claude/settings.json) are still blocked even if their parent is in allowOnly.
|
||
*/
|
||
export function isPathInSandboxWriteAllowlist(resolvedPath: string): boolean {
|
||
if (!SandboxManager.isSandboxingEnabled()) {
|
||
return false
|
||
}
|
||
const { allowOnly, denyWithinAllow } = SandboxManager.getFsWriteConfig()
|
||
// Resolve symlinks on both sides so comparisons are symmetric (matching
|
||
// pathInAllowedWorkingPath). Without this, an allowlist entry that is a
|
||
// symlink (e.g. /home/user/proj -> /data/proj) would not match a write to
|
||
// its resolved target, causing an unnecessary prompt. Over-conservative,
|
||
// not a security issue. All resolved input representations must be allowed
|
||
// and none may be denied. Config paths are session-stable, so memoize
|
||
// their resolution to avoid N × config.length redundant syscalls per
|
||
// command with N write targets (matching getResolvedWorkingDirPaths).
|
||
const pathsToCheck = getPathsForPermissionCheck(resolvedPath)
|
||
const resolvedAllow = allowOnly.flatMap(getResolvedSandboxConfigPath)
|
||
const resolvedDeny = denyWithinAllow.flatMap(getResolvedSandboxConfigPath)
|
||
return pathsToCheck.every(p => {
|
||
for (const denyPath of resolvedDeny) {
|
||
if (pathInWorkingPath(p, denyPath)) return false
|
||
}
|
||
return resolvedAllow.some(allowPath => pathInWorkingPath(p, allowPath))
|
||
})
|
||
}
|
||
|
||
// Sandbox config paths are session-stable; memoize their resolved forms to
|
||
// avoid repeated lstat/realpath syscalls on every write-target check.
|
||
// Matches the getResolvedWorkingDirPaths pattern in filesystem.ts.
|
||
const getResolvedSandboxConfigPath = memoize(getPathsForPermissionCheck)
|
||
|
||
/**
|
||
* Checks if a resolved path is allowed for the given operation type.
|
||
*
|
||
* @param precomputedPathsToCheck - Optional cached result of
|
||
* `getPathsForPermissionCheck(resolvedPath)`. When `resolvedPath` is the
|
||
* output of `realpathSync` (canonical path, all symlinks resolved), this
|
||
* is trivially `[resolvedPath]` and passing it here skips 5 redundant
|
||
* syscalls per inner check. Do NOT pass this for non-canonical paths
|
||
* (nonexistent files, UNC paths, etc.) — parent-directory symlink
|
||
* resolution is still required for those.
|
||
*/
|
||
export function isPathAllowed(
|
||
resolvedPath: string,
|
||
context: ToolPermissionContext,
|
||
operationType: FileOperationType,
|
||
precomputedPathsToCheck?: readonly string[],
|
||
): PathCheckResult {
|
||
// Determine which permission type to check based on operation
|
||
const permissionType = operationType === 'read' ? 'read' : 'edit'
|
||
|
||
// 1. Check deny rules first (they take precedence)
|
||
const denyRule = matchingRuleForInput(
|
||
resolvedPath,
|
||
context,
|
||
permissionType,
|
||
'deny',
|
||
)
|
||
if (denyRule !== null) {
|
||
return {
|
||
allowed: false,
|
||
decisionReason: { type: 'rule', rule: denyRule },
|
||
}
|
||
}
|
||
|
||
// 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs)
|
||
// This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory
|
||
// and internal editable paths live under ~/.claude/ — matching the ordering in
|
||
// checkWritePermissionForTool (filesystem.ts step 1.5)
|
||
if (operationType !== 'read') {
|
||
const internalEditResult = checkEditableInternalPath(resolvedPath, {})
|
||
if (internalEditResult.behavior === 'allow') {
|
||
return {
|
||
allowed: true,
|
||
decisionReason: internalEditResult.decisionReason,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2.5. For write/create operations, check comprehensive safety validations
|
||
// This MUST come before checking working directory to prevent bypass via acceptEdits mode
|
||
// Checks: Windows patterns, Claude config files, dangerous files (on original + symlink paths)
|
||
if (operationType !== 'read') {
|
||
const safetyCheck = checkPathSafetyForAutoEdit(
|
||
resolvedPath,
|
||
precomputedPathsToCheck,
|
||
)
|
||
if (!safetyCheck.safe) {
|
||
return {
|
||
allowed: false,
|
||
decisionReason: {
|
||
type: 'safetyCheck',
|
||
reason: safetyCheck.message,
|
||
classifierApprovable: safetyCheck.classifierApprovable,
|
||
},
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Check if path is in allowed working directory
|
||
// For write/create operations, require acceptEdits mode to auto-allow
|
||
// This is consistent with checkWritePermissionForTool in filesystem.ts
|
||
const isInWorkingDir = pathInAllowedWorkingPath(
|
||
resolvedPath,
|
||
context,
|
||
precomputedPathsToCheck,
|
||
)
|
||
if (isInWorkingDir) {
|
||
if (operationType === 'read' || context.mode === 'acceptEdits') {
|
||
return { allowed: true }
|
||
}
|
||
// Write/create without acceptEdits mode falls through to check allow rules
|
||
}
|
||
|
||
// 3.5. For read operations, check internal readable paths (project temp dir, session memory, etc.)
|
||
// This allows reading agent output files without explicit permission
|
||
if (operationType === 'read') {
|
||
const internalReadResult = checkReadableInternalPath(resolvedPath, {})
|
||
if (internalReadResult.behavior === 'allow') {
|
||
return {
|
||
allowed: true,
|
||
decisionReason: internalReadResult.decisionReason,
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3.7. For write/create operations to paths OUTSIDE the working directory,
|
||
// check the sandbox write allowlist. When the sandbox is enabled, users
|
||
// have explicitly configured writable directories (e.g. /tmp/claude/) —
|
||
// treat these as additional allowed write directories so redirects/touch/
|
||
// mkdir don't prompt unnecessarily. Safety checks (step 2) already ran.
|
||
// Paths IN the working directory are intentionally excluded: the sandbox
|
||
// allowlist always seeds '.' (cwd, see sandbox-adapter.ts), which would
|
||
// bypass the acceptEdits gate at step 3. Step 3 handles those.
|
||
if (
|
||
operationType !== 'read' &&
|
||
!isInWorkingDir &&
|
||
isPathInSandboxWriteAllowlist(resolvedPath)
|
||
) {
|
||
return {
|
||
allowed: true,
|
||
decisionReason: {
|
||
type: 'other',
|
||
reason: 'Path is in sandbox write allowlist',
|
||
},
|
||
}
|
||
}
|
||
|
||
// 4. Check allow rules for the operation type
|
||
const allowRule = matchingRuleForInput(
|
||
resolvedPath,
|
||
context,
|
||
permissionType,
|
||
'allow',
|
||
)
|
||
if (allowRule !== null) {
|
||
return {
|
||
allowed: true,
|
||
decisionReason: { type: 'rule', rule: allowRule },
|
||
}
|
||
}
|
||
|
||
// 5. Path is not allowed
|
||
return { allowed: false }
|
||
}
|
||
|
||
/**
|
||
* Validates a glob pattern by checking its base directory.
|
||
* Returns the validation result for the base path where the glob would expand.
|
||
*/
|
||
export function validateGlobPattern(
|
||
cleanPath: string,
|
||
cwd: string,
|
||
toolPermissionContext: ToolPermissionContext,
|
||
operationType: FileOperationType,
|
||
): ResolvedPathCheckResult {
|
||
if (containsPathTraversal(cleanPath)) {
|
||
// For patterns with path traversal, resolve the full path
|
||
const absolutePath = isAbsolute(cleanPath)
|
||
? cleanPath
|
||
: resolve(cwd, cleanPath)
|
||
const { resolvedPath, isCanonical } = safeResolvePath(
|
||
getFsImplementation(),
|
||
absolutePath,
|
||
)
|
||
const result = isPathAllowed(
|
||
resolvedPath,
|
||
toolPermissionContext,
|
||
operationType,
|
||
isCanonical ? [resolvedPath] : undefined,
|
||
)
|
||
return {
|
||
allowed: result.allowed,
|
||
resolvedPath,
|
||
decisionReason: result.decisionReason,
|
||
}
|
||
}
|
||
|
||
const basePath = getGlobBaseDirectory(cleanPath)
|
||
const absoluteBasePath = isAbsolute(basePath)
|
||
? basePath
|
||
: resolve(cwd, basePath)
|
||
const { resolvedPath, isCanonical } = safeResolvePath(
|
||
getFsImplementation(),
|
||
absoluteBasePath,
|
||
)
|
||
const result = isPathAllowed(
|
||
resolvedPath,
|
||
toolPermissionContext,
|
||
operationType,
|
||
isCanonical ? [resolvedPath] : undefined,
|
||
)
|
||
return {
|
||
allowed: result.allowed,
|
||
resolvedPath,
|
||
decisionReason: result.decisionReason,
|
||
}
|
||
}
|
||
|
||
const WINDOWS_DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?$/
|
||
const WINDOWS_DRIVE_CHILD_REGEX = /^[A-Za-z]:\/[^/]+$/
|
||
|
||
/**
|
||
* Checks if a resolved path is dangerous for removal operations (rm/rmdir).
|
||
* Dangerous paths are:
|
||
* - Wildcard '*' (removes all files in directory)
|
||
* - Any path ending with '/*' or '\*' (e.g., /path/to/dir/*, C:\foo\*)
|
||
* - Root directory (/)
|
||
* - Home directory (~)
|
||
* - Direct children of root (/usr, /tmp, /etc, etc.)
|
||
* - Windows drive root (C:\, D:\) and direct children (C:\Windows, C:\Users)
|
||
*/
|
||
export function isDangerousRemovalPath(resolvedPath: string): boolean {
|
||
// Callers pass both slash forms; collapse runs so C:\\Windows (valid in
|
||
// PowerShell) doesn't bypass the drive-child check.
|
||
const forwardSlashed = resolvedPath.replace(/[\\/]+/g, '/')
|
||
|
||
if (forwardSlashed === '*' || forwardSlashed.endsWith('/*')) {
|
||
return true
|
||
}
|
||
|
||
const normalizedPath =
|
||
forwardSlashed === '/' ? forwardSlashed : forwardSlashed.replace(/\/$/, '')
|
||
|
||
if (normalizedPath === '/') {
|
||
return true
|
||
}
|
||
|
||
if (WINDOWS_DRIVE_ROOT_REGEX.test(normalizedPath)) {
|
||
return true
|
||
}
|
||
|
||
const normalizedHome = homedir().replace(/[\\/]+/g, '/')
|
||
if (normalizedPath === normalizedHome) {
|
||
return true
|
||
}
|
||
|
||
// Direct children of root: /usr, /tmp, /etc (but not /usr/local)
|
||
const parentDir = dirname(normalizedPath)
|
||
if (parentDir === '/') {
|
||
return true
|
||
}
|
||
|
||
if (WINDOWS_DRIVE_CHILD_REGEX.test(normalizedPath)) {
|
||
return true
|
||
}
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Validates a file system path, handling tilde expansion and glob patterns.
|
||
* Returns whether the path is allowed and the resolved path for error messages.
|
||
*/
|
||
export function validatePath(
|
||
path: string,
|
||
cwd: string,
|
||
toolPermissionContext: ToolPermissionContext,
|
||
operationType: FileOperationType,
|
||
): ResolvedPathCheckResult {
|
||
// Remove surrounding quotes if present
|
||
const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, ''))
|
||
|
||
// SECURITY: Block UNC paths that could leak credentials
|
||
if (containsVulnerableUncPath(cleanPath)) {
|
||
return {
|
||
allowed: false,
|
||
resolvedPath: cleanPath,
|
||
decisionReason: {
|
||
type: 'other',
|
||
reason: 'UNC network paths require manual approval',
|
||
},
|
||
}
|
||
}
|
||
|
||
// SECURITY: Reject tilde variants (~user, ~+, ~-, ~N) that expandTilde doesn't handle.
|
||
// expandTilde resolves ~ and ~/ to $HOME, but ~root, ~+, ~- etc. are left as literal
|
||
// text and resolved as relative paths (e.g., /cwd/~root/.ssh/id_rsa).
|
||
// The shell expands these differently (~root → /var/root, ~+ → $PWD, ~- → $OLDPWD),
|
||
// creating a TOCTOU gap: we validate /cwd/~root/... but bash reads /var/root/...
|
||
// This check is safe from false positives because expandTilde already converted
|
||
// ~ and ~/ to absolute paths starting with /, so only unexpanded variants remain.
|
||
if (cleanPath.startsWith('~')) {
|
||
return {
|
||
allowed: false,
|
||
resolvedPath: cleanPath,
|
||
decisionReason: {
|
||
type: 'other',
|
||
reason:
|
||
'Tilde expansion variants (~user, ~+, ~-) in paths require manual approval',
|
||
},
|
||
}
|
||
}
|
||
|
||
// SECURITY: Reject paths containing ANY shell expansion syntax ($ or % characters,
|
||
// or paths starting with = which triggers Zsh equals expansion)
|
||
// - $VAR (Unix/Linux environment variables like $HOME, $PWD)
|
||
// - ${VAR} (brace expansion)
|
||
// - $(cmd) (command substitution)
|
||
// - %VAR% (Windows environment variables like %TEMP%, %USERPROFILE%)
|
||
// - Nested combinations like $(echo $HOME)
|
||
// - =cmd (Zsh equals expansion, e.g. =rg expands to /usr/bin/rg)
|
||
// All of these are preserved as literal strings during validation but expanded
|
||
// by the shell during execution, creating a TOCTOU vulnerability
|
||
if (
|
||
cleanPath.includes('$') ||
|
||
cleanPath.includes('%') ||
|
||
cleanPath.startsWith('=')
|
||
) {
|
||
return {
|
||
allowed: false,
|
||
resolvedPath: cleanPath,
|
||
decisionReason: {
|
||
type: 'other',
|
||
reason: 'Shell expansion syntax in paths requires manual approval',
|
||
},
|
||
}
|
||
}
|
||
|
||
// SECURITY: Block glob patterns in write/create operations
|
||
// Write tools don't expand globs - they use paths literally.
|
||
// Allowing globs in write operations could bypass security checks.
|
||
// Example: /allowed/dir/*.txt would only validate /allowed/dir,
|
||
// but the actual write would use the literal path with the *
|
||
if (GLOB_PATTERN_REGEX.test(cleanPath)) {
|
||
if (operationType === 'write' || operationType === 'create') {
|
||
return {
|
||
allowed: false,
|
||
resolvedPath: cleanPath,
|
||
decisionReason: {
|
||
type: 'other',
|
||
reason:
|
||
'Glob patterns are not allowed in write operations. Please specify an exact file path.',
|
||
},
|
||
}
|
||
}
|
||
|
||
// For read operations, validate the base directory where the glob would expand
|
||
return validateGlobPattern(
|
||
cleanPath,
|
||
cwd,
|
||
toolPermissionContext,
|
||
operationType,
|
||
)
|
||
}
|
||
|
||
// Resolve path
|
||
const absolutePath = isAbsolute(cleanPath)
|
||
? cleanPath
|
||
: resolve(cwd, cleanPath)
|
||
const { resolvedPath, isCanonical } = safeResolvePath(
|
||
getFsImplementation(),
|
||
absolutePath,
|
||
)
|
||
|
||
const result = isPathAllowed(
|
||
resolvedPath,
|
||
toolPermissionContext,
|
||
operationType,
|
||
isCanonical ? [resolvedPath] : undefined,
|
||
)
|
||
return {
|
||
allowed: result.allowed,
|
||
resolvedPath,
|
||
decisionReason: result.decisionReason,
|
||
}
|
||
}
|