source dump of claude code
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 1777 lines 62 kB view raw
1import { feature } from 'bun:bundle' 2import { randomBytes } from 'crypto' 3import ignore from 'ignore' 4import memoize from 'lodash-es/memoize.js' 5import { homedir, tmpdir } from 'os' 6import { join, normalize, posix, sep } from 'path' 7import { hasAutoMemPathOverride, isAutoMemPath } from 'src/memdir/paths.js' 8import { isAgentMemoryPath } from 'src/tools/AgentTool/agentMemory.js' 9import { 10 CLAUDE_FOLDER_PERMISSION_PATTERN, 11 FILE_EDIT_TOOL_NAME, 12 GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN, 13} from 'src/tools/FileEditTool/constants.js' 14import type { z } from 'zod/v4' 15import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' 16import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 17import type { AnyObject, Tool, ToolPermissionContext } from '../../Tool.js' 18import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' 19import { getCwd } from '../cwd.js' 20import { getClaudeConfigHomeDir } from '../envUtils.js' 21import { 22 getFsImplementation, 23 getPathsForPermissionCheck, 24} from '../fsOperations.js' 25import { 26 containsPathTraversal, 27 expandPath, 28 getDirectoryForPath, 29 sanitizePath, 30} from '../path.js' 31import { getPlanSlug, getPlansDirectory } from '../plans.js' 32import { getPlatform } from '../platform.js' 33import { getProjectDir } from '../sessionStorage.js' 34import { SETTING_SOURCES } from '../settings/constants.js' 35import { 36 getSettingsFilePathForSource, 37 getSettingsRootPathForSource, 38} from '../settings/settings.js' 39import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js' 40import { getToolResultsDir } from '../toolResultStorage.js' 41import { windowsPathToPosixPath } from '../windowsPaths.js' 42import type { 43 PermissionDecision, 44 PermissionResult, 45} from './PermissionResult.js' 46import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js' 47import { createReadRuleSuggestion } from './PermissionUpdate.js' 48import type { PermissionUpdate } from './PermissionUpdateSchema.js' 49import { getRuleByContentsForToolName } from './permissions.js' 50 51declare const MACRO: { VERSION: string } 52 53/** 54 * Dangerous files that should be protected from auto-editing. 55 * These files can be used for code execution or data exfiltration. 56 */ 57export const DANGEROUS_FILES = [ 58 '.gitconfig', 59 '.gitmodules', 60 '.bashrc', 61 '.bash_profile', 62 '.zshrc', 63 '.zprofile', 64 '.profile', 65 '.ripgreprc', 66 '.mcp.json', 67 '.claude.json', 68] as const 69 70/** 71 * Dangerous directories that should be protected from auto-editing. 72 * These directories contain sensitive configuration or executable files. 73 */ 74export const DANGEROUS_DIRECTORIES = [ 75 '.git', 76 '.vscode', 77 '.idea', 78 '.claude', 79] as const 80 81/** 82 * Normalizes a path for case-insensitive comparison. 83 * This prevents bypassing security checks using mixed-case paths on case-insensitive 84 * filesystems (macOS/Windows) like `.cLauDe/Settings.locaL.json`. 85 * 86 * We always normalize to lowercase regardless of platform for consistent security. 87 * @param path The path to normalize 88 * @returns The lowercase path for safe comparison 89 */ 90export function normalizeCaseForComparison(path: string): string { 91 return path.toLowerCase() 92} 93 94/** 95 * If filePath is inside a .claude/skills/{name}/ directory (project or global), 96 * return the skill name and a session-allow pattern scoped to just that skill. 97 * Used to offer a narrower "allow edits to this skill only" option in the 98 * permission dialog and SDK suggestions, so iterating on one skill doesn't 99 * require granting session access to all of .claude/ (settings.json, hooks/, etc.). 100 */ 101export function getClaudeSkillScope( 102 filePath: string, 103): { skillName: string; pattern: string } | null { 104 const absolutePath = expandPath(filePath) 105 const absolutePathLower = normalizeCaseForComparison(absolutePath) 106 107 const bases = [ 108 { 109 dir: expandPath(join(getOriginalCwd(), '.claude', 'skills')), 110 prefix: '/.claude/skills/', 111 }, 112 { 113 dir: expandPath(join(homedir(), '.claude', 'skills')), 114 prefix: '~/.claude/skills/', 115 }, 116 ] 117 118 for (const { dir, prefix } of bases) { 119 const dirLower = normalizeCaseForComparison(dir) 120 // Try both path separators (Windows paths may not be normalized to /) 121 for (const s of [sep, '/']) { 122 if (absolutePathLower.startsWith(dirLower + s.toLowerCase())) { 123 // Match on lowercase, but slice the ORIGINAL path so the skill name 124 // preserves case (pattern matching downstream is case-sensitive) 125 const rest = absolutePath.slice(dir.length + s.length) 126 const slash = rest.indexOf('/') 127 const bslash = sep === '\\' ? rest.indexOf('\\') : -1 128 const cut = 129 slash === -1 130 ? bslash 131 : bslash === -1 132 ? slash 133 : Math.min(slash, bslash) 134 // Require a separator: file must be INSIDE the skill dir, not a 135 // file directly under skills/ (no skill scope for that) 136 if (cut <= 0) return null 137 const skillName = rest.slice(0, cut) 138 // Reject traversal and empty. Use includes('..') not === '..' to 139 // match step 1.6's ruleContent.includes('..') guard: a skillName like 140 // 'v2..beta' would otherwise produce a suggestion step 1.7 emits but 141 // step 1.6 always rejects (dead suggestion, infinite re-prompt). 142 if (!skillName || skillName === '.' || skillName.includes('..')) { 143 return null 144 } 145 // Reject glob metacharacters. skillName is interpolated into a 146 // gitignore pattern consumed by ignore().add() in matchingRuleForInput 147 // at step 1.6. A directory literally named '*' (valid on POSIX) would 148 // produce '/.claude/skills/*/**' which matches ALL skills. Return null 149 // to fall through to generateSuggestions() instead. 150 if (/[*?[\]]/.test(skillName)) return null 151 return { skillName, pattern: prefix + skillName + '/**' } 152 } 153 } 154 } 155 156 return null 157} 158 159// Always use / as the path separator per gitignore spec 160// https://git-scm.com/docs/gitignore 161const DIR_SEP = posix.sep 162 163/** 164 * Cross-platform relative path calculation that returns POSIX-style paths. 165 * Handles Windows path conversion internally. 166 * @param from The base path 167 * @param to The target path 168 * @returns A POSIX-style relative path 169 */ 170export function relativePath(from: string, to: string): string { 171 if (getPlatform() === 'windows') { 172 // Convert Windows paths to POSIX for consistent comparison 173 const posixFrom = windowsPathToPosixPath(from) 174 const posixTo = windowsPathToPosixPath(to) 175 return posix.relative(posixFrom, posixTo) 176 } 177 // Use POSIX paths directly 178 return posix.relative(from, to) 179} 180 181/** 182 * Converts a path to POSIX format for pattern matching. 183 * Handles Windows path conversion internally. 184 * @param path The path to convert 185 * @returns A POSIX-style path 186 */ 187export function toPosixPath(path: string): string { 188 if (getPlatform() === 'windows') { 189 return windowsPathToPosixPath(path) 190 } 191 return path 192} 193 194function getSettingsPaths(): string[] { 195 return SETTING_SOURCES.map(source => 196 getSettingsFilePathForSource(source), 197 ).filter(path => path !== undefined) 198} 199 200export function isClaudeSettingsPath(filePath: string): boolean { 201 // SECURITY: Normalize path structure first to prevent bypass via redundant ./ 202 // sequences like `./.claude/./settings.json` which would evade the endsWith() check 203 const expandedPath = expandPath(filePath) 204 205 // Normalize for case-insensitive comparison to prevent bypassing security 206 // with paths like .cLauDe/Settings.locaL.json 207 const normalizedPath = normalizeCaseForComparison(expandedPath) 208 209 // Use platform separator so endsWith checks work on both Unix (/) and Windows (\) 210 if ( 211 normalizedPath.endsWith(`${sep}.claude${sep}settings.json`) || 212 normalizedPath.endsWith(`${sep}.claude${sep}settings.local.json`) 213 ) { 214 // Include .claude/settings.json even for other projects 215 return true 216 } 217 // Check for current project's settings files (including managed settings and CLI args) 218 // Both paths are now absolute and normalized for consistent comparison 219 return getSettingsPaths().some( 220 settingsPath => normalizeCaseForComparison(settingsPath) === normalizedPath, 221 ) 222} 223 224// Always ask when Claude Code tries to edit its own config files 225function isClaudeConfigFilePath(filePath: string): boolean { 226 if (isClaudeSettingsPath(filePath)) { 227 return true 228 } 229 230 // Check if file is within .claude/commands or .claude/agents directories 231 // using proper path segment validation (not string matching with includes()) 232 // pathInWorkingPath now handles case-insensitive comparison to prevent bypasses 233 const commandsDir = join(getOriginalCwd(), '.claude', 'commands') 234 const agentsDir = join(getOriginalCwd(), '.claude', 'agents') 235 const skillsDir = join(getOriginalCwd(), '.claude', 'skills') 236 237 return ( 238 pathInWorkingPath(filePath, commandsDir) || 239 pathInWorkingPath(filePath, agentsDir) || 240 pathInWorkingPath(filePath, skillsDir) 241 ) 242} 243 244// Check if file is the plan file for the current session 245function isSessionPlanFile(absolutePath: string): boolean { 246 // Check if path is a plan file for this session (main or agent-specific) 247 // Main plan file: {plansDir}/{planSlug}.md 248 // Agent plan file: {plansDir}/{planSlug}-agent-{agentId}.md 249 const expectedPrefix = join(getPlansDirectory(), getPlanSlug()) 250 // SECURITY: Normalize to prevent path traversal bypasses via .. segments 251 const normalizedPath = normalize(absolutePath) 252 return ( 253 normalizedPath.startsWith(expectedPrefix) && normalizedPath.endsWith('.md') 254 ) 255} 256 257/** 258 * Returns the session memory directory path for the current session with trailing separator. 259 * Path format: {projectDir}/{sessionId}/session-memory/ 260 */ 261export function getSessionMemoryDir(): string { 262 return join(getProjectDir(getCwd()), getSessionId(), 'session-memory') + sep 263} 264 265/** 266 * Returns the session memory file path for the current session. 267 * Path format: {projectDir}/{sessionId}/session-memory/summary.md 268 */ 269export function getSessionMemoryPath(): string { 270 return join(getSessionMemoryDir(), 'summary.md') 271} 272 273// Check if file is within the session memory directory 274function isSessionMemoryPath(absolutePath: string): boolean { 275 // SECURITY: Normalize to prevent path traversal bypasses via .. segments 276 const normalizedPath = normalize(absolutePath) 277 return normalizedPath.startsWith(getSessionMemoryDir()) 278} 279 280/** 281 * Check if file is within the current project's directory. 282 * Path format: ~/.claude/projects/{sanitized-cwd}/... 283 */ 284function isProjectDirPath(absolutePath: string): boolean { 285 const projectDir = getProjectDir(getCwd()) 286 // SECURITY: Normalize to prevent path traversal bypasses via .. segments 287 const normalizedPath = normalize(absolutePath) 288 return ( 289 normalizedPath === projectDir || normalizedPath.startsWith(projectDir + sep) 290 ) 291} 292 293/** 294 * Checks if the scratchpad directory feature is enabled. 295 * The scratchpad is a per-session directory for Claude to write temporary files. 296 * Controlled by the tengu_scratch Statsig gate. 297 */ 298export function isScratchpadEnabled(): boolean { 299 return checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_scratch') 300} 301 302/** 303 * Returns the user-specific Claude temp directory name. 304 * On Unix: 'claude-{uid}' to prevent multi-user permission conflicts 305 * On Windows: 'claude' (tmpdir() is already per-user) 306 */ 307export function getClaudeTempDirName(): string { 308 if (getPlatform() === 'windows') { 309 return 'claude' 310 } 311 // Use UID to create per-user directories, preventing permission conflicts 312 // when multiple users share the same /tmp directory 313 const uid = process.getuid?.() ?? 0 314 return `claude-${uid}` 315} 316 317/** 318 * Returns the Claude temp directory path with symlinks resolved. 319 * Uses TMPDIR env var if set, otherwise: 320 * - On Unix: /tmp/claude-{uid}/ (resolved to /private/tmp/claude-{uid}/ on macOS) 321 * - On Windows: {tmpdir}/claude/ (e.g., C:\Users\{user}\AppData\Local\Temp\claude\) 322 * This is a per-user temporary directory used by Claude Code for all temp files. 323 * 324 * NOTE: We resolve symlinks to ensure this path matches the resolved paths used 325 * in permission checks. On macOS, /tmp is a symlink to /private/tmp, so without 326 * resolution, paths like /tmp/claude-{uid}/... wouldn't match /private/tmp/claude-{uid}/... 327 */ 328// Memoized: called per-tool from permission checks (yoloClassifier, sandbox-adapter) 329// and per-turn from BashTool prompt. Inputs (CLAUDE_CODE_TMPDIR env + platform) are 330// fixed at startup, and the realpath of the system tmp dir does not change mid-session. 331export const getClaudeTempDir = memoize(function getClaudeTempDir(): string { 332 const baseTmpDir = 333 process.env.CLAUDE_CODE_TMPDIR || 334 (getPlatform() === 'windows' ? tmpdir() : '/tmp') 335 336 // Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS) 337 // This ensures the path matches resolved paths in permission checks 338 const fs = getFsImplementation() 339 let resolvedBaseTmpDir = baseTmpDir 340 try { 341 resolvedBaseTmpDir = fs.realpathSync(baseTmpDir) 342 } catch { 343 // If resolution fails, use the original path 344 } 345 346 return join(resolvedBaseTmpDir, getClaudeTempDirName()) + sep 347}) 348 349/** 350 * Root for bundled-skill file extraction (see bundledSkills.ts). 351 * 352 * SECURITY: The per-process random nonce is the load-bearing defense here. 353 * Every other path component (uid, VERSION, skill name, file keys) is public 354 * knowledge, so without it a local attacker can pre-create the tree on a 355 * shared /tmp — sticky bit prevents deletion, not creation — and either 356 * symlink an intermediate directory (O_NOFOLLOW only checks the final 357 * component) or own a parent dir and swap file contents post-write for prompt 358 * injection via the read allowlist. diskOutput.ts gets the same property from 359 * the session-ID UUID in its path. 360 * 361 * Memoized so the extraction writes and the permission check agree on the 362 * path for the life of the process. Version-scoped so stale extractions from 363 * other binaries don't fall under the allowlist. 364 */ 365export const getBundledSkillsRoot = memoize( 366 function getBundledSkillsRoot(): string { 367 const nonce = randomBytes(16).toString('hex') 368 return join(getClaudeTempDir(), 'bundled-skills', MACRO.VERSION, nonce) 369 }, 370) 371 372/** 373 * Returns the project temp directory path with trailing separator. 374 * Path format: /tmp/claude-{uid}/{sanitized-cwd}/ 375 */ 376export function getProjectTempDir(): string { 377 return join(getClaudeTempDir(), sanitizePath(getOriginalCwd())) + sep 378} 379 380/** 381 * Returns the scratchpad directory path for the current session. 382 * Path format: /tmp/claude-{uid}/{sanitized-cwd}/{sessionId}/scratchpad/ 383 */ 384export function getScratchpadDir(): string { 385 return join(getProjectTempDir(), getSessionId(), 'scratchpad') 386} 387 388/** 389 * Ensures the scratchpad directory exists for the current session. 390 * Creates the directory with secure permissions (0o700) if it doesn't exist. 391 * Returns the path to the scratchpad directory. 392 * @throws If scratchpad feature is not enabled 393 */ 394export async function ensureScratchpadDir(): Promise<string> { 395 if (!isScratchpadEnabled()) { 396 throw new Error('Scratchpad directory feature is not enabled') 397 } 398 399 const fs = getFsImplementation() 400 const scratchpadDir = getScratchpadDir() 401 402 // Create directory recursively with secure permissions (owner-only access) 403 // FsOperations.mkdir handles recursive: true internally and is a no-op if dir exists 404 await fs.mkdir(scratchpadDir, { mode: 0o700 }) 405 406 return scratchpadDir 407} 408 409// Check if file is within the scratchpad directory 410function isScratchpadPath(absolutePath: string): boolean { 411 if (!isScratchpadEnabled()) { 412 return false 413 } 414 const scratchpadDir = getScratchpadDir() 415 // SECURITY: Normalize the path to resolve .. segments before checking 416 // This prevents path traversal bypasses like: 417 // echo "malicious" > /tmp/claude-0/proj/session/scratchpad/../../../etc/passwd 418 // Without normalization, the path would pass the startsWith check but write to /etc/passwd 419 const normalizedPath = normalize(absolutePath) 420 return ( 421 normalizedPath === scratchpadDir || 422 normalizedPath.startsWith(scratchpadDir + sep) 423 ) 424} 425 426/** 427 * Check if a file path is dangerous to auto-edit without explicit permission. 428 * This includes: 429 * - Files in .git directories or .gitconfig files (to prevent git-based data exfiltration and code execution) 430 * - Files in .vscode directories (to prevent VS Code settings manipulation and potential code execution) 431 * - Files in .idea directories (to prevent JetBrains IDE settings manipulation) 432 * - Shell configuration files (to prevent shell startup script manipulation) 433 * - UNC paths (to prevent network file access and WebDAV attacks) 434 */ 435function isDangerousFilePathToAutoEdit(path: string): boolean { 436 const absolutePath = expandPath(path) 437 const pathSegments = absolutePath.split(sep) 438 const fileName = pathSegments.at(-1) 439 440 // Check for UNC paths (defense-in-depth to catch any patterns that might not be caught by containsVulnerableUncPath) 441 // Block anything starting with \\ or // as these are potentially UNC paths that could access network resources 442 if (path.startsWith('\\\\') || path.startsWith('//')) { 443 return true 444 } 445 446 // Check if path is within dangerous directories (case-insensitive to prevent bypasses) 447 for (let i = 0; i < pathSegments.length; i++) { 448 const segment = pathSegments[i]! 449 const normalizedSegment = normalizeCaseForComparison(segment) 450 451 for (const dir of DANGEROUS_DIRECTORIES) { 452 if (normalizedSegment !== normalizeCaseForComparison(dir)) { 453 continue 454 } 455 456 // Special case: .claude/worktrees/ is a structural path (where Claude stores 457 // git worktrees), not a user-created dangerous directory. Skip the .claude 458 // segment when it's followed by 'worktrees'. Any nested .claude directories 459 // within the worktree (not followed by 'worktrees') are still blocked. 460 if (dir === '.claude') { 461 const nextSegment = pathSegments[i + 1] 462 if ( 463 nextSegment && 464 normalizeCaseForComparison(nextSegment) === 'worktrees' 465 ) { 466 break // Skip this .claude, continue checking other segments 467 } 468 } 469 470 return true 471 } 472 } 473 474 // Check for dangerous configuration files (case-insensitive) 475 if (fileName) { 476 const normalizedFileName = normalizeCaseForComparison(fileName) 477 if ( 478 (DANGEROUS_FILES as readonly string[]).some( 479 dangerousFile => 480 normalizeCaseForComparison(dangerousFile) === normalizedFileName, 481 ) 482 ) { 483 return true 484 } 485 } 486 487 return false 488} 489 490/** 491 * Detects suspicious Windows path patterns that could bypass security checks. 492 * These patterns include: 493 * - NTFS Alternate Data Streams (e.g., file.txt::$DATA or file.txt:stream) 494 * - 8.3 short names (e.g., GIT~1, CLAUDE~1, SETTIN~1.JSON) 495 * - Long path prefixes (e.g., \\?\C:\..., \\.\C:\..., //?/C:/..., //./C:/...) 496 * - Trailing dots and spaces (e.g., .git., .claude , .bashrc...) 497 * - DOS device names (e.g., .git.CON, settings.json.PRN, .bashrc.AUX) 498 * - Three or more consecutive dots (e.g., .../file.txt, path/.../file, file...txt) 499 * 500 * When detected, these paths should always require manual approval to prevent 501 * bypassing security checks through path canonicalization vulnerabilities. 502 * 503 * ## Why Check on All Platforms? 504 * 505 * While these patterns are primarily Windows-specific, NTFS filesystems can be 506 * mounted on Linux and macOS (e.g., using ntfs-3g). On these systems, the same 507 * bypass techniques would work - an attacker could use short names or long path 508 * prefixes to bypass security checks. Therefore, we check for these patterns on 509 * all platforms to ensure comprehensive protection. (Note: the ADS colon check 510 * is Windows/WSL-only, since colon syntax is only interpreted by the Windows 511 * kernel; on Linux/macOS, NTFS ADS is accessed via xattrs, not colon syntax.) 512 * 513 * ## Why Detection Instead of Normalization? 514 * 515 * An alternative approach would be to normalize these paths using Windows APIs 516 * (e.g., GetLongPathNameW). However, this approach has significant challenges: 517 * 518 * 1. **Filesystem dependency**: Short path normalization is relative to files that 519 * currently exist on the filesystem. This creates issues when writing to new 520 * files since they don't exist yet and cannot be normalized. 521 * 522 * 2. **Race conditions**: The filesystem state can change between normalization 523 * and actual file access, creating TOCTOU (Time-Of-Check-Time-Of-Use) vulnerabilities. 524 * 525 * 3. **Complexity**: Proper normalization requires Windows-specific APIs, handling 526 * multiple edge cases, and dealing with various path formats (UNC, device paths, etc.). 527 * 528 * 4. **Reliability**: Pattern detection is more predictable and doesn't depend on 529 * external system state. 530 * 531 * If you are considering adding normalization for these paths, please reach out to 532 * AppSec first to discuss the security implications and implementation approach. 533 * 534 * @param path The path to check for suspicious patterns 535 * @returns true if suspicious Windows path patterns are detected 536 */ 537function hasSuspiciousWindowsPathPattern(path: string): boolean { 538 // Check for NTFS Alternate Data Streams 539 // Look for ':' after position 2 to skip drive letters (e.g., C:\) 540 // Examples: file.txt::$DATA, .bashrc:hidden, settings.json:stream 541 // Note: ADS colon syntax is only interpreted by the Windows kernel. On WSL, 542 // DrvFs mounts route file operations through the Windows kernel, so colon 543 // syntax is still interpreted as ADS separators. On Linux/macOS (non-WSL), 544 // even when NTFS is mounted, ADS is accessed via xattrs (ntfs-3g) not colon 545 // syntax, and colons are valid filename characters. 546 if (getPlatform() === 'windows' || getPlatform() === 'wsl') { 547 const colonIndex = path.indexOf(':', 2) 548 if (colonIndex !== -1) { 549 return true 550 } 551 } 552 553 // Check for 8.3 short names 554 // Look for '~' followed by a digit 555 // Examples: GIT~1, CLAUDE~1, SETTIN~1.JSON, BASHRC~1 556 if (/~\d/.test(path)) { 557 return true 558 } 559 560 // Check for long path prefixes (both backslash and forward slash variants) 561 // Examples: \\?\C:\Users\..., \\.\C:\..., //?/C:/..., //./C:/... 562 if ( 563 path.startsWith('\\\\?\\') || 564 path.startsWith('\\\\.\\') || 565 path.startsWith('//?/') || 566 path.startsWith('//./') 567 ) { 568 return true 569 } 570 571 // Check for trailing dots and spaces that Windows strips during path resolution 572 // Examples: .git., .claude , .bashrc..., settings.json. 573 // This can bypass string matching if ".git" is blocked but ".git." is used 574 if (/[.\s]+$/.test(path)) { 575 return true 576 } 577 578 // Check for DOS device names that Windows treats as special devices 579 // Examples: .git.CON, settings.json.PRN, .bashrc.AUX 580 // Device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9 581 if (/\.(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(path)) { 582 return true 583 } 584 585 // Check for three or more consecutive dots (...) when used as a path component 586 // This pattern can be used to bypass security checks or create confusion 587 // Examples: .../file.txt, path/.../file 588 // Only block when dots are preceded AND followed by path separators (/ or \) 589 // This allows legitimate uses like Next.js catch-all routes [...]name] 590 if (/(^|\/|\\)\.{3,}(\/|\\|$)/.test(path)) { 591 return true 592 } 593 594 // Check for UNC paths (on all platforms for defense-in-depth) 595 // Examples: \\server\share, \\foo.com\file, //server/share, \\192.168.1.1\share 596 // UNC paths can access remote resources, leak credentials, and bypass working directory restrictions 597 if (containsVulnerableUncPath(path)) { 598 return true 599 } 600 601 return false 602} 603 604/** 605 * Checks if a path is safe for auto-editing (acceptEdits mode). 606 * Returns information about why the path is unsafe, or null if all checks pass. 607 * 608 * This function performs comprehensive safety checks including: 609 * - Suspicious Windows path patterns (NTFS streams, 8.3 names, long path prefixes, etc.) 610 * - Claude config files (.claude/settings.json, .claude/commands/, .claude/agents/) 611 * - MCP CLI state files (managed internally by Claude Code) 612 * - Dangerous files (.bashrc, .gitconfig, .git/, .vscode/, .idea/, etc.) 613 * 614 * IMPORTANT: This function checks BOTH the original path AND resolved symlink paths 615 * to prevent bypasses via symlinks pointing to protected files. 616 * 617 * @param path The path to check for safety 618 * @returns Object with safe=false and message if unsafe, or { safe: true } if all checks pass 619 */ 620export function checkPathSafetyForAutoEdit( 621 path: string, 622 precomputedPathsToCheck?: readonly string[], 623): 624 | { safe: true } 625 | { safe: false; message: string; classifierApprovable: boolean } { 626 // Get all paths to check (original + symlink resolved paths) 627 const pathsToCheck = 628 precomputedPathsToCheck ?? getPathsForPermissionCheck(path) 629 630 // Check for suspicious Windows path patterns on all paths 631 for (const pathToCheck of pathsToCheck) { 632 if (hasSuspiciousWindowsPathPattern(pathToCheck)) { 633 return { 634 safe: false, 635 message: `Claude requested permissions to write to ${path}, which contains a suspicious Windows path pattern that requires manual approval.`, 636 classifierApprovable: false, 637 } 638 } 639 } 640 641 // Check for Claude config files on all paths 642 for (const pathToCheck of pathsToCheck) { 643 if (isClaudeConfigFilePath(pathToCheck)) { 644 return { 645 safe: false, 646 message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`, 647 classifierApprovable: true, 648 } 649 } 650 } 651 652 // Check for dangerous files on all paths 653 for (const pathToCheck of pathsToCheck) { 654 if (isDangerousFilePathToAutoEdit(pathToCheck)) { 655 return { 656 safe: false, 657 message: `Claude requested permissions to edit ${path} which is a sensitive file.`, 658 classifierApprovable: true, 659 } 660 } 661 } 662 663 // All safety checks passed 664 return { safe: true } 665} 666 667export function allWorkingDirectories( 668 context: ToolPermissionContext, 669): Set<string> { 670 return new Set([ 671 getOriginalCwd(), 672 ...context.additionalWorkingDirectories.keys(), 673 ]) 674} 675 676// Working directories are session-stable; memoize their resolved forms to 677// avoid repeated existsSync/lstatSync/realpathSync syscalls on every 678// permission check. Keyed by path string — getPathsForPermissionCheck is 679// deterministic for existing directories within a session. 680// Exported for test/preload.ts cache clearing (shard-isolation). 681export const getResolvedWorkingDirPaths = memoize(getPathsForPermissionCheck) 682 683export function pathInAllowedWorkingPath( 684 path: string, 685 toolPermissionContext: ToolPermissionContext, 686 precomputedPathsToCheck?: readonly string[], 687): boolean { 688 // Check both the original path and the resolved symlink path 689 const pathsToCheck = 690 precomputedPathsToCheck ?? getPathsForPermissionCheck(path) 691 692 // Resolve working directories the same way we resolve input paths so 693 // comparisons are symmetric. Without this, a resolved input path 694 // (e.g. /System/Volumes/Data/home/... on macOS) would not match an 695 // unresolved working directory (/home/...), causing false denials. 696 const workingPaths = Array.from( 697 allWorkingDirectories(toolPermissionContext), 698 ).flatMap(wp => getResolvedWorkingDirPaths(wp)) 699 700 // All paths must be within allowed working paths 701 // If any resolved path is outside, deny access 702 return pathsToCheck.every(pathToCheck => 703 workingPaths.some(workingPath => 704 pathInWorkingPath(pathToCheck, workingPath), 705 ), 706 ) 707} 708 709export function pathInWorkingPath(path: string, workingPath: string): boolean { 710 const absolutePath = expandPath(path) 711 const absoluteWorkingPath = expandPath(workingPath) 712 713 // On macOS, handle common symlink issues: 714 // - /var -> /private/var 715 // - /tmp -> /private/tmp 716 const normalizedPath = absolutePath 717 .replace(/^\/private\/var\//, '/var/') 718 .replace(/^\/private\/tmp(\/|$)/, '/tmp$1') 719 const normalizedWorkingPath = absoluteWorkingPath 720 .replace(/^\/private\/var\//, '/var/') 721 .replace(/^\/private\/tmp(\/|$)/, '/tmp$1') 722 723 // Normalize case for case-insensitive comparison to prevent bypassing security 724 // checks on case-insensitive filesystems (macOS/Windows) like .cLauDe/CoMmAnDs 725 const caseNormalizedPath = normalizeCaseForComparison(normalizedPath) 726 const caseNormalizedWorkingPath = normalizeCaseForComparison( 727 normalizedWorkingPath, 728 ) 729 730 // Use cross-platform relative path helper 731 const relative = relativePath(caseNormalizedWorkingPath, caseNormalizedPath) 732 733 // Same path 734 if (relative === '') { 735 return true 736 } 737 738 if (containsPathTraversal(relative)) { 739 return false 740 } 741 742 // Path is inside (relative path that doesn't go up) 743 return !posix.isAbsolute(relative) 744} 745 746function rootPathForSource(source: PermissionRuleSource): string { 747 switch (source) { 748 case 'cliArg': 749 case 'command': 750 case 'session': 751 return expandPath(getOriginalCwd()) 752 case 'userSettings': 753 case 'policySettings': 754 case 'projectSettings': 755 case 'localSettings': 756 case 'flagSettings': 757 return getSettingsRootPathForSource(source) 758 } 759} 760 761function prependDirSep(path: string): string { 762 return posix.join(DIR_SEP, path) 763} 764 765function normalizePatternToPath({ 766 patternRoot, 767 pattern, 768 rootPath, 769}: { 770 patternRoot: string 771 pattern: string 772 rootPath: string 773}): string | null { 774 // If the pattern root + pattern combination starts with our reference root 775 const fullPattern = posix.join(patternRoot, pattern) 776 if (patternRoot === rootPath) { 777 // If the pattern root exactly matches our reference root no need to change 778 return prependDirSep(pattern) 779 } else if (fullPattern.startsWith(`${rootPath}${DIR_SEP}`)) { 780 // Extract the relative part 781 const relativePart = fullPattern.slice(rootPath.length) 782 return prependDirSep(relativePart) 783 } else { 784 // Handle patterns that are inside the reference root but not starting with it 785 const relativePath = posix.relative(rootPath, patternRoot) 786 if ( 787 !relativePath || 788 relativePath.startsWith(`..${DIR_SEP}`) || 789 relativePath === '..' 790 ) { 791 // Pattern is outside the reference root, so it can be skipped 792 return null 793 } else { 794 const relativePattern = posix.join(relativePath, pattern) 795 return prependDirSep(relativePattern) 796 } 797 } 798} 799 800export function normalizePatternsToPath( 801 patternsByRoot: Map<string | null, string[]>, 802 root: string, 803): string[] { 804 // null root means the pattern can match anywhere 805 const result = new Set(patternsByRoot.get(null) ?? []) 806 807 for (const [patternRoot, patterns] of patternsByRoot.entries()) { 808 if (patternRoot === null) { 809 // already added 810 continue 811 } 812 813 // Check each pattern to see if the full path starts with our reference root 814 for (const pattern of patterns) { 815 const normalizedPattern = normalizePatternToPath({ 816 patternRoot, 817 pattern, 818 rootPath: root, 819 }) 820 if (normalizedPattern) { 821 result.add(normalizedPattern) 822 } 823 } 824 } 825 return Array.from(result) 826} 827 828/** 829 * Collects all deny rules for file read permissions and returns their ignore patterns 830 * Each pattern must be resolved relative to its root (map key) 831 * Null keys are used for patterns that don't have a root 832 * 833 * This is used to hide files that are blocked by Read deny rules. 834 * 835 * @param toolPermissionContext 836 */ 837export function getFileReadIgnorePatterns( 838 toolPermissionContext: ToolPermissionContext, 839): Map<string | null, string[]> { 840 const patternsByRoot = getPatternsByRoot( 841 toolPermissionContext, 842 'read', 843 'deny', 844 ) 845 const result = new Map<string | null, string[]>() 846 for (const [patternRoot, patternMap] of patternsByRoot.entries()) { 847 result.set(patternRoot, Array.from(patternMap.keys())) 848 } 849 850 return result 851} 852 853function patternWithRoot( 854 pattern: string, 855 source: PermissionRuleSource, 856): { 857 relativePattern: string 858 root: string | null 859} { 860 if (pattern.startsWith(`${DIR_SEP}${DIR_SEP}`)) { 861 // Patterns starting with // resolve relative to / 862 const patternWithoutDoubleSlash = pattern.slice(1) 863 864 // On Windows, check if this is a POSIX-style drive path like //c/Users/... 865 // Note: UNC paths (//server/share) will not match this regex and will be treated 866 // as root-relative patterns, which may need separate handling in the future 867 if ( 868 getPlatform() === 'windows' && 869 patternWithoutDoubleSlash.match(/^\/[a-z]\//i) 870 ) { 871 // Convert POSIX path to Windows format 872 // The pattern is like /c/Users/... so we convert it to C:\Users\... 873 const driveLetter = patternWithoutDoubleSlash[1]?.toUpperCase() ?? 'C' 874 // Keep the pattern in POSIX format since relativePath returns POSIX paths 875 const pathAfterDrive = patternWithoutDoubleSlash.slice(2) 876 877 // Extract the drive root (C:\) and the rest of the pattern 878 const driveRoot = `${driveLetter}:\\` 879 const relativeFromDrive = pathAfterDrive.startsWith('/') 880 ? pathAfterDrive.slice(1) 881 : pathAfterDrive 882 883 return { 884 relativePattern: relativeFromDrive, 885 root: driveRoot, 886 } 887 } 888 889 return { 890 relativePattern: patternWithoutDoubleSlash, 891 root: DIR_SEP, 892 } 893 } else if (pattern.startsWith(`~${DIR_SEP}`)) { 894 // Patterns starting with ~/ resolve relative to homedir 895 return { 896 relativePattern: pattern.slice(1), 897 root: homedir().normalize('NFC'), 898 } 899 } else if (pattern.startsWith(DIR_SEP)) { 900 // Patterns starting with / resolve relative to the directory where settings are stored (without .claude/) 901 return { 902 relativePattern: pattern, 903 root: rootPathForSource(source), 904 } 905 } 906 // No root specified, put it with all the other patterns 907 // Normalize patterns that start with "./" to remove the prefix 908 // This ensures that patterns like "./.env" match files like ".env" 909 let normalizedPattern = pattern 910 if (pattern.startsWith(`.${DIR_SEP}`)) { 911 normalizedPattern = pattern.slice(2) 912 } 913 return { 914 relativePattern: normalizedPattern, 915 root: null, 916 } 917} 918 919function getPatternsByRoot( 920 toolPermissionContext: ToolPermissionContext, 921 toolType: 'edit' | 'read', 922 behavior: 'allow' | 'deny' | 'ask', 923): Map<string | null, Map<string, PermissionRule>> { 924 const toolName = (() => { 925 switch (toolType) { 926 case 'edit': 927 // Apply Edit tool rules to any tool editing files 928 return FILE_EDIT_TOOL_NAME 929 case 'read': 930 // Apply Read tool rules to any tool reading files 931 return FILE_READ_TOOL_NAME 932 } 933 })() 934 935 const rules = getRuleByContentsForToolName( 936 toolPermissionContext, 937 toolName, 938 behavior, 939 ) 940 // Resolve rules relative to path based on source 941 const patternsByRoot = new Map<string | null, Map<string, PermissionRule>>() 942 for (const [pattern, rule] of rules.entries()) { 943 const { relativePattern, root } = patternWithRoot(pattern, rule.source) 944 let patternsForRoot = patternsByRoot.get(root) 945 if (patternsForRoot === undefined) { 946 patternsForRoot = new Map<string, PermissionRule>() 947 patternsByRoot.set(root, patternsForRoot) 948 } 949 // Store the rule keyed by the root 950 patternsForRoot.set(relativePattern, rule) 951 } 952 return patternsByRoot 953} 954 955export function matchingRuleForInput( 956 path: string, 957 toolPermissionContext: ToolPermissionContext, 958 toolType: 'edit' | 'read', 959 behavior: 'allow' | 'deny' | 'ask', 960): PermissionRule | null { 961 let fileAbsolutePath = expandPath(path) 962 963 // On Windows, convert to POSIX format to match against permission patterns 964 if (getPlatform() === 'windows' && fileAbsolutePath.includes('\\')) { 965 fileAbsolutePath = windowsPathToPosixPath(fileAbsolutePath) 966 } 967 968 const patternsByRoot = getPatternsByRoot( 969 toolPermissionContext, 970 toolType, 971 behavior, 972 ) 973 974 // Check each root for a matching pattern 975 for (const [root, patternMap] of patternsByRoot.entries()) { 976 // Transform patterns for the ignore library 977 const patterns = Array.from(patternMap.keys()).map(pattern => { 978 let adjustedPattern = pattern 979 980 // Remove /** suffix - ignore library treats 'path' as matching both 981 // the path itself and everything inside it 982 if (adjustedPattern.endsWith('/**')) { 983 adjustedPattern = adjustedPattern.slice(0, -3) 984 } 985 986 return adjustedPattern 987 }) 988 989 const ig = ignore().add(patterns) 990 991 // Use cross-platform relative path helper for POSIX-style patterns 992 const relativePathStr = relativePath( 993 root ?? getCwd(), 994 fileAbsolutePath ?? getCwd(), 995 ) 996 997 if (relativePathStr.startsWith(`..${DIR_SEP}`)) { 998 // The path is outside the root, so ignore it 999 continue 1000 } 1001 1002 // Important: ig.test throws if you give it an empty string 1003 if (!relativePathStr) { 1004 continue 1005 } 1006 1007 const igResult = ig.test(relativePathStr) 1008 1009 if (igResult.ignored && igResult.rule) { 1010 // Map the matched pattern back to the original rule 1011 const originalPattern = igResult.rule.pattern 1012 1013 // Check if this was a /** pattern we simplified 1014 const withWildcard = originalPattern + '/**' 1015 if (patternMap.has(withWildcard)) { 1016 return patternMap.get(withWildcard) ?? null 1017 } 1018 1019 return patternMap.get(originalPattern) ?? null 1020 } 1021 } 1022 1023 // No matching rule found 1024 return null 1025} 1026 1027/** 1028 * Permission result for read permission for the specified tool & tool input 1029 */ 1030export function checkReadPermissionForTool( 1031 tool: Tool, 1032 input: { [key: string]: unknown }, 1033 toolPermissionContext: ToolPermissionContext, 1034): PermissionDecision { 1035 if (typeof tool.getPath !== 'function') { 1036 return { 1037 behavior: 'ask', 1038 message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`, 1039 } 1040 } 1041 const path = tool.getPath(input) 1042 1043 // Get paths to check (includes both original and resolved symlinks). 1044 // Computed once here and threaded through checkWritePermissionForTool → 1045 // checkPathSafetyForAutoEdit → pathInAllowedWorkingPath to avoid redundant 1046 // existsSync/lstatSync/realpathSync syscalls on the same path (previously 1047 // 6× = 30 syscalls per Read permission check). 1048 const pathsToCheck = getPathsForPermissionCheck(path) 1049 1050 // 1. Defense-in-depth: Block UNC paths early (before other checks) 1051 // This catches paths starting with \\ or // that could access network resources 1052 // This may catch some UNC patterns not detected by containsVulnerableUncPath 1053 for (const pathToCheck of pathsToCheck) { 1054 if (pathToCheck.startsWith('\\\\') || pathToCheck.startsWith('//')) { 1055 return { 1056 behavior: 'ask', 1057 message: `Claude requested permissions to read from ${path}, which appears to be a UNC path that could access network resources.`, 1058 decisionReason: { 1059 type: 'other', 1060 reason: 'UNC path detected (defense-in-depth check)', 1061 }, 1062 } 1063 } 1064 } 1065 1066 // 2. Check for suspicious Windows path patterns (defense in depth) 1067 for (const pathToCheck of pathsToCheck) { 1068 if (hasSuspiciousWindowsPathPattern(pathToCheck)) { 1069 return { 1070 behavior: 'ask', 1071 message: `Claude requested permissions to read from ${path}, which contains a suspicious Windows path pattern that requires manual approval.`, 1072 decisionReason: { 1073 type: 'other', 1074 reason: 1075 'Path contains suspicious Windows-specific patterns (alternate data streams, short names, long path prefixes, or three or more consecutive dots) that require manual verification', 1076 }, 1077 } 1078 } 1079 } 1080 1081 // 3. Check for READ-SPECIFIC deny rules first - check both the original path and resolved symlink path 1082 // SECURITY: This must come before any allow checks (including "edit access implies read access") 1083 // to prevent bypassing explicit read deny rules 1084 for (const pathToCheck of pathsToCheck) { 1085 const denyRule = matchingRuleForInput( 1086 pathToCheck, 1087 toolPermissionContext, 1088 'read', 1089 'deny', 1090 ) 1091 if (denyRule) { 1092 return { 1093 behavior: 'deny', 1094 message: `Permission to read ${path} has been denied.`, 1095 decisionReason: { 1096 type: 'rule', 1097 rule: denyRule, 1098 }, 1099 } 1100 } 1101 } 1102 1103 // 4. Check for READ-SPECIFIC ask rules - check both the original path and resolved symlink path 1104 // SECURITY: This must come before implicit allow checks to ensure explicit ask rules are honored 1105 for (const pathToCheck of pathsToCheck) { 1106 const askRule = matchingRuleForInput( 1107 pathToCheck, 1108 toolPermissionContext, 1109 'read', 1110 'ask', 1111 ) 1112 if (askRule) { 1113 return { 1114 behavior: 'ask', 1115 message: `Claude requested permissions to read from ${path}, but you haven't granted it yet.`, 1116 decisionReason: { 1117 type: 'rule', 1118 rule: askRule, 1119 }, 1120 } 1121 } 1122 } 1123 1124 // 5. Edit access implies read access (but only if no read-specific deny/ask rules exist) 1125 // We check this after read-specific rules so that explicit read restrictions take precedence 1126 const editResult = checkWritePermissionForTool( 1127 tool, 1128 input, 1129 toolPermissionContext, 1130 pathsToCheck, 1131 ) 1132 if (editResult.behavior === 'allow') { 1133 return editResult 1134 } 1135 1136 // 6. Allow reads in working directories 1137 const isInWorkingDir = pathInAllowedWorkingPath( 1138 path, 1139 toolPermissionContext, 1140 pathsToCheck, 1141 ) 1142 if (isInWorkingDir) { 1143 return { 1144 behavior: 'allow', 1145 updatedInput: input, 1146 decisionReason: { 1147 type: 'mode', 1148 mode: 'default', 1149 }, 1150 } 1151 } 1152 1153 // 7. Allow reads from internal harness paths (session-memory, plans, tool-results) 1154 const absolutePath = expandPath(path) 1155 const internalReadResult = checkReadableInternalPath(absolutePath, input) 1156 if (internalReadResult.behavior !== 'passthrough') { 1157 return internalReadResult 1158 } 1159 1160 // 8. Check for allow rules 1161 const allowRule = matchingRuleForInput( 1162 path, 1163 toolPermissionContext, 1164 'read', 1165 'allow', 1166 ) 1167 if (allowRule) { 1168 return { 1169 behavior: 'allow', 1170 updatedInput: input, 1171 decisionReason: { 1172 type: 'rule', 1173 rule: allowRule, 1174 }, 1175 } 1176 } 1177 1178 // 12. Default to asking for permission 1179 // At this point, isInWorkingDir is false (from step #6), so path is outside working directories 1180 return { 1181 behavior: 'ask', 1182 message: `Claude requested permissions to read from ${path}, but you haven't granted it yet.`, 1183 suggestions: generateSuggestions( 1184 path, 1185 'read', 1186 toolPermissionContext, 1187 pathsToCheck, 1188 ), 1189 decisionReason: { 1190 type: 'workingDir', 1191 reason: 'Path is outside allowed working directories', 1192 }, 1193 } 1194} 1195 1196/** 1197 * Permission result for write permission for the specified tool & tool input. 1198 * 1199 * @param precomputedPathsToCheck - Optional cached result of 1200 * `getPathsForPermissionCheck(tool.getPath(input))`. Callers MUST derive this 1201 * from the same `tool` and `input` in the same synchronous frame — `path` is 1202 * re-derived internally for error messages and internal-path checks, so a 1203 * stale value would silently check deny rules for the wrong path. 1204 */ 1205export function checkWritePermissionForTool<Input extends AnyObject>( 1206 tool: Tool<Input>, 1207 input: z.infer<Input>, 1208 toolPermissionContext: ToolPermissionContext, 1209 precomputedPathsToCheck?: readonly string[], 1210): PermissionDecision { 1211 if (typeof tool.getPath !== 'function') { 1212 return { 1213 behavior: 'ask', 1214 message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`, 1215 } 1216 } 1217 const path = tool.getPath(input) 1218 1219 // 1. Check for deny rules - check both the original path and resolved symlink path 1220 const pathsToCheck = 1221 precomputedPathsToCheck ?? getPathsForPermissionCheck(path) 1222 for (const pathToCheck of pathsToCheck) { 1223 const denyRule = matchingRuleForInput( 1224 pathToCheck, 1225 toolPermissionContext, 1226 'edit', 1227 'deny', 1228 ) 1229 if (denyRule) { 1230 return { 1231 behavior: 'deny', 1232 message: `Permission to edit ${path} has been denied.`, 1233 decisionReason: { 1234 type: 'rule', 1235 rule: denyRule, 1236 }, 1237 } 1238 } 1239 } 1240 1241 // 1.5. Allow writes to internal editable paths (plan files, scratchpad) 1242 // This MUST come before isDangerousFilePathToAutoEdit check since .claude is a dangerous directory 1243 const absolutePathForEdit = expandPath(path) 1244 const internalEditResult = checkEditableInternalPath( 1245 absolutePathForEdit, 1246 input, 1247 ) 1248 if (internalEditResult.behavior !== 'passthrough') { 1249 return internalEditResult 1250 } 1251 1252 // 1.6. Check for .claude/** allow rules BEFORE safety checks 1253 // This allows session-level permissions to bypass the safety blocks for .claude/ 1254 // We only allow this for session-level rules to prevent users from accidentally 1255 // permanently granting broad access to their .claude/ folder. 1256 // 1257 // matchingRuleForInput returns the first match across all sources. If the user 1258 // also has a broader Edit(.claude) rule in userSettings (e.g. from sandbox 1259 // write-allow conversion), that rule would be found first and its source check 1260 // below would fail. Scope the search to session-only rules so the dialog's 1261 // "allow Claude to edit its own settings for this session" option actually works. 1262 const claudeFolderAllowRule = matchingRuleForInput( 1263 path, 1264 { 1265 ...toolPermissionContext, 1266 alwaysAllowRules: { 1267 session: toolPermissionContext.alwaysAllowRules.session ?? [], 1268 }, 1269 }, 1270 'edit', 1271 'allow', 1272 ) 1273 if (claudeFolderAllowRule) { 1274 // Check if this rule is scoped under .claude/ (project or global). 1275 // Accepts both the broad patterns ('/.claude/**', '~/.claude/**') and 1276 // narrowed ones like '/.claude/skills/my-skill/**' so users can grant 1277 // session access to a single skill without also exposing settings.json 1278 // or hooks/. The rule already matched the path via matchingRuleForInput; 1279 // this is an additional scope check. Reject '..' to prevent a rule like 1280 // '/.claude/../**' from leaking this bypass outside .claude/. 1281 const ruleContent = claudeFolderAllowRule.ruleValue.ruleContent 1282 if ( 1283 ruleContent && 1284 (ruleContent.startsWith(CLAUDE_FOLDER_PERMISSION_PATTERN.slice(0, -2)) || 1285 ruleContent.startsWith( 1286 GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN.slice(0, -2), 1287 )) && 1288 !ruleContent.includes('..') && 1289 ruleContent.endsWith('/**') 1290 ) { 1291 return { 1292 behavior: 'allow', 1293 updatedInput: input, 1294 decisionReason: { 1295 type: 'rule', 1296 rule: claudeFolderAllowRule, 1297 }, 1298 } 1299 } 1300 } 1301 1302 // 1.7. Check comprehensive safety validations (Windows patterns, Claude config, dangerous files) 1303 // This MUST come before checking allow rules to prevent users from accidentally granting 1304 // permission to edit protected files 1305 const safetyCheck = checkPathSafetyForAutoEdit(path, pathsToCheck) 1306 if (!safetyCheck.safe) { 1307 // SDK suggestion: if under .claude/skills/{name}/, emit the narrowed 1308 // session-scoped addRules that step 1.6 will honor on the next call. 1309 // Everything else (.claude/settings.json, .git/, .vscode/, .idea/) falls 1310 // back to generateSuggestions — its setMode suggestion doesn't bypass 1311 // this check, but preserving it avoids a surprising empty array. 1312 const skillScope = getClaudeSkillScope(path) 1313 const safetySuggestions: PermissionUpdate[] = skillScope 1314 ? [ 1315 { 1316 type: 'addRules', 1317 rules: [ 1318 { 1319 toolName: FILE_EDIT_TOOL_NAME, 1320 ruleContent: skillScope.pattern, 1321 }, 1322 ], 1323 behavior: 'allow', 1324 destination: 'session', 1325 }, 1326 ] 1327 : generateSuggestions(path, 'write', toolPermissionContext, pathsToCheck) 1328 return { 1329 behavior: 'ask', 1330 message: safetyCheck.message, 1331 suggestions: safetySuggestions, 1332 decisionReason: { 1333 type: 'safetyCheck', 1334 reason: safetyCheck.message, 1335 classifierApprovable: safetyCheck.classifierApprovable, 1336 }, 1337 } 1338 } 1339 1340 // 2. Check for ask rules - check both the original path and resolved symlink path 1341 for (const pathToCheck of pathsToCheck) { 1342 const askRule = matchingRuleForInput( 1343 pathToCheck, 1344 toolPermissionContext, 1345 'edit', 1346 'ask', 1347 ) 1348 if (askRule) { 1349 return { 1350 behavior: 'ask', 1351 message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`, 1352 decisionReason: { 1353 type: 'rule', 1354 rule: askRule, 1355 }, 1356 } 1357 } 1358 } 1359 1360 // 3. If in acceptEdits or sandboxBashMode mode, allow all writes in original cwd 1361 const isInWorkingDir = pathInAllowedWorkingPath( 1362 path, 1363 toolPermissionContext, 1364 pathsToCheck, 1365 ) 1366 if (toolPermissionContext.mode === 'acceptEdits' && isInWorkingDir) { 1367 return { 1368 behavior: 'allow', 1369 updatedInput: input, 1370 decisionReason: { 1371 type: 'mode', 1372 mode: toolPermissionContext.mode, 1373 }, 1374 } 1375 } 1376 1377 // 4. Check for allow rules 1378 const allowRule = matchingRuleForInput( 1379 path, 1380 toolPermissionContext, 1381 'edit', 1382 'allow', 1383 ) 1384 if (allowRule) { 1385 return { 1386 behavior: 'allow', 1387 updatedInput: input, 1388 decisionReason: { 1389 type: 'rule', 1390 rule: allowRule, 1391 }, 1392 } 1393 } 1394 1395 // 5. Default to asking for permission 1396 return { 1397 behavior: 'ask', 1398 message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`, 1399 suggestions: generateSuggestions( 1400 path, 1401 'write', 1402 toolPermissionContext, 1403 pathsToCheck, 1404 ), 1405 decisionReason: !isInWorkingDir 1406 ? { 1407 type: 'workingDir', 1408 reason: 'Path is outside allowed working directories', 1409 } 1410 : undefined, 1411 } 1412} 1413 1414export function generateSuggestions( 1415 filePath: string, 1416 operationType: 'read' | 'write' | 'create', 1417 toolPermissionContext: ToolPermissionContext, 1418 precomputedPathsToCheck?: readonly string[], 1419): PermissionUpdate[] { 1420 const isOutsideWorkingDir = !pathInAllowedWorkingPath( 1421 filePath, 1422 toolPermissionContext, 1423 precomputedPathsToCheck, 1424 ) 1425 1426 if (operationType === 'read' && isOutsideWorkingDir) { 1427 // For read operations outside working directories, add Read rules 1428 // IMPORTANT: Include both the symlink path and resolved path so subsequent checks pass 1429 const dirPath = getDirectoryForPath(filePath) 1430 const dirsToAdd = getPathsForPermissionCheck(dirPath) 1431 1432 const suggestions = dirsToAdd 1433 .map(dir => createReadRuleSuggestion(dir, 'session')) 1434 .filter((s): s is PermissionUpdate => s !== undefined) 1435 1436 return suggestions 1437 } 1438 1439 // Only suggest setMode:acceptEdits when it would be an upgrade. In auto 1440 // mode the classifier already auto-approves edits; in bypassPermissions 1441 // everything is allowed; in acceptEdits it's a no-op. Suggesting it 1442 // anyway and having the SDK host apply it on "Always allow" silently 1443 // downgrades auto → acceptEdits, which then prompts for MCP/Bash. 1444 const shouldSuggestAcceptEdits = 1445 toolPermissionContext.mode === 'default' || 1446 toolPermissionContext.mode === 'plan' 1447 1448 if (operationType === 'write' || operationType === 'create') { 1449 const updates: PermissionUpdate[] = shouldSuggestAcceptEdits 1450 ? [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }] 1451 : [] 1452 1453 if (isOutsideWorkingDir) { 1454 // For write operations outside working directories, also add the directory 1455 // IMPORTANT: Include both the symlink path and resolved path so subsequent checks pass 1456 const dirPath = getDirectoryForPath(filePath) 1457 const dirsToAdd = getPathsForPermissionCheck(dirPath) 1458 1459 updates.push({ 1460 type: 'addDirectories', 1461 directories: dirsToAdd, 1462 destination: 'session', 1463 }) 1464 } 1465 1466 return updates 1467 } 1468 1469 // For read operations inside working directories, just change mode 1470 return shouldSuggestAcceptEdits 1471 ? [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }] 1472 : [] 1473} 1474 1475/** 1476 * Check if a path is an internal path that can be edited without permission. 1477 * Returns a PermissionResult - either 'allow' if matched, or 'passthrough' to continue checking. 1478 */ 1479export function checkEditableInternalPath( 1480 absolutePath: string, 1481 input: { [key: string]: unknown }, 1482): PermissionResult { 1483 // SECURITY: Normalize path to prevent traversal bypasses via .. segments 1484 // This is defense-in-depth; individual helper functions also normalize 1485 const normalizedPath = normalize(absolutePath) 1486 1487 // Plan files for current session 1488 if (isSessionPlanFile(normalizedPath)) { 1489 return { 1490 behavior: 'allow', 1491 updatedInput: input, 1492 decisionReason: { 1493 type: 'other', 1494 reason: 'Plan files for current session are allowed for writing', 1495 }, 1496 } 1497 } 1498 1499 // Scratchpad directory for current session 1500 if (isScratchpadPath(normalizedPath)) { 1501 return { 1502 behavior: 'allow', 1503 updatedInput: input, 1504 decisionReason: { 1505 type: 'other', 1506 reason: 'Scratchpad files for current session are allowed for writing', 1507 }, 1508 } 1509 } 1510 1511 // Template job's own directory. Env key hardcoded (vs importing JOB_ENV_KEY 1512 // from jobs/state) so tree-shaking eliminates the string from external 1513 // builds — spawn.test.ts asserts the string matches. Hijack guard: the env 1514 // var value must itself resolve under ~/.claude/jobs/. Symlink guard: every 1515 // resolved form of the target (lexical + symlink chain) must fall under some 1516 // resolved form of the job dir, so a symlink inside the job dir pointing at 1517 // e.g. ~/.ssh/authorized_keys does not get a free write. Resolving both 1518 // sides handles the macOS /tmp → /private/tmp case where the config dir 1519 // lives under a symlinked root. 1520 if (feature('TEMPLATES')) { 1521 const jobDir = process.env.CLAUDE_JOB_DIR 1522 if (jobDir) { 1523 const jobsRoot = join(getClaudeConfigHomeDir(), 'jobs') 1524 const jobDirForms = getPathsForPermissionCheck(jobDir).map(normalize) 1525 const jobsRootForms = getPathsForPermissionCheck(jobsRoot).map(normalize) 1526 // Hijack guard: every resolved form of the job dir must sit under 1527 // some resolved form of the jobs root. Resolving both sides handles 1528 // the case where ~/.claude is a symlink (e.g. to /data/claude-config). 1529 const isUnderJobsRoot = jobDirForms.every(jd => 1530 jobsRootForms.some(jr => jd.startsWith(jr + sep)), 1531 ) 1532 if (isUnderJobsRoot) { 1533 const targetForms = getPathsForPermissionCheck(absolutePath) 1534 const allInsideJobDir = targetForms.every(p => { 1535 const np = normalize(p) 1536 return jobDirForms.some(jd => np === jd || np.startsWith(jd + sep)) 1537 }) 1538 if (allInsideJobDir) { 1539 return { 1540 behavior: 'allow', 1541 updatedInput: input, 1542 decisionReason: { 1543 type: 'other', 1544 reason: 1545 'Job directory files for current job are allowed for writing', 1546 }, 1547 } 1548 } 1549 } 1550 } 1551 } 1552 1553 // Agent memory directory (for self-improving agents) 1554 if (isAgentMemoryPath(normalizedPath)) { 1555 return { 1556 behavior: 'allow', 1557 updatedInput: input, 1558 decisionReason: { 1559 type: 'other', 1560 reason: 'Agent memory files are allowed for writing', 1561 }, 1562 } 1563 } 1564 1565 // Memdir directory (persistent memory for cross-session learning) 1566 // This pre-safety-check carve-out exists because the default path is under 1567 // ~/.claude/, which is in DANGEROUS_DIRECTORIES. The CLAUDE_COWORK_MEMORY_PATH_OVERRIDE 1568 // override is an arbitrary caller-designated directory with no such conflict, 1569 // so it gets NO special permission treatment here — writes go through normal 1570 // permission flow (step 5 → ask). SDK callers who want silent memory should 1571 // pass an allow rule for the override path. 1572 if (!hasAutoMemPathOverride() && isAutoMemPath(normalizedPath)) { 1573 return { 1574 behavior: 'allow', 1575 updatedInput: input, 1576 decisionReason: { 1577 type: 'other', 1578 reason: 'auto memory files are allowed for writing', 1579 }, 1580 } 1581 } 1582 1583 // .claude/launch.json — desktop preview config (dev server command + port). 1584 // The desktop's preview_start MCP tool instructs Claude to create/update 1585 // this file as part of the preview workflow. Without this carve-out the 1586 // .claude/ DANGEROUS_DIRECTORIES check prompts for it, which in SDK mode 1587 // cascades: user clicks "Always allow" → setMode:acceptEdits suggestion 1588 // applied → silent downgrade from auto mode. Matches the project-level 1589 // .claude/ only (not ~/.claude/) since launch.json is per-project. 1590 if ( 1591 normalizeCaseForComparison(normalizedPath) === 1592 normalizeCaseForComparison(join(getOriginalCwd(), '.claude', 'launch.json')) 1593 ) { 1594 return { 1595 behavior: 'allow', 1596 updatedInput: input, 1597 decisionReason: { 1598 type: 'other', 1599 reason: 'Preview launch config is allowed for writing', 1600 }, 1601 } 1602 } 1603 1604 return { behavior: 'passthrough', message: '' } 1605} 1606 1607/** 1608 * Check if a path is an internal path that can be read without permission. 1609 * Returns a PermissionResult - either 'allow' if matched, or 'passthrough' to continue checking. 1610 */ 1611export function checkReadableInternalPath( 1612 absolutePath: string, 1613 input: { [key: string]: unknown }, 1614): PermissionResult { 1615 // SECURITY: Normalize path to prevent traversal bypasses via .. segments 1616 // This is defense-in-depth; individual helper functions also normalize 1617 const normalizedPath = normalize(absolutePath) 1618 1619 // Session memory directory 1620 if (isSessionMemoryPath(normalizedPath)) { 1621 return { 1622 behavior: 'allow', 1623 updatedInput: input, 1624 decisionReason: { 1625 type: 'other', 1626 reason: 'Session memory files are allowed for reading', 1627 }, 1628 } 1629 } 1630 1631 // Project directory (for reading past session memories) 1632 // Path format: ~/.claude/projects/{sanitized-cwd}/... 1633 if (isProjectDirPath(normalizedPath)) { 1634 return { 1635 behavior: 'allow', 1636 updatedInput: input, 1637 decisionReason: { 1638 type: 'other', 1639 reason: 'Project directory files are allowed for reading', 1640 }, 1641 } 1642 } 1643 1644 // Plan files for current session 1645 if (isSessionPlanFile(normalizedPath)) { 1646 return { 1647 behavior: 'allow', 1648 updatedInput: input, 1649 decisionReason: { 1650 type: 'other', 1651 reason: 'Plan files for current session are allowed for reading', 1652 }, 1653 } 1654 } 1655 1656 // Tool results directory (persisted large outputs) 1657 // Use path separator suffix to prevent path traversal (e.g., tool-results-evil/) 1658 const toolResultsDir = getToolResultsDir() 1659 const toolResultsDirWithSep = toolResultsDir.endsWith(sep) 1660 ? toolResultsDir 1661 : toolResultsDir + sep 1662 if ( 1663 normalizedPath === toolResultsDir || 1664 normalizedPath.startsWith(toolResultsDirWithSep) 1665 ) { 1666 return { 1667 behavior: 'allow', 1668 updatedInput: input, 1669 decisionReason: { 1670 type: 'other', 1671 reason: 'Tool result files are allowed for reading', 1672 }, 1673 } 1674 } 1675 1676 // Scratchpad directory for current session 1677 if (isScratchpadPath(normalizedPath)) { 1678 return { 1679 behavior: 'allow', 1680 updatedInput: input, 1681 decisionReason: { 1682 type: 'other', 1683 reason: 'Scratchpad files for current session are allowed for reading', 1684 }, 1685 } 1686 } 1687 1688 // Project temp directory (/tmp/claude/{sanitized-cwd}/) 1689 // Intentionally allows reading files from all sessions in this project, not just the current session. 1690 // This enables cross-session file access within the same project's temp space. 1691 const projectTempDir = getProjectTempDir() 1692 if (normalizedPath.startsWith(projectTempDir)) { 1693 return { 1694 behavior: 'allow', 1695 updatedInput: input, 1696 decisionReason: { 1697 type: 'other', 1698 reason: 'Project temp directory files are allowed for reading', 1699 }, 1700 } 1701 } 1702 1703 // Agent memory directory (for self-improving agents) 1704 if (isAgentMemoryPath(normalizedPath)) { 1705 return { 1706 behavior: 'allow', 1707 updatedInput: input, 1708 decisionReason: { 1709 type: 'other', 1710 reason: 'Agent memory files are allowed for reading', 1711 }, 1712 } 1713 } 1714 1715 // Memdir directory (persistent memory for cross-session learning) 1716 if (isAutoMemPath(normalizedPath)) { 1717 return { 1718 behavior: 'allow', 1719 updatedInput: input, 1720 decisionReason: { 1721 type: 'other', 1722 reason: 'auto memory files are allowed for reading', 1723 }, 1724 } 1725 } 1726 1727 // Tasks directory (~/.claude/tasks/) for swarm task coordination 1728 const tasksDir = join(getClaudeConfigHomeDir(), 'tasks') + sep 1729 if ( 1730 normalizedPath === tasksDir.slice(0, -1) || 1731 normalizedPath.startsWith(tasksDir) 1732 ) { 1733 return { 1734 behavior: 'allow', 1735 updatedInput: input, 1736 decisionReason: { 1737 type: 'other', 1738 reason: 'Task files are allowed for reading', 1739 }, 1740 } 1741 } 1742 1743 // Teams directory (~/.claude/teams/) for swarm coordination 1744 const teamsReadDir = join(getClaudeConfigHomeDir(), 'teams') + sep 1745 if ( 1746 normalizedPath === teamsReadDir.slice(0, -1) || 1747 normalizedPath.startsWith(teamsReadDir) 1748 ) { 1749 return { 1750 behavior: 'allow', 1751 updatedInput: input, 1752 decisionReason: { 1753 type: 'other', 1754 reason: 'Team files are allowed for reading', 1755 }, 1756 } 1757 } 1758 1759 // Bundled skill reference files extracted on first invocation. 1760 // SECURITY: See getBundledSkillsRoot() — the per-process nonce in the path 1761 // is the load-bearing defense; uid/VERSION alone are public knowledge and 1762 // squattable. We always write-before-read on invocation, so content under 1763 // this subtree is harness-controlled. 1764 const bundledSkillsRoot = getBundledSkillsRoot() + sep 1765 if (normalizedPath.startsWith(bundledSkillsRoot)) { 1766 return { 1767 behavior: 'allow', 1768 updatedInput: input, 1769 decisionReason: { 1770 type: 'other', 1771 reason: 'Bundled skill reference files are allowed for reading', 1772 }, 1773 } 1774 } 1775 1776 return { behavior: 'passthrough', message: '' } 1777}