1778 lines
61 KiB
TypeScript
1778 lines
61 KiB
TypeScript
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: '' }
|
||
}
|