pathValidation.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. import memoize from 'lodash-es/memoize.js'
  2. import { homedir } from 'os'
  3. import { dirname, isAbsolute, resolve } from 'path'
  4. import type { ToolPermissionContext } from '../../Tool.js'
  5. import { getPlatform } from '../../utils/platform.js'
  6. import {
  7. getFsImplementation,
  8. getPathsForPermissionCheck,
  9. safeResolvePath,
  10. } from '../fsOperations.js'
  11. import { containsPathTraversal } from '../path.js'
  12. import { SandboxManager } from '../sandbox/sandbox-adapter.js'
  13. import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js'
  14. import {
  15. checkEditableInternalPath,
  16. checkPathSafetyForAutoEdit,
  17. checkReadableInternalPath,
  18. matchingRuleForInput,
  19. pathInAllowedWorkingPath,
  20. pathInWorkingPath,
  21. } from './filesystem.js'
  22. import type { PermissionDecisionReason } from './PermissionResult.js'
  23. const MAX_DIRS_TO_LIST = 5
  24. const GLOB_PATTERN_REGEX = /[*?[\]{}]/
  25. export type FileOperationType = 'read' | 'write' | 'create'
  26. export type PathCheckResult = {
  27. allowed: boolean
  28. decisionReason?: PermissionDecisionReason
  29. }
  30. export type ResolvedPathCheckResult = PathCheckResult & {
  31. resolvedPath: string
  32. }
  33. export function formatDirectoryList(directories: string[]): string {
  34. const dirCount = directories.length
  35. if (dirCount <= MAX_DIRS_TO_LIST) {
  36. return directories.map(dir => `'${dir}'`).join(', ')
  37. }
  38. const firstDirs = directories
  39. .slice(0, MAX_DIRS_TO_LIST)
  40. .map(dir => `'${dir}'`)
  41. .join(', ')
  42. return `${firstDirs}, and ${dirCount - MAX_DIRS_TO_LIST} more`
  43. }
  44. /**
  45. * Extracts the base directory from a glob pattern for validation.
  46. * For example: "/path/to/*.txt" returns "/path/to"
  47. */
  48. export function getGlobBaseDirectory(path: string): string {
  49. const globMatch = path.match(GLOB_PATTERN_REGEX)
  50. if (!globMatch || globMatch.index === undefined) {
  51. return path
  52. }
  53. // Get everything before the first glob character
  54. const beforeGlob = path.substring(0, globMatch.index)
  55. // Find the last directory separator
  56. const lastSepIndex =
  57. getPlatform() === 'windows'
  58. ? Math.max(beforeGlob.lastIndexOf('/'), beforeGlob.lastIndexOf('\\'))
  59. : beforeGlob.lastIndexOf('/')
  60. if (lastSepIndex === -1) return '.'
  61. return beforeGlob.substring(0, lastSepIndex) || '/'
  62. }
  63. /**
  64. * Expands tilde (~) at the start of a path to the user's home directory.
  65. * Note: ~username expansion is not supported for security reasons.
  66. */
  67. export function expandTilde(path: string): string {
  68. if (
  69. path === '~' ||
  70. path.startsWith('~/') ||
  71. (process.platform === 'win32' && path.startsWith('~\\'))
  72. ) {
  73. return homedir() + path.slice(1)
  74. }
  75. return path
  76. }
  77. /**
  78. * Checks if a resolved path is writable according to the sandbox write allowlist.
  79. * When the sandbox is enabled, the user has explicitly configured which directories
  80. * are writable. We treat these as additional allowed write directories for path
  81. * validation purposes, so commands like `echo foo > /tmp/claude/x.txt` don't
  82. * prompt for permission when /tmp/claude/ is already in the sandbox allowlist.
  83. *
  84. * Respects the deny-within-allow list: paths in denyWithinAllow (like
  85. * .claude/settings.json) are still blocked even if their parent is in allowOnly.
  86. */
  87. export function isPathInSandboxWriteAllowlist(resolvedPath: string): boolean {
  88. if (!SandboxManager.isSandboxingEnabled()) {
  89. return false
  90. }
  91. const { allowOnly, denyWithinAllow } = SandboxManager.getFsWriteConfig()
  92. // Resolve symlinks on both sides so comparisons are symmetric (matching
  93. // pathInAllowedWorkingPath). Without this, an allowlist entry that is a
  94. // symlink (e.g. /home/user/proj -> /data/proj) would not match a write to
  95. // its resolved target, causing an unnecessary prompt. Over-conservative,
  96. // not a security issue. All resolved input representations must be allowed
  97. // and none may be denied. Config paths are session-stable, so memoize
  98. // their resolution to avoid N × config.length redundant syscalls per
  99. // command with N write targets (matching getResolvedWorkingDirPaths).
  100. const pathsToCheck = getPathsForPermissionCheck(resolvedPath)
  101. const resolvedAllow = allowOnly.flatMap(getResolvedSandboxConfigPath) as string[]
  102. const resolvedDeny = denyWithinAllow.flatMap(getResolvedSandboxConfigPath) as string[]
  103. return pathsToCheck.every(p => {
  104. for (const denyPath of resolvedDeny) {
  105. if (pathInWorkingPath(p, denyPath)) return false
  106. }
  107. return resolvedAllow.some(allowPath => pathInWorkingPath(p, allowPath))
  108. })
  109. }
  110. // Sandbox config paths are session-stable; memoize their resolved forms to
  111. // avoid repeated lstat/realpath syscalls on every write-target check.
  112. // Matches the getResolvedWorkingDirPaths pattern in filesystem.ts.
  113. const getResolvedSandboxConfigPath = memoize(getPathsForPermissionCheck)
  114. /**
  115. * Checks if a resolved path is allowed for the given operation type.
  116. *
  117. * @param precomputedPathsToCheck - Optional cached result of
  118. * `getPathsForPermissionCheck(resolvedPath)`. When `resolvedPath` is the
  119. * output of `realpathSync` (canonical path, all symlinks resolved), this
  120. * is trivially `[resolvedPath]` and passing it here skips 5 redundant
  121. * syscalls per inner check. Do NOT pass this for non-canonical paths
  122. * (nonexistent files, UNC paths, etc.) — parent-directory symlink
  123. * resolution is still required for those.
  124. */
  125. export function isPathAllowed(
  126. resolvedPath: string,
  127. context: ToolPermissionContext,
  128. operationType: FileOperationType,
  129. precomputedPathsToCheck?: readonly string[],
  130. ): PathCheckResult {
  131. // Determine which permission type to check based on operation
  132. const permissionType = operationType === 'read' ? 'read' : 'edit'
  133. // 1. Check deny rules first (they take precedence)
  134. const denyRule = matchingRuleForInput(
  135. resolvedPath,
  136. context,
  137. permissionType,
  138. 'deny',
  139. )
  140. if (denyRule !== null) {
  141. return {
  142. allowed: false,
  143. decisionReason: { type: 'rule', rule: denyRule },
  144. }
  145. }
  146. // 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs)
  147. // This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory
  148. // and internal editable paths live under ~/.claude/ — matching the ordering in
  149. // checkWritePermissionForTool (filesystem.ts step 1.5)
  150. if (operationType !== 'read') {
  151. const internalEditResult = checkEditableInternalPath(resolvedPath, {})
  152. if (internalEditResult.behavior === 'allow') {
  153. return {
  154. allowed: true,
  155. decisionReason: internalEditResult.decisionReason,
  156. }
  157. }
  158. }
  159. // 2.5. For write/create operations, check comprehensive safety validations
  160. // This MUST come before checking working directory to prevent bypass via acceptEdits mode
  161. // Checks: Windows patterns, Claude config files, dangerous files (on original + symlink paths)
  162. if (operationType !== 'read') {
  163. const safetyCheck = checkPathSafetyForAutoEdit(
  164. resolvedPath,
  165. precomputedPathsToCheck,
  166. )
  167. if (!safetyCheck.safe) {
  168. const failedCheck = safetyCheck as { safe: false; message: string; classifierApprovable: boolean }
  169. return {
  170. allowed: false,
  171. decisionReason: {
  172. type: 'safetyCheck',
  173. reason: failedCheck.message,
  174. classifierApprovable: failedCheck.classifierApprovable,
  175. },
  176. }
  177. }
  178. }
  179. // 3. Check if path is in allowed working directory
  180. // For write/create operations, require acceptEdits mode to auto-allow
  181. // This is consistent with checkWritePermissionForTool in filesystem.ts
  182. const isInWorkingDir = pathInAllowedWorkingPath(
  183. resolvedPath,
  184. context,
  185. precomputedPathsToCheck,
  186. )
  187. if (isInWorkingDir) {
  188. if (operationType === 'read' || context.mode === 'acceptEdits') {
  189. return { allowed: true }
  190. }
  191. // Write/create without acceptEdits mode falls through to check allow rules
  192. }
  193. // 3.5. For read operations, check internal readable paths (project temp dir, session memory, etc.)
  194. // This allows reading agent output files without explicit permission
  195. if (operationType === 'read') {
  196. const internalReadResult = checkReadableInternalPath(resolvedPath, {})
  197. if (internalReadResult.behavior === 'allow') {
  198. return {
  199. allowed: true,
  200. decisionReason: internalReadResult.decisionReason,
  201. }
  202. }
  203. }
  204. // 3.7. For write/create operations to paths OUTSIDE the working directory,
  205. // check the sandbox write allowlist. When the sandbox is enabled, users
  206. // have explicitly configured writable directories (e.g. /tmp/claude/) —
  207. // treat these as additional allowed write directories so redirects/touch/
  208. // mkdir don't prompt unnecessarily. Safety checks (step 2) already ran.
  209. // Paths IN the working directory are intentionally excluded: the sandbox
  210. // allowlist always seeds '.' (cwd, see sandbox-adapter.ts), which would
  211. // bypass the acceptEdits gate at step 3. Step 3 handles those.
  212. if (
  213. operationType !== 'read' &&
  214. !isInWorkingDir &&
  215. isPathInSandboxWriteAllowlist(resolvedPath)
  216. ) {
  217. return {
  218. allowed: true,
  219. decisionReason: {
  220. type: 'other',
  221. reason: 'Path is in sandbox write allowlist',
  222. },
  223. }
  224. }
  225. // 4. Check allow rules for the operation type
  226. const allowRule = matchingRuleForInput(
  227. resolvedPath,
  228. context,
  229. permissionType,
  230. 'allow',
  231. )
  232. if (allowRule !== null) {
  233. return {
  234. allowed: true,
  235. decisionReason: { type: 'rule', rule: allowRule },
  236. }
  237. }
  238. // 5. Path is not allowed
  239. return { allowed: false }
  240. }
  241. /**
  242. * Validates a glob pattern by checking its base directory.
  243. * Returns the validation result for the base path where the glob would expand.
  244. */
  245. export function validateGlobPattern(
  246. cleanPath: string,
  247. cwd: string,
  248. toolPermissionContext: ToolPermissionContext,
  249. operationType: FileOperationType,
  250. ): ResolvedPathCheckResult {
  251. if (containsPathTraversal(cleanPath)) {
  252. // For patterns with path traversal, resolve the full path
  253. const absolutePath = isAbsolute(cleanPath)
  254. ? cleanPath
  255. : resolve(cwd, cleanPath)
  256. const { resolvedPath, isCanonical } = safeResolvePath(
  257. getFsImplementation(),
  258. absolutePath,
  259. )
  260. const result = isPathAllowed(
  261. resolvedPath,
  262. toolPermissionContext,
  263. operationType,
  264. isCanonical ? [resolvedPath] : undefined,
  265. )
  266. return {
  267. allowed: result.allowed,
  268. resolvedPath,
  269. decisionReason: result.decisionReason,
  270. }
  271. }
  272. const basePath = getGlobBaseDirectory(cleanPath)
  273. const absoluteBasePath = isAbsolute(basePath)
  274. ? basePath
  275. : resolve(cwd, basePath)
  276. const { resolvedPath, isCanonical } = safeResolvePath(
  277. getFsImplementation(),
  278. absoluteBasePath,
  279. )
  280. const result = isPathAllowed(
  281. resolvedPath,
  282. toolPermissionContext,
  283. operationType,
  284. isCanonical ? [resolvedPath] : undefined,
  285. )
  286. return {
  287. allowed: result.allowed,
  288. resolvedPath,
  289. decisionReason: result.decisionReason,
  290. }
  291. }
  292. const WINDOWS_DRIVE_ROOT_REGEX = /^[A-Za-z]:\/?$/
  293. const WINDOWS_DRIVE_CHILD_REGEX = /^[A-Za-z]:\/[^/]+$/
  294. /**
  295. * Checks if a resolved path is dangerous for removal operations (rm/rmdir).
  296. * Dangerous paths are:
  297. * - Wildcard '*' (removes all files in directory)
  298. * - Any path ending with '/*' or '\*' (e.g., /path/to/dir/*, C:\foo\*)
  299. * - Root directory (/)
  300. * - Home directory (~)
  301. * - Direct children of root (/usr, /tmp, /etc, etc.)
  302. * - Windows drive root (C:\, D:\) and direct children (C:\Windows, C:\Users)
  303. */
  304. export function isDangerousRemovalPath(resolvedPath: string): boolean {
  305. // Callers pass both slash forms; collapse runs so C:\\Windows (valid in
  306. // PowerShell) doesn't bypass the drive-child check.
  307. const forwardSlashed = resolvedPath.replace(/[\\/]+/g, '/')
  308. if (forwardSlashed === '*' || forwardSlashed.endsWith('/*')) {
  309. return true
  310. }
  311. const normalizedPath =
  312. forwardSlashed === '/' ? forwardSlashed : forwardSlashed.replace(/\/$/, '')
  313. if (normalizedPath === '/') {
  314. return true
  315. }
  316. if (WINDOWS_DRIVE_ROOT_REGEX.test(normalizedPath)) {
  317. return true
  318. }
  319. const normalizedHome = homedir().replace(/[\\/]+/g, '/')
  320. if (normalizedPath === normalizedHome) {
  321. return true
  322. }
  323. // Direct children of root: /usr, /tmp, /etc (but not /usr/local)
  324. const parentDir = dirname(normalizedPath)
  325. if (parentDir === '/') {
  326. return true
  327. }
  328. if (WINDOWS_DRIVE_CHILD_REGEX.test(normalizedPath)) {
  329. return true
  330. }
  331. return false
  332. }
  333. /**
  334. * Validates a file system path, handling tilde expansion and glob patterns.
  335. * Returns whether the path is allowed and the resolved path for error messages.
  336. */
  337. export function validatePath(
  338. path: string,
  339. cwd: string,
  340. toolPermissionContext: ToolPermissionContext,
  341. operationType: FileOperationType,
  342. ): ResolvedPathCheckResult {
  343. // Remove surrounding quotes if present
  344. const cleanPath = expandTilde(path.replace(/^['"]|['"]$/g, ''))
  345. // SECURITY: Block UNC paths that could leak credentials
  346. if (containsVulnerableUncPath(cleanPath)) {
  347. return {
  348. allowed: false,
  349. resolvedPath: cleanPath,
  350. decisionReason: {
  351. type: 'other',
  352. reason: 'UNC network paths require manual approval',
  353. },
  354. }
  355. }
  356. // SECURITY: Reject tilde variants (~user, ~+, ~-, ~N) that expandTilde doesn't handle.
  357. // expandTilde resolves ~ and ~/ to $HOME, but ~root, ~+, ~- etc. are left as literal
  358. // text and resolved as relative paths (e.g., /cwd/~root/.ssh/id_rsa).
  359. // The shell expands these differently (~root → /var/root, ~+ → $PWD, ~- → $OLDPWD),
  360. // creating a TOCTOU gap: we validate /cwd/~root/... but bash reads /var/root/...
  361. // This check is safe from false positives because expandTilde already converted
  362. // ~ and ~/ to absolute paths starting with /, so only unexpanded variants remain.
  363. if (cleanPath.startsWith('~')) {
  364. return {
  365. allowed: false,
  366. resolvedPath: cleanPath,
  367. decisionReason: {
  368. type: 'other',
  369. reason:
  370. 'Tilde expansion variants (~user, ~+, ~-) in paths require manual approval',
  371. },
  372. }
  373. }
  374. // SECURITY: Reject paths containing ANY shell expansion syntax ($ or % characters,
  375. // or paths starting with = which triggers Zsh equals expansion)
  376. // - $VAR (Unix/Linux environment variables like $HOME, $PWD)
  377. // - ${VAR} (brace expansion)
  378. // - $(cmd) (command substitution)
  379. // - %VAR% (Windows environment variables like %TEMP%, %USERPROFILE%)
  380. // - Nested combinations like $(echo $HOME)
  381. // - =cmd (Zsh equals expansion, e.g. =rg expands to /usr/bin/rg)
  382. // All of these are preserved as literal strings during validation but expanded
  383. // by the shell during execution, creating a TOCTOU vulnerability
  384. if (
  385. cleanPath.includes('$') ||
  386. cleanPath.includes('%') ||
  387. cleanPath.startsWith('=')
  388. ) {
  389. return {
  390. allowed: false,
  391. resolvedPath: cleanPath,
  392. decisionReason: {
  393. type: 'other',
  394. reason: 'Shell expansion syntax in paths requires manual approval',
  395. },
  396. }
  397. }
  398. // SECURITY: Block glob patterns in write/create operations
  399. // Write tools don't expand globs - they use paths literally.
  400. // Allowing globs in write operations could bypass security checks.
  401. // Example: /allowed/dir/*.txt would only validate /allowed/dir,
  402. // but the actual write would use the literal path with the *
  403. if (GLOB_PATTERN_REGEX.test(cleanPath)) {
  404. if (operationType === 'write' || operationType === 'create') {
  405. return {
  406. allowed: false,
  407. resolvedPath: cleanPath,
  408. decisionReason: {
  409. type: 'other',
  410. reason:
  411. 'Glob patterns are not allowed in write operations. Please specify an exact file path.',
  412. },
  413. }
  414. }
  415. // For read operations, validate the base directory where the glob would expand
  416. return validateGlobPattern(
  417. cleanPath,
  418. cwd,
  419. toolPermissionContext,
  420. operationType,
  421. )
  422. }
  423. // Resolve path
  424. const absolutePath = isAbsolute(cleanPath)
  425. ? cleanPath
  426. : resolve(cwd, cleanPath)
  427. const { resolvedPath, isCanonical } = safeResolvePath(
  428. getFsImplementation(),
  429. absolutePath,
  430. )
  431. const result = isPathAllowed(
  432. resolvedPath,
  433. toolPermissionContext,
  434. operationType,
  435. isCanonical ? [resolvedPath] : undefined,
  436. )
  437. return {
  438. allowed: result.allowed,
  439. resolvedPath,
  440. decisionReason: result.decisionReason,
  441. }
  442. }