Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
62 kB · 1777 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778import { 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/gitignoreconst 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 filesfunction 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 sessionfunction 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 directoryfunction 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 directoryfunction 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: '' }}