/ src / utils / permissions / filesystem.ts
filesystem.ts
   1  import { feature } from 'bun:bundle'
   2  import { randomBytes } from 'crypto'
   3  import ignore from 'ignore'
   4  import memoize from 'lodash-es/memoize.js'
   5  import { homedir, tmpdir } from 'os'
   6  import { join, normalize, posix, sep } from 'path'
   7  import { hasAutoMemPathOverride, isAutoMemPath } from 'src/memdir/paths.js'
   8  import { isAgentMemoryPath } from 'src/tools/AgentTool/agentMemory.js'
   9  import {
  10    CLAUDE_FOLDER_PERMISSION_PATTERN,
  11    FILE_EDIT_TOOL_NAME,
  12    GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN,
  13  } from 'src/tools/FileEditTool/constants.js'
  14  import type { z } from 'zod/v4'
  15  import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
  16  import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
  17  import type { AnyObject, Tool, ToolPermissionContext } from '../../Tool.js'
  18  import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js'
  19  import { getCwd } from '../cwd.js'
  20  import { getClaudeConfigHomeDir } from '../envUtils.js'
  21  import {
  22    getFsImplementation,
  23    getPathsForPermissionCheck,
  24  } from '../fsOperations.js'
  25  import {
  26    containsPathTraversal,
  27    expandPath,
  28    getDirectoryForPath,
  29    sanitizePath,
  30  } from '../path.js'
  31  import { getPlanSlug, getPlansDirectory } from '../plans.js'
  32  import { getPlatform } from '../platform.js'
  33  import { getProjectDir } from '../sessionStorage.js'
  34  import { SETTING_SOURCES } from '../settings/constants.js'
  35  import {
  36    getSettingsFilePathForSource,
  37    getSettingsRootPathForSource,
  38  } from '../settings/settings.js'
  39  import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js'
  40  import { getToolResultsDir } from '../toolResultStorage.js'
  41  import { windowsPathToPosixPath } from '../windowsPaths.js'
  42  import type {
  43    PermissionDecision,
  44    PermissionResult,
  45  } from './PermissionResult.js'
  46  import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js'
  47  import { createReadRuleSuggestion } from './PermissionUpdate.js'
  48  import type { PermissionUpdate } from './PermissionUpdateSchema.js'
  49  import { getRuleByContentsForToolName } from './permissions.js'
  50  
  51  declare 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   */
  57  export 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   */
  74  export 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   */
  90  export 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   */
 101  export 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
 161  const 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   */
 170  export 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   */
 187  export function toPosixPath(path: string): string {
 188    if (getPlatform() === 'windows') {
 189      return windowsPathToPosixPath(path)
 190    }
 191    return path
 192  }
 193  
 194  function getSettingsPaths(): string[] {
 195    return SETTING_SOURCES.map(source =>
 196      getSettingsFilePathForSource(source),
 197    ).filter(path => path !== undefined)
 198  }
 199  
 200  export 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
 225  function 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
 245  function 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   */
 261  export 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   */
 269  export function getSessionMemoryPath(): string {
 270    return join(getSessionMemoryDir(), 'summary.md')
 271  }
 272  
 273  // Check if file is within the session memory directory
 274  function 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   */
 284  function 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   */
 298  export 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   */
 307  export 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.
 331  export 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   */
 365  export 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   */
 376  export 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   */
 384  export 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   */
 394  export 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
 410  function 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   */
 435  function 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   */
 537  function 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   */
 620  export 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  
 667  export 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).
 681  export const getResolvedWorkingDirPaths = memoize(getPathsForPermissionCheck)
 682  
 683  export 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  
 709  export 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  
 746  function 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  
 761  function prependDirSep(path: string): string {
 762    return posix.join(DIR_SEP, path)
 763  }
 764  
 765  function 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  
 800  export 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   */
 837  export 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  
 853  function 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  
 919  function 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  
 955  export 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   */
1030  export 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   */
1205  export 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  
1414  export 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   */
1479  export 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   */
1611  export 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  }