Download src/utils/permissions/filesystem.ts from chenbhao/codev: direct link, hf CLI and curl.
- Browser
- Download file 62.3 kB
-
https://huggingface.co/chenbhao/codev/resolve/main/src/utils/permissions/filesystem.ts
- Command line
-
hf download hf://chenbhao/codev/src/utils/permissions/filesystem.ts
-
curl -L -o filesystem.ts https://huggingface.co/chenbhao/codev/resolve/main/src/utils/permissions/filesystem.ts
62.3 kB
| import { feature } from 'bun:bundle' | |
| import { randomBytes } from 'crypto' | |
| import ignore from 'ignore' | |
| import memoize from 'lodash-es/memoize.js' | |
| import { homedir, tmpdir } from 'os' | |
| import { join, normalize, posix, sep } from 'path' | |
| import { hasAutoMemPathOverride, isAutoMemPath } from 'src/memdir/paths.js' | |
| import { isAgentMemoryPath } from 'src/tools/AgentTool/agentMemory.js' | |
| import { | |
| CLAUDE_FOLDER_PERMISSION_PATTERN, | |
| FILE_EDIT_TOOL_NAME, | |
| GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN, | |
| } from 'src/tools/FileEditTool/constants.js' | |
| import type { z } from 'zod/v4' | |
| import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' | |
| import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' | |
| import type { AnyObject, Tool, ToolPermissionContext } from '../../Tool.js' | |
| import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' | |
| import { getCwd } from '../cwd.js' | |
| import { getClaudeConfigHomeDir } from '../envUtils.js' | |
| import { | |
| getFsImplementation, | |
| getPathsForPermissionCheck, | |
| } from '../fsOperations.js' | |
| import { | |
| containsPathTraversal, | |
| expandPath, | |
| getDirectoryForPath, | |
| sanitizePath, | |
| } from '../path.js' | |
| import { getPlanSlug, getPlansDirectory } from '../plans.js' | |
| import { getPlatform } from '../platform.js' | |
| import { getProjectDir } from '../sessionStorage.js' | |
| import { SETTING_SOURCES } from '../settings/constants.js' | |
| import { | |
| getSettingsFilePathForSource, | |
| getSettingsRootPathForSource, | |
| } from '../settings/settings.js' | |
| import { containsVulnerableUncPath } from '../shell/readOnlyCommandValidation.js' | |
| import { getToolResultsDir } from '../toolResultStorage.js' | |
| import { windowsPathToPosixPath } from '../windowsPaths.js' | |
| import type { | |
| PermissionDecision, | |
| PermissionResult, | |
| } from './PermissionResult.js' | |
| import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js' | |
| import { createReadRuleSuggestion } from './PermissionUpdate.js' | |
| import type { PermissionUpdate } from './PermissionUpdateSchema.js' | |
| import { getRuleByContentsForToolName } from './permissions.js' | |
| declare const MACRO: { VERSION: string } | |
| /** | |
| * Dangerous files that should be protected from auto-editing. | |
| * These files can be used for code execution or data exfiltration. | |
| */ | |
| export const DANGEROUS_FILES = [ | |
| '.gitconfig', | |
| '.gitmodules', | |
| '.bashrc', | |
| '.bash_profile', | |
| '.zshrc', | |
| '.zprofile', | |
| '.profile', | |
| '.ripgreprc', | |
| '.mcp.json', | |
| '.claude.json', | |
| ] as const | |
| /** | |
| * Dangerous directories that should be protected from auto-editing. | |
| * These directories contain sensitive configuration or executable files. | |
| */ | |
| export const DANGEROUS_DIRECTORIES = [ | |
| '.git', | |
| '.vscode', | |
| '.idea', | |
| '.claude', | |
| ] as const | |
| /** | |
| * Normalizes a path for case-insensitive comparison. | |
| * This prevents bypassing security checks using mixed-case paths on case-insensitive | |
| * filesystems (macOS/Windows) like `.cLauDe/Settings.locaL.json`. | |
| * | |
| * We always normalize to lowercase regardless of platform for consistent security. | |
| * @param path The path to normalize | |
| * @returns The lowercase path for safe comparison | |
| */ | |
| export function normalizeCaseForComparison(path: string): string { | |
| return path.toLowerCase() | |
| } | |
| /** | |
| * If filePath is inside a .claude/skills/{name}/ directory (project or global), | |
| * return the skill name and a session-allow pattern scoped to just that skill. | |
| * Used to offer a narrower "allow edits to this skill only" option in the | |
| * permission dialog and SDK suggestions, so iterating on one skill doesn't | |
| * require granting session access to all of .claude/ (settings.json, hooks/, etc.). | |
| */ | |
| export function getClaudeSkillScope( | |
| filePath: string, | |
| ): { skillName: string; pattern: string } | null { | |
| const absolutePath = expandPath(filePath) | |
| const absolutePathLower = normalizeCaseForComparison(absolutePath) | |
| const bases = [ | |
| { | |
| dir: expandPath(join(getOriginalCwd(), '.claude', 'skills')), | |
| prefix: '/.claude/skills/', | |
| }, | |
| { | |
| dir: expandPath(join(homedir(), '.claude', 'skills')), | |
| prefix: '~/.claude/skills/', | |
| }, | |
| ] | |
| for (const { dir, prefix } of bases) { | |
| const dirLower = normalizeCaseForComparison(dir) | |
| // Try both path separators (Windows paths may not be normalized to /) | |
| for (const s of [sep, '/']) { | |
| if (absolutePathLower.startsWith(dirLower + s.toLowerCase())) { | |
| // Match on lowercase, but slice the ORIGINAL path so the skill name | |
| // preserves case (pattern matching downstream is case-sensitive) | |
| const rest = absolutePath.slice(dir.length + s.length) | |
| const slash = rest.indexOf('/') | |
| const bslash = sep === '\\' ? rest.indexOf('\\') : -1 | |
| const cut = | |
| slash === -1 | |
| ? bslash | |
| : bslash === -1 | |
| ? slash | |
| : Math.min(slash, bslash) | |
| // Require a separator: file must be INSIDE the skill dir, not a | |
| // file directly under skills/ (no skill scope for that) | |
| if (cut <= 0) return null | |
| const skillName = rest.slice(0, cut) | |
| // Reject traversal and empty. Use includes('..') not === '..' to | |
| // match step 1.6's ruleContent.includes('..') guard: a skillName like | |
| // 'v2..beta' would otherwise produce a suggestion step 1.7 emits but | |
| // step 1.6 always rejects (dead suggestion, infinite re-prompt). | |
| if (!skillName || skillName === '.' || skillName.includes('..')) { | |
| return null | |
| } | |
| // Reject glob metacharacters. skillName is interpolated into a | |
| // gitignore pattern consumed by ignore().add() in matchingRuleForInput | |
| // at step 1.6. A directory literally named '*' (valid on POSIX) would | |
| // produce '/.claude/skills/*/**' which matches ALL skills. Return null | |
| // to fall through to generateSuggestions() instead. | |
| if (/[*?[\]]/.test(skillName)) return null | |
| return { skillName, pattern: prefix + skillName + '/**' } | |
| } | |
| } | |
| } | |
| return null | |
| } | |
| // Always use / as the path separator per gitignore spec | |
| // https://git-scm.com/docs/gitignore | |
| const DIR_SEP = posix.sep | |
| /** | |
| * Cross-platform relative path calculation that returns POSIX-style paths. | |
| * Handles Windows path conversion internally. | |
| * @param from The base path | |
| * @param to The target path | |
| * @returns A POSIX-style relative path | |
| */ | |
| export function relativePath(from: string, to: string): string { | |
| if (getPlatform() === 'windows') { | |
| // Convert Windows paths to POSIX for consistent comparison | |
| const posixFrom = windowsPathToPosixPath(from) | |
| const posixTo = windowsPathToPosixPath(to) | |
| return posix.relative(posixFrom, posixTo) | |
| } | |
| // Use POSIX paths directly | |
| return posix.relative(from, to) | |
| } | |
| /** | |
| * Converts a path to POSIX format for pattern matching. | |
| * Handles Windows path conversion internally. | |
| * @param path The path to convert | |
| * @returns A POSIX-style path | |
| */ | |
| export function toPosixPath(path: string): string { | |
| if (getPlatform() === 'windows') { | |
| return windowsPathToPosixPath(path) | |
| } | |
| return path | |
| } | |
| function getSettingsPaths(): string[] { | |
| return SETTING_SOURCES.map(source => | |
| getSettingsFilePathForSource(source), | |
| ).filter(path => path !== undefined) | |
| } | |
| export function isClaudeSettingsPath(filePath: string): boolean { | |
| // SECURITY: Normalize path structure first to prevent bypass via redundant ./ | |
| // sequences like `./.claude/./settings.json` which would evade the endsWith() check | |
| const expandedPath = expandPath(filePath) | |
| // Normalize for case-insensitive comparison to prevent bypassing security | |
| // with paths like .cLauDe/Settings.locaL.json | |
| const normalizedPath = normalizeCaseForComparison(expandedPath) | |
| // Use platform separator so endsWith checks work on both Unix (/) and Windows (\) | |
| if ( | |
| normalizedPath.endsWith(`${sep}.claude${sep}settings.json`) || | |
| normalizedPath.endsWith(`${sep}.claude${sep}settings.local.json`) | |
| ) { | |
| // Include .claude/settings.json even for other projects | |
| return true | |
| } | |
| // Check for current project's settings files (including managed settings and CLI args) | |
| // Both paths are now absolute and normalized for consistent comparison | |
| return getSettingsPaths().some( | |
| settingsPath => normalizeCaseForComparison(settingsPath) === normalizedPath, | |
| ) | |
| } | |
| // Always ask when Claude Code tries to edit its own config files | |
| function isClaudeConfigFilePath(filePath: string): boolean { | |
| if (isClaudeSettingsPath(filePath)) { | |
| return true | |
| } | |
| // Check if file is within .claude/commands or .claude/agents directories | |
| // using proper path segment validation (not string matching with includes()) | |
| // pathInWorkingPath now handles case-insensitive comparison to prevent bypasses | |
| const commandsDir = join(getOriginalCwd(), '.claude', 'commands') | |
| const agentsDir = join(getOriginalCwd(), '.claude', 'agents') | |
| const skillsDir = join(getOriginalCwd(), '.claude', 'skills') | |
| return ( | |
| pathInWorkingPath(filePath, commandsDir) || | |
| pathInWorkingPath(filePath, agentsDir) || | |
| pathInWorkingPath(filePath, skillsDir) | |
| ) | |
| } | |
| // Check if file is the plan file for the current session | |
| function isSessionPlanFile(absolutePath: string): boolean { | |
| // Check if path is a plan file for this session (main or agent-specific) | |
| // Main plan file: {plansDir}/{planSlug}.md | |
| // Agent plan file: {plansDir}/{planSlug}-agent-{agentId}.md | |
| const expectedPrefix = join(getPlansDirectory(), getPlanSlug()) | |
| // SECURITY: Normalize to prevent path traversal bypasses via .. segments | |
| const normalizedPath = normalize(absolutePath) | |
| return ( | |
| normalizedPath.startsWith(expectedPrefix) && normalizedPath.endsWith('.md') | |
| ) | |
| } | |
| /** | |
| * Returns the session memory directory path for the current session with trailing separator. | |
| * Path format: {projectDir}/{sessionId}/session-memory/ | |
| */ | |
| export function getSessionMemoryDir(): string { | |
| return join(getProjectDir(getCwd()), getSessionId(), 'session-memory') + sep | |
| } | |
| /** | |
| * Returns the session memory file path for the current session. | |
| * Path format: {projectDir}/{sessionId}/session-memory/summary.md | |
| */ | |
| export function getSessionMemoryPath(): string { | |
| return join(getSessionMemoryDir(), 'summary.md') | |
| } | |
| // Check if file is within the session memory directory | |
| function isSessionMemoryPath(absolutePath: string): boolean { | |
| // SECURITY: Normalize to prevent path traversal bypasses via .. segments | |
| const normalizedPath = normalize(absolutePath) | |
| return normalizedPath.startsWith(getSessionMemoryDir()) | |
| } | |
| /** | |
| * Check if file is within the current project's directory. | |
| * Path format: ~/.claude/projects/{sanitized-cwd}/... | |
| */ | |
| function isProjectDirPath(absolutePath: string): boolean { | |
| const projectDir = getProjectDir(getCwd()) | |
| // SECURITY: Normalize to prevent path traversal bypasses via .. segments | |
| const normalizedPath = normalize(absolutePath) | |
| return ( | |
| normalizedPath === projectDir || normalizedPath.startsWith(projectDir + sep) | |
| ) | |
| } | |
| /** | |
| * Checks if the scratchpad directory feature is enabled. | |
| * The scratchpad is a per-session directory for Claude to write temporary files. | |
| * Controlled by the tengu_scratch Statsig gate. | |
| */ | |
| export function isScratchpadEnabled(): boolean { | |
| return checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_scratch') | |
| } | |
| /** | |
| * Returns the user-specific Claude temp directory name. | |
| * On Unix: 'claude-{uid}' to prevent multi-user permission conflicts | |
| * On Windows: 'claude' (tmpdir() is already per-user) | |
| */ | |
| export function getClaudeTempDirName(): string { | |
| if (getPlatform() === 'windows') { | |
| return 'claude' | |
| } | |
| // Use UID to create per-user directories, preventing permission conflicts | |
| // when multiple users share the same /tmp directory | |
| const uid = process.getuid?.() ?? 0 | |
| return `claude-${uid}` | |
| } | |
| /** | |
| * Returns the Claude temp directory path with symlinks resolved. | |
| * Uses TMPDIR env var if set, otherwise: | |
| * - On Unix: /tmp/claude-{uid}/ (resolved to /private/tmp/claude-{uid}/ on macOS) | |
| * - On Windows: {tmpdir}/claude/ (e.g., C:\Users\{user}\AppData\Local\Temp\claude\) | |
| * This is a per-user temporary directory used by Claude Code for all temp files. | |
| * | |
| * NOTE: We resolve symlinks to ensure this path matches the resolved paths used | |
| * in permission checks. On macOS, /tmp is a symlink to /private/tmp, so without | |
| * resolution, paths like /tmp/claude-{uid}/... wouldn't match /private/tmp/claude-{uid}/... | |
| */ | |
| // Memoized: called per-tool from permission checks (yoloClassifier, sandbox-adapter) | |
| // and per-turn from BashTool prompt. Inputs (CLAUDE_CODE_TMPDIR env + platform) are | |
| // fixed at startup, and the realpath of the system tmp dir does not change mid-session. | |
| export const getClaudeTempDir = memoize(function getClaudeTempDir(): string { | |
| const baseTmpDir = | |
| process.env.CLAUDE_CODE_TMPDIR || | |
| (getPlatform() === 'windows' ? tmpdir() : '/tmp') | |
| // Resolve symlinks in the base temp directory (e.g., /tmp -> /private/tmp on macOS) | |
| // This ensures the path matches resolved paths in permission checks | |
| const fs = getFsImplementation() | |
| let resolvedBaseTmpDir = baseTmpDir | |
| try { | |
| resolvedBaseTmpDir = fs.realpathSync(baseTmpDir) | |
| } catch { | |
| // If resolution fails, use the original path | |
| } | |
| return join(resolvedBaseTmpDir, getClaudeTempDirName()) + sep | |
| }) | |
| /** | |
| * Root for bundled-skill file extraction (see bundledSkills.ts). | |
| * | |
| * SECURITY: The per-process random nonce is the load-bearing defense here. | |
| * Every other path component (uid, VERSION, skill name, file keys) is public | |
| * knowledge, so without it a local attacker can pre-create the tree on a | |
| * shared /tmp — sticky bit prevents deletion, not creation — and either | |
| * symlink an intermediate directory (O_NOFOLLOW only checks the final | |
| * component) or own a parent dir and swap file contents post-write for prompt | |
| * injection via the read allowlist. diskOutput.ts gets the same property from | |
| * the session-ID UUID in its path. | |
| * | |
| * Memoized so the extraction writes and the permission check agree on the | |
| * path for the life of the process. Version-scoped so stale extractions from | |
| * other binaries don't fall under the allowlist. | |
| */ | |
| export const getBundledSkillsRoot = memoize( | |
| function getBundledSkillsRoot(): string { | |
| const nonce = randomBytes(16).toString('hex') | |
| return join(getClaudeTempDir(), 'bundled-skills', MACRO.VERSION, nonce) | |
| }, | |
| ) | |
| /** | |
| * Returns the project temp directory path with trailing separator. | |
| * Path format: /tmp/claude-{uid}/{sanitized-cwd}/ | |
| */ | |
| export function getProjectTempDir(): string { | |
| return join(getClaudeTempDir(), sanitizePath(getOriginalCwd())) + sep | |
| } | |
| /** | |
| * Returns the scratchpad directory path for the current session. | |
| * Path format: /tmp/claude-{uid}/{sanitized-cwd}/{sessionId}/scratchpad/ | |
| */ | |
| export function getScratchpadDir(): string { | |
| return join(getProjectTempDir(), getSessionId(), 'scratchpad') | |
| } | |
| /** | |
| * Ensures the scratchpad directory exists for the current session. | |
| * Creates the directory with secure permissions (0o700) if it doesn't exist. | |
| * Returns the path to the scratchpad directory. | |
| * @throws If scratchpad feature is not enabled | |
| */ | |
| export async function ensureScratchpadDir(): Promise<string> { | |
| if (!isScratchpadEnabled()) { | |
| throw new Error('Scratchpad directory feature is not enabled') | |
| } | |
| const fs = getFsImplementation() | |
| const scratchpadDir = getScratchpadDir() | |
| // Create directory recursively with secure permissions (owner-only access) | |
| // FsOperations.mkdir handles recursive: true internally and is a no-op if dir exists | |
| await fs.mkdir(scratchpadDir, { mode: 0o700 }) | |
| return scratchpadDir | |
| } | |
| // Check if file is within the scratchpad directory | |
| function isScratchpadPath(absolutePath: string): boolean { | |
| if (!isScratchpadEnabled()) { | |
| return false | |
| } | |
| const scratchpadDir = getScratchpadDir() | |
| // SECURITY: Normalize the path to resolve .. segments before checking | |
| // This prevents path traversal bypasses like: | |
| // echo "malicious" > /tmp/claude-0/proj/session/scratchpad/../../../etc/passwd | |
| // Without normalization, the path would pass the startsWith check but write to /etc/passwd | |
| const normalizedPath = normalize(absolutePath) | |
| return ( | |
| normalizedPath === scratchpadDir || | |
| normalizedPath.startsWith(scratchpadDir + sep) | |
| ) | |
| } | |
| /** | |
| * Check if a file path is dangerous to auto-edit without explicit permission. | |
| * This includes: | |
| * - Files in .git directories or .gitconfig files (to prevent git-based data exfiltration and code execution) | |
| * - Files in .vscode directories (to prevent VS Code settings manipulation and potential code execution) | |
| * - Files in .idea directories (to prevent JetBrains IDE settings manipulation) | |
| * - Shell configuration files (to prevent shell startup script manipulation) | |
| * - UNC paths (to prevent network file access and WebDAV attacks) | |
| */ | |
| function isDangerousFilePathToAutoEdit(path: string): boolean { | |
| const absolutePath = expandPath(path) | |
| const pathSegments = absolutePath.split(sep) | |
| const fileName = pathSegments.at(-1) | |
| // Check for UNC paths (defense-in-depth to catch any patterns that might not be caught by containsVulnerableUncPath) | |
| // Block anything starting with \\ or // as these are potentially UNC paths that could access network resources | |
| if (path.startsWith('\\\\') || path.startsWith('//')) { | |
| return true | |
| } | |
| // Check if path is within dangerous directories (case-insensitive to prevent bypasses) | |
| for (let i = 0; i < pathSegments.length; i++) { | |
| const segment = pathSegments[i]! | |
| const normalizedSegment = normalizeCaseForComparison(segment) | |
| for (const dir of DANGEROUS_DIRECTORIES) { | |
| if (normalizedSegment !== normalizeCaseForComparison(dir)) { | |
| continue | |
| } | |
| // Special case: .claude/worktrees/ is a structural path (where Claude stores | |
| // git worktrees), not a user-created dangerous directory. Skip the .claude | |
| // segment when it's followed by 'worktrees'. Any nested .claude directories | |
| // within the worktree (not followed by 'worktrees') are still blocked. | |
| if (dir === '.claude') { | |
| const nextSegment = pathSegments[i + 1] | |
| if ( | |
| nextSegment && | |
| normalizeCaseForComparison(nextSegment) === 'worktrees' | |
| ) { | |
| break // Skip this .claude, continue checking other segments | |
| } | |
| } | |
| return true | |
| } | |
| } | |
| // Check for dangerous configuration files (case-insensitive) | |
| if (fileName) { | |
| const normalizedFileName = normalizeCaseForComparison(fileName) | |
| if ( | |
| (DANGEROUS_FILES as readonly string[]).some( | |
| dangerousFile => | |
| normalizeCaseForComparison(dangerousFile) === normalizedFileName, | |
| ) | |
| ) { | |
| return true | |
| } | |
| } | |
| return false | |
| } | |
| /** | |
| * Detects suspicious Windows path patterns that could bypass security checks. | |
| * These patterns include: | |
| * - NTFS Alternate Data Streams (e.g., file.txt::$DATA or file.txt:stream) | |
| * - 8.3 short names (e.g., GIT~1, CLAUDE~1, SETTIN~1.JSON) | |
| * - Long path prefixes (e.g., \\?\C:\..., \\.\C:\..., //?/C:/..., //./C:/...) | |
| * - Trailing dots and spaces (e.g., .git., .claude , .bashrc...) | |
| * - DOS device names (e.g., .git.CON, settings.json.PRN, .bashrc.AUX) | |
| * - Three or more consecutive dots (e.g., .../file.txt, path/.../file, file...txt) | |
| * | |
| * When detected, these paths should always require manual approval to prevent | |
| * bypassing security checks through path canonicalization vulnerabilities. | |
| * | |
| * ## Why Check on All Platforms? | |
| * | |
| * While these patterns are primarily Windows-specific, NTFS filesystems can be | |
| * mounted on Linux and macOS (e.g., using ntfs-3g). On these systems, the same | |
| * bypass techniques would work - an attacker could use short names or long path | |
| * prefixes to bypass security checks. Therefore, we check for these patterns on | |
| * all platforms to ensure comprehensive protection. (Note: the ADS colon check | |
| * is Windows/WSL-only, since colon syntax is only interpreted by the Windows | |
| * kernel; on Linux/macOS, NTFS ADS is accessed via xattrs, not colon syntax.) | |
| * | |
| * ## Why Detection Instead of Normalization? | |
| * | |
| * An alternative approach would be to normalize these paths using Windows APIs | |
| * (e.g., GetLongPathNameW). However, this approach has significant challenges: | |
| * | |
| * 1. **Filesystem dependency**: Short path normalization is relative to files that | |
| * currently exist on the filesystem. This creates issues when writing to new | |
| * files since they don't exist yet and cannot be normalized. | |
| * | |
| * 2. **Race conditions**: The filesystem state can change between normalization | |
| * and actual file access, creating TOCTOU (Time-Of-Check-Time-Of-Use) vulnerabilities. | |
| * | |
| * 3. **Complexity**: Proper normalization requires Windows-specific APIs, handling | |
| * multiple edge cases, and dealing with various path formats (UNC, device paths, etc.). | |
| * | |
| * 4. **Reliability**: Pattern detection is more predictable and doesn't depend on | |
| * external system state. | |
| * | |
| * If you are considering adding normalization for these paths, please reach out to | |
| * AppSec first to discuss the security implications and implementation approach. | |
| * | |
| * @param path The path to check for suspicious patterns | |
| * @returns true if suspicious Windows path patterns are detected | |
| */ | |
| function hasSuspiciousWindowsPathPattern(path: string): boolean { | |
| // Check for NTFS Alternate Data Streams | |
| // Look for ':' after position 2 to skip drive letters (e.g., C:\) | |
| // Examples: file.txt::$DATA, .bashrc:hidden, settings.json:stream | |
| // Note: ADS colon syntax is only interpreted by the Windows kernel. On WSL, | |
| // DrvFs mounts route file operations through the Windows kernel, so colon | |
| // syntax is still interpreted as ADS separators. On Linux/macOS (non-WSL), | |
| // even when NTFS is mounted, ADS is accessed via xattrs (ntfs-3g) not colon | |
| // syntax, and colons are valid filename characters. | |
| if (getPlatform() === 'windows' || getPlatform() === 'wsl') { | |
| const colonIndex = path.indexOf(':', 2) | |
| if (colonIndex !== -1) { | |
| return true | |
| } | |
| } | |
| // Check for 8.3 short names | |
| // Look for '~' followed by a digit | |
| // Examples: GIT~1, CLAUDE~1, SETTIN~1.JSON, BASHRC~1 | |
| if (/~\d/.test(path)) { | |
| return true | |
| } | |
| // Check for long path prefixes (both backslash and forward slash variants) | |
| // Examples: \\?\C:\Users\..., \\.\C:\..., //?/C:/..., //./C:/... | |
| if ( | |
| path.startsWith('\\\\?\\') || | |
| path.startsWith('\\\\.\\') || | |
| path.startsWith('//?/') || | |
| path.startsWith('//./') | |
| ) { | |
| return true | |
| } | |
| // Check for trailing dots and spaces that Windows strips during path resolution | |
| // Examples: .git., .claude , .bashrc..., settings.json. | |
| // This can bypass string matching if ".git" is blocked but ".git." is used | |
| if (/[.\s]+$/.test(path)) { | |
| return true | |
| } | |
| // Check for DOS device names that Windows treats as special devices | |
| // Examples: .git.CON, settings.json.PRN, .bashrc.AUX | |
| // Device names: CON, PRN, AUX, NUL, COM1-9, LPT1-9 | |
| if (/\.(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(path)) { | |
| return true | |
| } | |
| // Check for three or more consecutive dots (...) when used as a path component | |
| // This pattern can be used to bypass security checks or create confusion | |
| // Examples: .../file.txt, path/.../file | |
| // Only block when dots are preceded AND followed by path separators (/ or \) | |
| // This allows legitimate uses like Next.js catch-all routes [...]name] | |
| if (/(^|\/|\\)\.{3,}(\/|\\|$)/.test(path)) { | |
| return true | |
| } | |
| // Check for UNC paths (on all platforms for defense-in-depth) | |
| // Examples: \\server\share, \\foo.com\file, //server/share, \\192.168.1.1\share | |
| // UNC paths can access remote resources, leak credentials, and bypass working directory restrictions | |
| if (containsVulnerableUncPath(path)) { | |
| return true | |
| } | |
| return false | |
| } | |
| /** | |
| * Checks if a path is safe for auto-editing (acceptEdits mode). | |
| * Returns information about why the path is unsafe, or null if all checks pass. | |
| * | |
| * This function performs comprehensive safety checks including: | |
| * - Suspicious Windows path patterns (NTFS streams, 8.3 names, long path prefixes, etc.) | |
| * - Claude config files (.claude/settings.json, .claude/commands/, .claude/agents/) | |
| * - MCP CLI state files (managed internally by Claude Code) | |
| * - Dangerous files (.bashrc, .gitconfig, .git/, .vscode/, .idea/, etc.) | |
| * | |
| * IMPORTANT: This function checks BOTH the original path AND resolved symlink paths | |
| * to prevent bypasses via symlinks pointing to protected files. | |
| * | |
| * @param path The path to check for safety | |
| * @returns Object with safe=false and message if unsafe, or { safe: true } if all checks pass | |
| */ | |
| export function checkPathSafetyForAutoEdit( | |
| path: string, | |
| precomputedPathsToCheck?: readonly string[], | |
| ): | |
| | { safe: true } | |
| | { safe: false; message: string; classifierApprovable: boolean } { | |
| // Get all paths to check (original + symlink resolved paths) | |
| const pathsToCheck = | |
| precomputedPathsToCheck ?? getPathsForPermissionCheck(path) | |
| // Check for suspicious Windows path patterns on all paths | |
| for (const pathToCheck of pathsToCheck) { | |
| if (hasSuspiciousWindowsPathPattern(pathToCheck)) { | |
| return { | |
| safe: false, | |
| message: `Claude requested permissions to write to ${path}, which contains a suspicious Windows path pattern that requires manual approval.`, | |
| classifierApprovable: false, | |
| } | |
| } | |
| } | |
| // Check for Claude config files on all paths | |
| for (const pathToCheck of pathsToCheck) { | |
| if (isClaudeConfigFilePath(pathToCheck)) { | |
| return { | |
| safe: false, | |
| message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`, | |
| classifierApprovable: true, | |
| } | |
| } | |
| } | |
| // Check for dangerous files on all paths | |
| for (const pathToCheck of pathsToCheck) { | |
| if (isDangerousFilePathToAutoEdit(pathToCheck)) { | |
| return { | |
| safe: false, | |
| message: `Claude requested permissions to edit ${path} which is a sensitive file.`, | |
| classifierApprovable: true, | |
| } | |
| } | |
| } | |
| // All safety checks passed | |
| return { safe: true } | |
| } | |
| export function allWorkingDirectories( | |
| context: ToolPermissionContext, | |
| ): Set<string> { | |
| return new Set([ | |
| getOriginalCwd(), | |
| ...context.additionalWorkingDirectories.keys(), | |
| ]) | |
| } | |
| // Working directories are session-stable; memoize their resolved forms to | |
| // avoid repeated existsSync/lstatSync/realpathSync syscalls on every | |
| // permission check. Keyed by path string — getPathsForPermissionCheck is | |
| // deterministic for existing directories within a session. | |
| // Exported for test/preload.ts cache clearing (shard-isolation). | |
| export const getResolvedWorkingDirPaths = memoize(getPathsForPermissionCheck) | |
| export function pathInAllowedWorkingPath( | |
| path: string, | |
| toolPermissionContext: ToolPermissionContext, | |
| precomputedPathsToCheck?: readonly string[], | |
| ): boolean { | |
| // Check both the original path and the resolved symlink path | |
| const pathsToCheck = | |
| precomputedPathsToCheck ?? getPathsForPermissionCheck(path) | |
| // Resolve working directories the same way we resolve input paths so | |
| // comparisons are symmetric. Without this, a resolved input path | |
| // (e.g. /System/Volumes/Data/home/... on macOS) would not match an | |
| // unresolved working directory (/home/...), causing false denials. | |
| const workingPaths = Array.from( | |
| allWorkingDirectories(toolPermissionContext), | |
| ).flatMap(wp => getResolvedWorkingDirPaths(wp)) | |
| // All paths must be within allowed working paths | |
| // If any resolved path is outside, deny access | |
| return pathsToCheck.every(pathToCheck => | |
| workingPaths.some(workingPath => | |
| pathInWorkingPath(pathToCheck, workingPath), | |
| ), | |
| ) | |
| } | |
| export function pathInWorkingPath(path: string, workingPath: string): boolean { | |
| const absolutePath = expandPath(path) | |
| const absoluteWorkingPath = expandPath(workingPath) | |
| // On macOS, handle common symlink issues: | |
| // - /var -> /private/var | |
| // - /tmp -> /private/tmp | |
| const normalizedPath = absolutePath | |
| .replace(/^\/private\/var\//, '/var/') | |
| .replace(/^\/private\/tmp(\/|$)/, '/tmp$1') | |
| const normalizedWorkingPath = absoluteWorkingPath | |
| .replace(/^\/private\/var\//, '/var/') | |
| .replace(/^\/private\/tmp(\/|$)/, '/tmp$1') | |
| // Normalize case for case-insensitive comparison to prevent bypassing security | |
| // checks on case-insensitive filesystems (macOS/Windows) like .cLauDe/CoMmAnDs | |
| const caseNormalizedPath = normalizeCaseForComparison(normalizedPath) | |
| const caseNormalizedWorkingPath = normalizeCaseForComparison( | |
| normalizedWorkingPath, | |
| ) | |
| // Use cross-platform relative path helper | |
| const relative = relativePath(caseNormalizedWorkingPath, caseNormalizedPath) | |
| // Same path | |
| if (relative === '') { | |
| return true | |
| } | |
| if (containsPathTraversal(relative)) { | |
| return false | |
| } | |
| // Path is inside (relative path that doesn't go up) | |
| return !posix.isAbsolute(relative) | |
| } | |
| function rootPathForSource(source: PermissionRuleSource): string { | |
| switch (source) { | |
| case 'cliArg': | |
| case 'command': | |
| case 'session': | |
| return expandPath(getOriginalCwd()) | |
| case 'userSettings': | |
| case 'policySettings': | |
| case 'projectSettings': | |
| case 'localSettings': | |
| case 'flagSettings': | |
| return getSettingsRootPathForSource(source) | |
| } | |
| } | |
| function prependDirSep(path: string): string { | |
| return posix.join(DIR_SEP, path) | |
| } | |
| function normalizePatternToPath({ | |
| patternRoot, | |
| pattern, | |
| rootPath, | |
| }: { | |
| patternRoot: string | |
| pattern: string | |
| rootPath: string | |
| }): string | null { | |
| // If the pattern root + pattern combination starts with our reference root | |
| const fullPattern = posix.join(patternRoot, pattern) | |
| if (patternRoot === rootPath) { | |
| // If the pattern root exactly matches our reference root no need to change | |
| return prependDirSep(pattern) | |
| } else if (fullPattern.startsWith(`${rootPath}${DIR_SEP}`)) { | |
| // Extract the relative part | |
| const relativePart = fullPattern.slice(rootPath.length) | |
| return prependDirSep(relativePart) | |
| } else { | |
| // Handle patterns that are inside the reference root but not starting with it | |
| const relativePath = posix.relative(rootPath, patternRoot) | |
| if ( | |
| !relativePath || | |
| relativePath.startsWith(`..${DIR_SEP}`) || | |
| relativePath === '..' | |
| ) { | |
| // Pattern is outside the reference root, so it can be skipped | |
| return null | |
| } else { | |
| const relativePattern = posix.join(relativePath, pattern) | |
| return prependDirSep(relativePattern) | |
| } | |
| } | |
| } | |
| export function normalizePatternsToPath( | |
| patternsByRoot: Map<string | null, string[]>, | |
| root: string, | |
| ): string[] { | |
| // null root means the pattern can match anywhere | |
| const result = new Set(patternsByRoot.get(null) ?? []) | |
| for (const [patternRoot, patterns] of patternsByRoot.entries()) { | |
| if (patternRoot === null) { | |
| // already added | |
| continue | |
| } | |
| // Check each pattern to see if the full path starts with our reference root | |
| for (const pattern of patterns) { | |
| const normalizedPattern = normalizePatternToPath({ | |
| patternRoot, | |
| pattern, | |
| rootPath: root, | |
| }) | |
| if (normalizedPattern) { | |
| result.add(normalizedPattern) | |
| } | |
| } | |
| } | |
| return Array.from(result) | |
| } | |
| /** | |
| * Collects all deny rules for file read permissions and returns their ignore patterns | |
| * Each pattern must be resolved relative to its root (map key) | |
| * Null keys are used for patterns that don't have a root | |
| * | |
| * This is used to hide files that are blocked by Read deny rules. | |
| * | |
| * @param toolPermissionContext | |
| */ | |
| export function getFileReadIgnorePatterns( | |
| toolPermissionContext: ToolPermissionContext, | |
| ): Map<string | null, string[]> { | |
| const patternsByRoot = getPatternsByRoot( | |
| toolPermissionContext, | |
| 'read', | |
| 'deny', | |
| ) | |
| const result = new Map<string | null, string[]>() | |
| for (const [patternRoot, patternMap] of patternsByRoot.entries()) { | |
| result.set(patternRoot, Array.from(patternMap.keys())) | |
| } | |
| return result | |
| } | |
| function patternWithRoot( | |
| pattern: string, | |
| source: PermissionRuleSource, | |
| ): { | |
| relativePattern: string | |
| root: string | null | |
| } { | |
| if (pattern.startsWith(`${DIR_SEP}${DIR_SEP}`)) { | |
| // Patterns starting with // resolve relative to / | |
| const patternWithoutDoubleSlash = pattern.slice(1) | |
| // On Windows, check if this is a POSIX-style drive path like //c/Users/... | |
| // Note: UNC paths (//server/share) will not match this regex and will be treated | |
| // as root-relative patterns, which may need separate handling in the future | |
| if ( | |
| getPlatform() === 'windows' && | |
| patternWithoutDoubleSlash.match(/^\/[a-z]\//i) | |
| ) { | |
| // Convert POSIX path to Windows format | |
| // The pattern is like /c/Users/... so we convert it to C:\Users\... | |
| const driveLetter = patternWithoutDoubleSlash[1]?.toUpperCase() ?? 'C' | |
| // Keep the pattern in POSIX format since relativePath returns POSIX paths | |
| const pathAfterDrive = patternWithoutDoubleSlash.slice(2) | |
| // Extract the drive root (C:\) and the rest of the pattern | |
| const driveRoot = `${driveLetter}:\\` | |
| const relativeFromDrive = pathAfterDrive.startsWith('/') | |
| ? pathAfterDrive.slice(1) | |
| : pathAfterDrive | |
| return { | |
| relativePattern: relativeFromDrive, | |
| root: driveRoot, | |
| } | |
| } | |
| return { | |
| relativePattern: patternWithoutDoubleSlash, | |
| root: DIR_SEP, | |
| } | |
| } else if (pattern.startsWith(`~${DIR_SEP}`)) { | |
| // Patterns starting with ~/ resolve relative to homedir | |
| return { | |
| relativePattern: pattern.slice(1), | |
| root: homedir().normalize('NFC'), | |
| } | |
| } else if (pattern.startsWith(DIR_SEP)) { | |
| // Patterns starting with / resolve relative to the directory where settings are stored (without .claude/) | |
| return { | |
| relativePattern: pattern, | |
| root: rootPathForSource(source), | |
| } | |
| } | |
| // No root specified, put it with all the other patterns | |
| // Normalize patterns that start with "./" to remove the prefix | |
| // This ensures that patterns like "./.env" match files like ".env" | |
| let normalizedPattern = pattern | |
| if (pattern.startsWith(`.${DIR_SEP}`)) { | |
| normalizedPattern = pattern.slice(2) | |
| } | |
| return { | |
| relativePattern: normalizedPattern, | |
| root: null, | |
| } | |
| } | |
| function getPatternsByRoot( | |
| toolPermissionContext: ToolPermissionContext, | |
| toolType: 'edit' | 'read', | |
| behavior: 'allow' | 'deny' | 'ask', | |
| ): Map<string | null, Map<string, PermissionRule>> { | |
| const toolName = (() => { | |
| switch (toolType) { | |
| case 'edit': | |
| // Apply Edit tool rules to any tool editing files | |
| return FILE_EDIT_TOOL_NAME | |
| case 'read': | |
| // Apply Read tool rules to any tool reading files | |
| return FILE_READ_TOOL_NAME | |
| } | |
| })() | |
| const rules = getRuleByContentsForToolName( | |
| toolPermissionContext, | |
| toolName, | |
| behavior, | |
| ) | |
| // Resolve rules relative to path based on source | |
| const patternsByRoot = new Map<string | null, Map<string, PermissionRule>>() | |
| for (const [pattern, rule] of rules.entries()) { | |
| const { relativePattern, root } = patternWithRoot(pattern, rule.source) | |
| let patternsForRoot = patternsByRoot.get(root) | |
| if (patternsForRoot === undefined) { | |
| patternsForRoot = new Map<string, PermissionRule>() | |
| patternsByRoot.set(root, patternsForRoot) | |
| } | |
| // Store the rule keyed by the root | |
| patternsForRoot.set(relativePattern, rule) | |
| } | |
| return patternsByRoot | |
| } | |
| export function matchingRuleForInput( | |
| path: string, | |
| toolPermissionContext: ToolPermissionContext, | |
| toolType: 'edit' | 'read', | |
| behavior: 'allow' | 'deny' | 'ask', | |
| ): PermissionRule | null { | |
| let fileAbsolutePath = expandPath(path) | |
| // On Windows, convert to POSIX format to match against permission patterns | |
| if (getPlatform() === 'windows' && fileAbsolutePath.includes('\\')) { | |
| fileAbsolutePath = windowsPathToPosixPath(fileAbsolutePath) | |
| } | |
| const patternsByRoot = getPatternsByRoot( | |
| toolPermissionContext, | |
| toolType, | |
| behavior, | |
| ) | |
| // Check each root for a matching pattern | |
| for (const [root, patternMap] of patternsByRoot.entries()) { | |
| // Transform patterns for the ignore library | |
| const patterns = Array.from(patternMap.keys()).map(pattern => { | |
| let adjustedPattern = pattern | |
| // Remove /** suffix - ignore library treats 'path' as matching both | |
| // the path itself and everything inside it | |
| if (adjustedPattern.endsWith('/**')) { | |
| adjustedPattern = adjustedPattern.slice(0, -3) | |
| } | |
| return adjustedPattern | |
| }) | |
| const ig = ignore().add(patterns) | |
| // Use cross-platform relative path helper for POSIX-style patterns | |
| const relativePathStr = relativePath( | |
| root ?? getCwd(), | |
| fileAbsolutePath ?? getCwd(), | |
| ) | |
| if (relativePathStr.startsWith(`..${DIR_SEP}`)) { | |
| // The path is outside the root, so ignore it | |
| continue | |
| } | |
| // Important: ig.test throws if you give it an empty string | |
| if (!relativePathStr) { | |
| continue | |
| } | |
| const igResult = ig.test(relativePathStr) | |
| if (igResult.ignored && igResult.rule) { | |
| // Map the matched pattern back to the original rule | |
| const originalPattern = igResult.rule.pattern | |
| // Check if this was a /** pattern we simplified | |
| const withWildcard = originalPattern + '/**' | |
| if (patternMap.has(withWildcard)) { | |
| return patternMap.get(withWildcard) ?? null | |
| } | |
| return patternMap.get(originalPattern) ?? null | |
| } | |
| } | |
| // No matching rule found | |
| return null | |
| } | |
| /** | |
| * Permission result for read permission for the specified tool & tool input | |
| */ | |
| export function checkReadPermissionForTool( | |
| tool: Tool, | |
| input: { [key: string]: unknown }, | |
| toolPermissionContext: ToolPermissionContext, | |
| ): PermissionDecision { | |
| if (typeof tool.getPath !== 'function') { | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`, | |
| } | |
| } | |
| const path = tool.getPath(input) | |
| // Get paths to check (includes both original and resolved symlinks). | |
| // Computed once here and threaded through checkWritePermissionForTool → | |
| // checkPathSafetyForAutoEdit → pathInAllowedWorkingPath to avoid redundant | |
| // existsSync/lstatSync/realpathSync syscalls on the same path (previously | |
| // 6× = 30 syscalls per Read permission check). | |
| const pathsToCheck = getPathsForPermissionCheck(path) | |
| // 1. Defense-in-depth: Block UNC paths early (before other checks) | |
| // This catches paths starting with \\ or // that could access network resources | |
| // This may catch some UNC patterns not detected by containsVulnerableUncPath | |
| for (const pathToCheck of pathsToCheck) { | |
| if (pathToCheck.startsWith('\\\\') || pathToCheck.startsWith('//')) { | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to read from ${path}, which appears to be a UNC path that could access network resources.`, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'UNC path detected (defense-in-depth check)', | |
| }, | |
| } | |
| } | |
| } | |
| // 2. Check for suspicious Windows path patterns (defense in depth) | |
| for (const pathToCheck of pathsToCheck) { | |
| if (hasSuspiciousWindowsPathPattern(pathToCheck)) { | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to read from ${path}, which contains a suspicious Windows path pattern that requires manual approval.`, | |
| decisionReason: { | |
| type: 'other', | |
| reason: | |
| 'Path contains suspicious Windows-specific patterns (alternate data streams, short names, long path prefixes, or three or more consecutive dots) that require manual verification', | |
| }, | |
| } | |
| } | |
| } | |
| // 3. Check for READ-SPECIFIC deny rules first - check both the original path and resolved symlink path | |
| // SECURITY: This must come before any allow checks (including "edit access implies read access") | |
| // to prevent bypassing explicit read deny rules | |
| for (const pathToCheck of pathsToCheck) { | |
| const denyRule = matchingRuleForInput( | |
| pathToCheck, | |
| toolPermissionContext, | |
| 'read', | |
| 'deny', | |
| ) | |
| if (denyRule) { | |
| return { | |
| behavior: 'deny', | |
| message: `Permission to read ${path} has been denied.`, | |
| decisionReason: { | |
| type: 'rule', | |
| rule: denyRule, | |
| }, | |
| } | |
| } | |
| } | |
| // 4. Check for READ-SPECIFIC ask rules - check both the original path and resolved symlink path | |
| // SECURITY: This must come before implicit allow checks to ensure explicit ask rules are honored | |
| for (const pathToCheck of pathsToCheck) { | |
| const askRule = matchingRuleForInput( | |
| pathToCheck, | |
| toolPermissionContext, | |
| 'read', | |
| 'ask', | |
| ) | |
| if (askRule) { | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to read from ${path}, but you haven't granted it yet.`, | |
| decisionReason: { | |
| type: 'rule', | |
| rule: askRule, | |
| }, | |
| } | |
| } | |
| } | |
| // 5. Edit access implies read access (but only if no read-specific deny/ask rules exist) | |
| // We check this after read-specific rules so that explicit read restrictions take precedence | |
| const editResult = checkWritePermissionForTool( | |
| tool, | |
| input, | |
| toolPermissionContext, | |
| pathsToCheck, | |
| ) | |
| if (editResult.behavior === 'allow') { | |
| return editResult | |
| } | |
| // 6. Allow reads in working directories | |
| const isInWorkingDir = pathInAllowedWorkingPath( | |
| path, | |
| toolPermissionContext, | |
| pathsToCheck, | |
| ) | |
| if (isInWorkingDir) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'mode', | |
| mode: 'default', | |
| }, | |
| } | |
| } | |
| // 7. Allow reads from internal harness paths (session-memory, plans, tool-results) | |
| const absolutePath = expandPath(path) | |
| const internalReadResult = checkReadableInternalPath(absolutePath, input) | |
| if (internalReadResult.behavior !== 'passthrough') { | |
| return internalReadResult | |
| } | |
| // 8. Check for allow rules | |
| const allowRule = matchingRuleForInput( | |
| path, | |
| toolPermissionContext, | |
| 'read', | |
| 'allow', | |
| ) | |
| if (allowRule) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'rule', | |
| rule: allowRule, | |
| }, | |
| } | |
| } | |
| // 12. Default to asking for permission | |
| // At this point, isInWorkingDir is false (from step #6), so path is outside working directories | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to read from ${path}, but you haven't granted it yet.`, | |
| suggestions: generateSuggestions( | |
| path, | |
| 'read', | |
| toolPermissionContext, | |
| pathsToCheck, | |
| ), | |
| decisionReason: { | |
| type: 'workingDir', | |
| reason: 'Path is outside allowed working directories', | |
| }, | |
| } | |
| } | |
| /** | |
| * Permission result for write permission for the specified tool & tool input. | |
| * | |
| * @param precomputedPathsToCheck - Optional cached result of | |
| * `getPathsForPermissionCheck(tool.getPath(input))`. Callers MUST derive this | |
| * from the same `tool` and `input` in the same synchronous frame — `path` is | |
| * re-derived internally for error messages and internal-path checks, so a | |
| * stale value would silently check deny rules for the wrong path. | |
| */ | |
| export function checkWritePermissionForTool<Input extends AnyObject>( | |
| tool: Tool<Input>, | |
| input: z.infer<Input>, | |
| toolPermissionContext: ToolPermissionContext, | |
| precomputedPathsToCheck?: readonly string[], | |
| ): PermissionDecision { | |
| if (typeof tool.getPath !== 'function') { | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to use ${tool.name}, but you haven't granted it yet.`, | |
| } | |
| } | |
| const path = tool.getPath(input) | |
| // 1. Check for deny rules - check both the original path and resolved symlink path | |
| const pathsToCheck = | |
| precomputedPathsToCheck ?? getPathsForPermissionCheck(path) | |
| for (const pathToCheck of pathsToCheck) { | |
| const denyRule = matchingRuleForInput( | |
| pathToCheck, | |
| toolPermissionContext, | |
| 'edit', | |
| 'deny', | |
| ) | |
| if (denyRule) { | |
| return { | |
| behavior: 'deny', | |
| message: `Permission to edit ${path} has been denied.`, | |
| decisionReason: { | |
| type: 'rule', | |
| rule: denyRule, | |
| }, | |
| } | |
| } | |
| } | |
| // 1.5. Allow writes to internal editable paths (plan files, scratchpad) | |
| // This MUST come before isDangerousFilePathToAutoEdit check since .claude is a dangerous directory | |
| const absolutePathForEdit = expandPath(path) | |
| const internalEditResult = checkEditableInternalPath( | |
| absolutePathForEdit, | |
| input, | |
| ) | |
| if (internalEditResult.behavior !== 'passthrough') { | |
| return internalEditResult | |
| } | |
| // 1.6. Check for .claude/** allow rules BEFORE safety checks | |
| // This allows session-level permissions to bypass the safety blocks for .claude/ | |
| // We only allow this for session-level rules to prevent users from accidentally | |
| // permanently granting broad access to their .claude/ folder. | |
| // | |
| // matchingRuleForInput returns the first match across all sources. If the user | |
| // also has a broader Edit(.claude) rule in userSettings (e.g. from sandbox | |
| // write-allow conversion), that rule would be found first and its source check | |
| // below would fail. Scope the search to session-only rules so the dialog's | |
| // "allow Claude to edit its own settings for this session" option actually works. | |
| const claudeFolderAllowRule = matchingRuleForInput( | |
| path, | |
| { | |
| ...toolPermissionContext, | |
| alwaysAllowRules: { | |
| session: toolPermissionContext.alwaysAllowRules.session ?? [], | |
| }, | |
| }, | |
| 'edit', | |
| 'allow', | |
| ) | |
| if (claudeFolderAllowRule) { | |
| // Check if this rule is scoped under .claude/ (project or global). | |
| // Accepts both the broad patterns ('/.claude/**', '~/.claude/**') and | |
| // narrowed ones like '/.claude/skills/my-skill/**' so users can grant | |
| // session access to a single skill without also exposing settings.json | |
| // or hooks/. The rule already matched the path via matchingRuleForInput; | |
| // this is an additional scope check. Reject '..' to prevent a rule like | |
| // '/.claude/../**' from leaking this bypass outside .claude/. | |
| const ruleContent = claudeFolderAllowRule.ruleValue.ruleContent | |
| if ( | |
| ruleContent && | |
| (ruleContent.startsWith(CLAUDE_FOLDER_PERMISSION_PATTERN.slice(0, -2)) || | |
| ruleContent.startsWith( | |
| GLOBAL_CLAUDE_FOLDER_PERMISSION_PATTERN.slice(0, -2), | |
| )) && | |
| !ruleContent.includes('..') && | |
| ruleContent.endsWith('/**') | |
| ) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'rule', | |
| rule: claudeFolderAllowRule, | |
| }, | |
| } | |
| } | |
| } | |
| // 1.7. Check comprehensive safety validations (Windows patterns, Claude config, dangerous files) | |
| // This MUST come before checking allow rules to prevent users from accidentally granting | |
| // permission to edit protected files | |
| const safetyCheck = checkPathSafetyForAutoEdit(path, pathsToCheck) | |
| if (!safetyCheck.safe) { | |
| // SDK suggestion: if under .claude/skills/{name}/, emit the narrowed | |
| // session-scoped addRules that step 1.6 will honor on the next call. | |
| // Everything else (.claude/settings.json, .git/, .vscode/, .idea/) falls | |
| // back to generateSuggestions — its setMode suggestion doesn't bypass | |
| // this check, but preserving it avoids a surprising empty array. | |
| const skillScope = getClaudeSkillScope(path) | |
| const safetySuggestions: PermissionUpdate[] = skillScope | |
| ? [ | |
| { | |
| type: 'addRules', | |
| rules: [ | |
| { | |
| toolName: FILE_EDIT_TOOL_NAME, | |
| ruleContent: skillScope.pattern, | |
| }, | |
| ], | |
| behavior: 'allow', | |
| destination: 'session', | |
| }, | |
| ] | |
| : generateSuggestions(path, 'write', toolPermissionContext, pathsToCheck) | |
| return { | |
| behavior: 'ask', | |
| message: safetyCheck.message, | |
| suggestions: safetySuggestions, | |
| decisionReason: { | |
| type: 'safetyCheck', | |
| reason: safetyCheck.message, | |
| classifierApprovable: safetyCheck.classifierApprovable, | |
| }, | |
| } | |
| } | |
| // 2. Check for ask rules - check both the original path and resolved symlink path | |
| for (const pathToCheck of pathsToCheck) { | |
| const askRule = matchingRuleForInput( | |
| pathToCheck, | |
| toolPermissionContext, | |
| 'edit', | |
| 'ask', | |
| ) | |
| if (askRule) { | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`, | |
| decisionReason: { | |
| type: 'rule', | |
| rule: askRule, | |
| }, | |
| } | |
| } | |
| } | |
| // 3. If in acceptEdits or sandboxBashMode mode, allow all writes in original cwd | |
| const isInWorkingDir = pathInAllowedWorkingPath( | |
| path, | |
| toolPermissionContext, | |
| pathsToCheck, | |
| ) | |
| if (toolPermissionContext.mode === 'acceptEdits' && isInWorkingDir) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'mode', | |
| mode: toolPermissionContext.mode, | |
| }, | |
| } | |
| } | |
| // 4. Check for allow rules | |
| const allowRule = matchingRuleForInput( | |
| path, | |
| toolPermissionContext, | |
| 'edit', | |
| 'allow', | |
| ) | |
| if (allowRule) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'rule', | |
| rule: allowRule, | |
| }, | |
| } | |
| } | |
| // 5. Default to asking for permission | |
| return { | |
| behavior: 'ask', | |
| message: `Claude requested permissions to write to ${path}, but you haven't granted it yet.`, | |
| suggestions: generateSuggestions( | |
| path, | |
| 'write', | |
| toolPermissionContext, | |
| pathsToCheck, | |
| ), | |
| decisionReason: !isInWorkingDir | |
| ? { | |
| type: 'workingDir', | |
| reason: 'Path is outside allowed working directories', | |
| } | |
| : undefined, | |
| } | |
| } | |
| export function generateSuggestions( | |
| filePath: string, | |
| operationType: 'read' | 'write' | 'create', | |
| toolPermissionContext: ToolPermissionContext, | |
| precomputedPathsToCheck?: readonly string[], | |
| ): PermissionUpdate[] { | |
| const isOutsideWorkingDir = !pathInAllowedWorkingPath( | |
| filePath, | |
| toolPermissionContext, | |
| precomputedPathsToCheck, | |
| ) | |
| if (operationType === 'read' && isOutsideWorkingDir) { | |
| // For read operations outside working directories, add Read rules | |
| // IMPORTANT: Include both the symlink path and resolved path so subsequent checks pass | |
| const dirPath = getDirectoryForPath(filePath) | |
| const dirsToAdd = getPathsForPermissionCheck(dirPath) | |
| const suggestions = dirsToAdd | |
| .map(dir => createReadRuleSuggestion(dir, 'session')) | |
| .filter((s): s is PermissionUpdate => s !== undefined) | |
| return suggestions | |
| } | |
| // Only suggest setMode:acceptEdits when it would be an upgrade. In auto | |
| // mode the classifier already auto-approves edits; in bypassPermissions | |
| // everything is allowed; in acceptEdits it's a no-op. Suggesting it | |
| // anyway and having the SDK host apply it on "Always allow" silently | |
| // downgrades auto → acceptEdits, which then prompts for MCP/Bash. | |
| const shouldSuggestAcceptEdits = | |
| toolPermissionContext.mode === 'default' || | |
| toolPermissionContext.mode === 'plan' | |
| if (operationType === 'write' || operationType === 'create') { | |
| const updates: PermissionUpdate[] = shouldSuggestAcceptEdits | |
| ? [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }] | |
| : [] | |
| if (isOutsideWorkingDir) { | |
| // For write operations outside working directories, also add the directory | |
| // IMPORTANT: Include both the symlink path and resolved path so subsequent checks pass | |
| const dirPath = getDirectoryForPath(filePath) | |
| const dirsToAdd = getPathsForPermissionCheck(dirPath) | |
| updates.push({ | |
| type: 'addDirectories', | |
| directories: dirsToAdd, | |
| destination: 'session', | |
| }) | |
| } | |
| return updates | |
| } | |
| // For read operations inside working directories, just change mode | |
| return shouldSuggestAcceptEdits | |
| ? [{ type: 'setMode', mode: 'acceptEdits', destination: 'session' }] | |
| : [] | |
| } | |
| /** | |
| * Check if a path is an internal path that can be edited without permission. | |
| * Returns a PermissionResult - either 'allow' if matched, or 'passthrough' to continue checking. | |
| */ | |
| export function checkEditableInternalPath( | |
| absolutePath: string, | |
| input: { [key: string]: unknown }, | |
| ): PermissionResult { | |
| // SECURITY: Normalize path to prevent traversal bypasses via .. segments | |
| // This is defense-in-depth; individual helper functions also normalize | |
| const normalizedPath = normalize(absolutePath) | |
| // Plan files for current session | |
| if (isSessionPlanFile(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Plan files for current session are allowed for writing', | |
| }, | |
| } | |
| } | |
| // Scratchpad directory for current session | |
| if (isScratchpadPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Scratchpad files for current session are allowed for writing', | |
| }, | |
| } | |
| } | |
| // Template job's own directory. Env key hardcoded (vs importing JOB_ENV_KEY | |
| // from jobs/state) so tree-shaking eliminates the string from external | |
| // builds — spawn.test.ts asserts the string matches. Hijack guard: the env | |
| // var value must itself resolve under ~/.claude/jobs/. Symlink guard: every | |
| // resolved form of the target (lexical + symlink chain) must fall under some | |
| // resolved form of the job dir, so a symlink inside the job dir pointing at | |
| // e.g. ~/.ssh/authorized_keys does not get a free write. Resolving both | |
| // sides handles the macOS /tmp → /private/tmp case where the config dir | |
| // lives under a symlinked root. | |
| if (feature('TEMPLATES')) { | |
| const jobDir = process.env.CLAUDE_JOB_DIR | |
| if (jobDir) { | |
| const jobsRoot = join(getClaudeConfigHomeDir(), 'jobs') | |
| const jobDirForms = getPathsForPermissionCheck(jobDir).map(normalize) | |
| const jobsRootForms = getPathsForPermissionCheck(jobsRoot).map(normalize) | |
| // Hijack guard: every resolved form of the job dir must sit under | |
| // some resolved form of the jobs root. Resolving both sides handles | |
| // the case where ~/.claude is a symlink (e.g. to /data/claude-config). | |
| const isUnderJobsRoot = jobDirForms.every(jd => | |
| jobsRootForms.some(jr => jd.startsWith(jr + sep)), | |
| ) | |
| if (isUnderJobsRoot) { | |
| const targetForms = getPathsForPermissionCheck(absolutePath) | |
| const allInsideJobDir = targetForms.every(p => { | |
| const np = normalize(p) | |
| return jobDirForms.some(jd => np === jd || np.startsWith(jd + sep)) | |
| }) | |
| if (allInsideJobDir) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: | |
| 'Job directory files for current job are allowed for writing', | |
| }, | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Agent memory directory (for self-improving agents) | |
| if (isAgentMemoryPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Agent memory files are allowed for writing', | |
| }, | |
| } | |
| } | |
| // Memdir directory (persistent memory for cross-session learning) | |
| // This pre-safety-check carve-out exists because the default path is under | |
| // ~/.claude/, which is in DANGEROUS_DIRECTORIES. The CLAUDE_COWORK_MEMORY_PATH_OVERRIDE | |
| // override is an arbitrary caller-designated directory with no such conflict, | |
| // so it gets NO special permission treatment here — writes go through normal | |
| // permission flow (step 5 → ask). SDK callers who want silent memory should | |
| // pass an allow rule for the override path. | |
| if (!hasAutoMemPathOverride() && isAutoMemPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'auto memory files are allowed for writing', | |
| }, | |
| } | |
| } | |
| // .claude/launch.json — desktop preview config (dev server command + port). | |
| // The desktop's preview_start MCP tool instructs Claude to create/update | |
| // this file as part of the preview workflow. Without this carve-out the | |
| // .claude/ DANGEROUS_DIRECTORIES check prompts for it, which in SDK mode | |
| // cascades: user clicks "Always allow" → setMode:acceptEdits suggestion | |
| // applied → silent downgrade from auto mode. Matches the project-level | |
| // .claude/ only (not ~/.claude/) since launch.json is per-project. | |
| if ( | |
| normalizeCaseForComparison(normalizedPath) === | |
| normalizeCaseForComparison(join(getOriginalCwd(), '.claude', 'launch.json')) | |
| ) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Preview launch config is allowed for writing', | |
| }, | |
| } | |
| } | |
| return { behavior: 'passthrough', message: '' } | |
| } | |
| /** | |
| * Check if a path is an internal path that can be read without permission. | |
| * Returns a PermissionResult - either 'allow' if matched, or 'passthrough' to continue checking. | |
| */ | |
| export function checkReadableInternalPath( | |
| absolutePath: string, | |
| input: { [key: string]: unknown }, | |
| ): PermissionResult { | |
| // SECURITY: Normalize path to prevent traversal bypasses via .. segments | |
| // This is defense-in-depth; individual helper functions also normalize | |
| const normalizedPath = normalize(absolutePath) | |
| // Session memory directory | |
| if (isSessionMemoryPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Session memory files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Project directory (for reading past session memories) | |
| // Path format: ~/.claude/projects/{sanitized-cwd}/... | |
| if (isProjectDirPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Project directory files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Plan files for current session | |
| if (isSessionPlanFile(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Plan files for current session are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Tool results directory (persisted large outputs) | |
| // Use path separator suffix to prevent path traversal (e.g., tool-results-evil/) | |
| const toolResultsDir = getToolResultsDir() | |
| const toolResultsDirWithSep = toolResultsDir.endsWith(sep) | |
| ? toolResultsDir | |
| : toolResultsDir + sep | |
| if ( | |
| normalizedPath === toolResultsDir || | |
| normalizedPath.startsWith(toolResultsDirWithSep) | |
| ) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Tool result files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Scratchpad directory for current session | |
| if (isScratchpadPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Scratchpad files for current session are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Project temp directory (/tmp/claude/{sanitized-cwd}/) | |
| // Intentionally allows reading files from all sessions in this project, not just the current session. | |
| // This enables cross-session file access within the same project's temp space. | |
| const projectTempDir = getProjectTempDir() | |
| if (normalizedPath.startsWith(projectTempDir)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Project temp directory files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Agent memory directory (for self-improving agents) | |
| if (isAgentMemoryPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Agent memory files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Memdir directory (persistent memory for cross-session learning) | |
| if (isAutoMemPath(normalizedPath)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'auto memory files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Tasks directory (~/.claude/tasks/) for swarm task coordination | |
| const tasksDir = join(getClaudeConfigHomeDir(), 'tasks') + sep | |
| if ( | |
| normalizedPath === tasksDir.slice(0, -1) || | |
| normalizedPath.startsWith(tasksDir) | |
| ) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Task files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Teams directory (~/.claude/teams/) for swarm coordination | |
| const teamsReadDir = join(getClaudeConfigHomeDir(), 'teams') + sep | |
| if ( | |
| normalizedPath === teamsReadDir.slice(0, -1) || | |
| normalizedPath.startsWith(teamsReadDir) | |
| ) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Team files are allowed for reading', | |
| }, | |
| } | |
| } | |
| // Bundled skill reference files extracted on first invocation. | |
| // SECURITY: See getBundledSkillsRoot() — the per-process nonce in the path | |
| // is the load-bearing defense; uid/VERSION alone are public knowledge and | |
| // squattable. We always write-before-read on invocation, so content under | |
| // this subtree is harness-controlled. | |
| const bundledSkillsRoot = getBundledSkillsRoot() + sep | |
| if (normalizedPath.startsWith(bundledSkillsRoot)) { | |
| return { | |
| behavior: 'allow', | |
| updatedInput: input, | |
| decisionReason: { | |
| type: 'other', | |
| reason: 'Bundled skill reference files are allowed for reading', | |
| }, | |
| } | |
| } | |
| return { behavior: 'passthrough', message: '' } | |
| } | |