diff --git a/CHANGELOG.md b/CHANGELOG.md index af75c77c..9b6f7963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to DreamCoder will be documented in this file. ### Fixed - Confirmation dialogs keep long workspace paths inside their bounds, so Cancel and Enable remain visible when enabling bypass permissions (#226). Thanks @imshah-id. +- Reading environment files, credential files, and SSH private keys now requires explicit approval through both the Read tool and read-only Bash commands. Recursive directory reads, Grep tool glob filters, and files read from Git history are not covered yet. +- The exact shell no-op `:` is recognized as read-only and no longer requires approval. - Images attached from uploads, paste or the built-in browser are kept unchanged within the client limits and resized or compressed when they exceed 2000×2000 pixels or 3.75 MB; a re-encoded image is named after its new type. An image the app cannot decode is attached as it is when it is within 3.75 MB. A file that cannot be attached is named in a message, and the other files picked or dropped with it are still attached. Sent images now use compact thumbnails that still open in the gallery (#218). Thanks @henricop. ## [0.8.0] — 2026-11-01 diff --git a/src/tools/BashTool/bashPermissions.test.ts b/src/tools/BashTool/bashPermissions.test.ts index 5bb9d7b8..41bfcfea 100644 --- a/src/tools/BashTool/bashPermissions.test.ts +++ b/src/tools/BashTool/bashPermissions.test.ts @@ -1,4 +1,16 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getOriginalCwd, setOriginalCwd } from '../../bootstrap/state' +import { getEmptyToolPermissionContext } from '../../Tool' import { BINARY_HIJACK_VARS, getFirstWordPrefix, @@ -8,6 +20,15 @@ import { stripSafeWrappers, stripWrappersFromArgv, } from './bashPermissions' +import { checkPathConstraints } from './pathValidation' +import { checkReadOnlyConstraints } from './readOnlyValidation' + +const originalCwd = getOriginalCwd() +const originalHome = process.env.HOME +const originalUserProfile = process.env.USERPROFILE +let root: string +let project: string +let home: string describe('bash permission command normalization', () => { test('extracts stable prefixes only through safe env vars', () => { @@ -45,3 +66,210 @@ describe('bash permission command normalization', () => { expect(matchWildcardPattern('git commit', 'git status')).toBe(false) }) }) + +describe('bash sensitive read permissions', () => { + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'issue-145-bash-'))) + project = join(root, 'project') + home = join(root, 'home') + await mkdir(join(project, '.ssh'), { recursive: true }) + await mkdir(join(home, '.ssh'), { recursive: true }) + await writeFile(join(project, '.env'), 'secret') + await writeFile(join(project, '.ssh', 'id_rsa'), 'secret') + await writeFile(join(home, '.ssh', 'id_rsa'), 'secret') + process.env.HOME = home + process.env.USERPROFILE = home + setOriginalCwd(project) + }) + + afterEach(async () => { + setOriginalCwd(originalCwd) + if (originalHome === undefined) delete process.env.HOME + else process.env.HOME = originalHome + if (originalUserProfile === undefined) delete process.env.USERPROFILE + else process.env.USERPROFILE = originalUserProfile + await rm(root, { recursive: true, force: true }) + }) + + const check = ( + command: string, + context = getEmptyToolPermissionContext(), + ) => + checkPathConstraints( + { command }, + project, + context, + ) + + test('asks before reading environment and credential files', () => { + for (const command of [ + 'cat .env', + 'cat .ENV', + 'cat .env.local', + 'cat .env*', + 'head -n 5 credentials', + 'grep token secrets.yaml', + 'cat .netrc', + 'cat .npmrc', + 'cat .pgpass', + 'cat token', + 'cat .aws/credentials', + ]) { + const result = check(command) + expect(result.behavior).toBe('ask') + expect(result.decisionReason?.type).toBe('safetyCheck') + } + }) + + test('asks before reading SSH private keys', () => { + for (const command of [ + 'cat .ssh/id_rsa', + 'cat .ssh/ID_RSA', + 'head -n 1 .ssh/id_ed25519', + 'cat .ssh/id_*', + ]) { + expect(check(command).behavior).toBe('ask') + } + }) + + test('checks every read-only command that accepts file paths', () => { + for (const command of [ + 'tac .env', + 'rev .env', + 'fold .env', + 'expand .env', + 'unexpand .env', + 'fmt .env', + 'pr .env', + 'comm .env README.md', + 'cmp .env README.md', + 'tsort .env', + ]) { + expect(check(command).behavior).toBe('ask') + } + }) + + test('checks files matched by glob patterns', () => { + for (const command of [ + 'cat .e*', + 'cat .en?', + 'cat .[e]nv', + 'cat .*', + 'cat .ssh/*', + 'cat .ssh/i*', + 'cat ~/.ssh/*', + ]) { + expect(check(command).behavior).toBe('ask') + } + }) + + test('asks for directory globs only when the file pattern is sensitive', () => { + for (const command of ['cat .s*/id_rsa', 'cat */.env']) { + expect(check(command).behavior).toBe('ask') + } + for (const command of [ + 'cat packages/*/package.json', + 'cat src/**/*.ts', + 'cat app/[id]/page.tsx', + ]) { + expect(check(command).behavior).toBe('passthrough') + } + }) + + test('allows reads of existing directories with sensitive names', async () => { + await mkdir(join(project, 'src', 'token'), { recursive: true }) + await mkdir(join(project, 'credentials')) + + expect(check('ls src/token').behavior).toBe('passthrough') + expect(check('ls credentials').behavior).toBe('passthrough') + }) + + test('checks the original name of a file symlink', async () => { + const envDirectory = join(project, 'envs') + await mkdir(envDirectory) + await writeFile(join(envDirectory, 'prod.conf'), 'secret') + await rm(join(project, '.env')) + try { + await symlink( + join(envDirectory, 'prod.conf'), + join(project, '.env'), + 'file', + ) + } catch (error) { + if ( + process.platform !== 'win32' || + !(error instanceof Error) || + !('code' in error) || + error.code !== 'EPERM' + ) { + throw error + } + await symlink(envDirectory, join(project, '.env'), 'junction') + } + + expect(check('cat .env').behavior).toBe('ask') + }) + + test('allows environment templates but protects environment variants', () => { + for (const command of [ + 'cat .env.example', + 'cat .env.sample', + 'cat .env.template', + 'cat .env.dist', + 'cat .envrc', + 'cat .environment', + ]) { + expect(check(command).behavior).toBe('passthrough') + } + expect(check('cat .env.local').behavior).toBe('ask') + }) + + test('skips sensitive read prompts while bypass is active', () => { + const bypassContext = { + ...getEmptyToolPermissionContext(), + mode: 'bypassPermissions' as const, + isBypassPermissionsModeAvailable: true, + } + const planFromBypassContext = { + ...getEmptyToolPermissionContext(), + mode: 'plan' as const, + isBypassPermissionsModeAvailable: true, + } + + expect(check('cat .env').behavior).toBe('ask') + expect(check('cat .env', bypassContext).behavior).toBe('passthrough') + expect(check('cat .env', planFromBypassContext).behavior).toBe( + 'passthrough', + ) + }) + + test('does not suggest an ineffective directory allow rule', () => { + expect(check('cat .env').suggestions).toEqual([]) + }) + + test('keeps safe SSH files and ordinary reads on the read-only path', () => { + for (const command of [ + 'cat README.md', + 'cat .ssh/id_rsa.pub', + 'cat .ssh/known_hosts', + 'cat .ssh/config', + 'cat .bashrc', + ]) { + expect(check(command).behavior).toBe('passthrough') + } + }) +}) + +describe('bash data-backed read-only permissions', () => { + test('allows only the exact shell no-op shape', () => { + expect(checkReadOnlyConstraints({ command: ':' }, false).behavior).toBe( + 'allow', + ) + expect( + checkReadOnlyConstraints({ command: ': $(whoami)' }, false).behavior, + ).toBe('passthrough') + expect( + checkReadOnlyConstraints({ command: ': && rm x' }, false).behavior, + ).not.toBe('allow') + }) +}) diff --git a/src/tools/BashTool/pathValidation.ts b/src/tools/BashTool/pathValidation.ts index 29cd0338..338d5658 100644 --- a/src/tools/BashTool/pathValidation.ts +++ b/src/tools/BashTool/pathValidation.ts @@ -36,6 +36,16 @@ export type PathCommand = | 'mv' | 'cp' | 'cat' + | 'tac' + | 'rev' + | 'fold' + | 'expand' + | 'unexpand' + | 'fmt' + | 'pr' + | 'comm' + | 'cmp' + | 'tsort' | 'head' | 'tail' | 'sort' @@ -277,6 +287,16 @@ export const PATH_EXTRACTORS: Record< mv: filterOutFlags, cp: filterOutFlags, cat: filterOutFlags, + tac: filterOutFlags, + rev: filterOutFlags, + fold: filterOutFlags, + expand: filterOutFlags, + unexpand: filterOutFlags, + fmt: filterOutFlags, + pr: filterOutFlags, + comm: filterOutFlags, + cmp: filterOutFlags, + tsort: filterOutFlags, head: filterOutFlags, tail: filterOutFlags, sort: filterOutFlags, @@ -522,6 +542,16 @@ const ACTION_VERBS: Record = { mv: 'move files to/from', cp: 'copy files to/from', cat: 'concatenate files from', + tac: 'concatenate files from', + rev: 'reverse text from files in', + fold: 'wrap text from files in', + expand: 'expand tabs in files from', + unexpand: 'compress spaces in files from', + fmt: 'format text from files in', + pr: 'paginate files from', + comm: 'compare files from', + cmp: 'compare files from', + tsort: 'sort dependencies from files in', head: 'read the beginning of files from', tail: 'read the end of files from', sort: 'sort contents of files from', @@ -561,6 +591,16 @@ export const COMMAND_OPERATION_TYPE: Record = { mv: 'write', cp: 'write', cat: 'read', + tac: 'read', + rev: 'read', + fold: 'read', + expand: 'read', + unexpand: 'read', + fmt: 'read', + pr: 'read', + comm: 'read', + cmp: 'read', + tsort: 'read', head: 'read', tail: 'read', sort: 'read', @@ -747,9 +787,13 @@ export function createPathChecker( const operationType = operationTypeOverride ?? COMMAND_OPERATION_TYPE[command] const suggestions: PermissionUpdate[] = [] + const isSensitiveReadCheck = + operationType === 'read' && + result.decisionReason?.type === 'safetyCheck' && + !result.decisionReason.classifierApprovable // Only suggest adding directory/rules if we have a blocked path - if (result.blockedPath) { + if (result.blockedPath && !isSensitiveReadCheck) { if (operationType === 'read') { // For read operations, suggest a Read rule for the directory (only if it exists) const dirPath = getDirectoryForPath(result.blockedPath) diff --git a/src/tools/BashTool/readOnlyValidation.ts b/src/tools/BashTool/readOnlyValidation.ts index 784222ae..3db29e15 100644 --- a/src/tools/BashTool/readOnlyValidation.ts +++ b/src/tools/BashTool/readOnlyValidation.ts @@ -1538,6 +1538,7 @@ const READONLY_COMMAND_REGEXES = new Set([ /^node --version$/, /^python --version$/, /^python3 --version$/, + /^:$/, // Misc. safe commands // tree command moved to COMMAND_ALLOWLIST for proper flag validation (blocks -o/--output) diff --git a/src/utils/permissions/PermissionMode.ts b/src/utils/permissions/PermissionMode.ts index 0dbd9363..3e8ee50f 100644 --- a/src/utils/permissions/PermissionMode.ts +++ b/src/utils/permissions/PermissionMode.ts @@ -1,5 +1,6 @@ import { feature } from 'bun:bundle' import z from 'zod/v4' +import type { ToolPermissionContext } from '../../Tool.js' import { PAUSE_ICON } from '../../constants/figures.js' // Types extracted to src/types/permissions.ts to break import cycles import { @@ -128,6 +129,15 @@ export function isDefaultMode(mode: PermissionMode | undefined): boolean { return mode === 'default' || mode === undefined } +export function isBypassPermissionsActive( + context: ToolPermissionContext, +): boolean { + return ( + context.mode === 'bypassPermissions' || + (context.mode === 'plan' && context.isBypassPermissionsModeAvailable) + ) +} + export function permissionModeShortTitle(mode: PermissionMode): string { return getModeConfig(mode).shortTitle } diff --git a/src/utils/permissions/__tests__/sensitiveReads.test.ts b/src/utils/permissions/__tests__/sensitiveReads.test.ts new file mode 100644 index 00000000..3034dfc1 --- /dev/null +++ b/src/utils/permissions/__tests__/sensitiveReads.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { + mkdir, + mkdtemp, + realpath, + rm, + symlink, + writeFile, +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getOriginalCwd, setOriginalCwd } from '../../../bootstrap/state.js' +import { getEmptyToolPermissionContext } from '../../../Tool.js' +import { FileReadTool } from '../../../tools/FileReadTool/FileReadTool.js' +import { + checkReadPermissionForTool, + isSensitiveReadPath, +} from '../filesystem.js' + +const originalCwd = getOriginalCwd() +let root: string +let project: string + +beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'sensitive-reads-'))) + project = join(root, 'project') + await mkdir(project) + setOriginalCwd(project) +}) + +afterEach(async () => { + setOriginalCwd(originalCwd) + await rm(root, { recursive: true, force: true }) +}) + +const read = ( + path: string, + context = getEmptyToolPermissionContext(), +) => + checkReadPermissionForTool( + FileReadTool, + { file_path: join(project, path) }, + context, + ) + +describe('sensitive Read tool permissions', () => { + test('asks before reading environment and credential files', () => { + for (const path of [ + '.env', + '.env.local', + 'credentials', + 'secrets.yaml', + '.netrc', + '.npmrc', + '.pgpass', + 'token', + join('.aws', 'credentials'), + ]) { + const result = read(path) + expect(result.behavior).toBe('ask') + expect(result.decisionReason?.type).toBe('safetyCheck') + } + }) + + test('asks before reading SSH private keys', () => { + for (const path of [ + join('.ssh', 'id_rsa'), + join('.ssh', 'id_ed25519'), + join('.ssh', 'id_custom'), + ]) { + expect(read(path).behavior).toBe('ask') + } + }) + + test('allows safe SSH files, shell profiles, and ordinary project files', () => { + for (const path of [ + 'README.md', + '.env.example', + '.env.sample', + '.env.template', + '.env.dist', + '.envrc', + '.environment', + join('.ssh', 'id_rsa.pub'), + join('.ssh', 'known_hosts'), + join('.ssh', 'config'), + '.bashrc', + ]) { + expect(read(path).behavior).toBe('allow') + } + }) + + test('allows existing directories with sensitive names', async () => { + await mkdir(join(project, 'token')) + await mkdir(join(project, 'credentials')) + + expect(read('token').behavior).toBe('allow') + expect(read('credentials').behavior).toBe('allow') + }) + + test('matches case variants and Windows path separators', () => { + expect(read('.ENV').behavior).toBe('ask') + expect(isSensitiveReadPath('C:\\project\\.ssh\\ID_RSA')).toBe(true) + }) + + test('skips sensitive read prompts while bypass is active', () => { + const bypassContext = { + ...getEmptyToolPermissionContext(), + mode: 'bypassPermissions' as const, + isBypassPermissionsModeAvailable: true, + } + const planFromBypassContext = { + ...getEmptyToolPermissionContext(), + mode: 'plan' as const, + isBypassPermissionsModeAvailable: true, + } + + expect(read('.env').behavior).toBe('ask') + expect(read('.env', bypassContext).behavior).toBe('allow') + expect(read('.env', planFromBypassContext).behavior).toBe('allow') + }) + + test('checks the original name of a file symlink', async () => { + const envDirectory = join(project, 'envs') + await mkdir(envDirectory) + await writeFile(join(envDirectory, 'prod.conf'), 'secret') + try { + await symlink( + join(envDirectory, 'prod.conf'), + join(project, '.env'), + 'file', + ) + } catch (error) { + if ( + process.platform !== 'win32' || + !(error instanceof Error) || + !('code' in error) || + error.code !== 'EPERM' + ) { + throw error + } + await symlink(envDirectory, join(project, '.env'), 'junction') + } + + const result = read('.env') + expect(result.behavior).toBe('ask') + expect(result.decisionReason?.type).toBe('safetyCheck') + }) + + test('checks the resolved target of a directory symlink', async () => { + const sshDirectory = join(root, 'outside', '.ssh') + await mkdir(sshDirectory, { recursive: true }) + await writeFile(join(sshDirectory, 'id_rsa'), 'secret') + await symlink(sshDirectory, join(project, 'keys'), 'junction') + + const result = read(join('keys', 'id_rsa')) + expect(result.behavior).toBe('ask') + expect(result.decisionReason?.type).toBe('safetyCheck') + }) +}) diff --git a/src/utils/permissions/filesystem.ts b/src/utils/permissions/filesystem.ts index f433142e..f414f0e3 100644 --- a/src/utils/permissions/filesystem.ts +++ b/src/utils/permissions/filesystem.ts @@ -4,6 +4,7 @@ import { artifactDenialReason, artifactLocationDenial, isOwnArtifactWrite, OWN_A import { randomBytes } from 'crypto' import ignore from 'ignore' import memoize from 'lodash-es/memoize.js' +import picomatch from 'picomatch' import { homedir, tmpdir } from 'os' import { join, normalize, posix, sep } from 'path' import { hasAutoMemPathOverride, isAutoMemPath } from 'src/memdir/paths.js' @@ -45,6 +46,7 @@ import type { PermissionResult, } from './PermissionResult.js' import type { PermissionRule, PermissionRuleSource } from './PermissionRule.js' +import { isBypassPermissionsActive } from './PermissionMode.js' import { createReadRuleSuggestion } from './PermissionUpdate.js' import type { PermissionUpdate } from './PermissionUpdateSchema.js' import { getRuleByContentsForToolName } from './permissions.js' @@ -68,6 +70,30 @@ export const DANGEROUS_FILES = [ '.claude.json', ] as const +export const SENSITIVE_READ_FILES = [ + 'credentials', + 'secrets.yaml', + '.netrc', + '.npmrc', + '.pgpass', + 'token', +] as const + +const SAFE_ENV_FILE_SUFFIXES = new Set([ + 'example', + 'sample', + 'template', + 'dist', +]) + +const SENSITIVE_READ_GLOB_CANDIDATES = [ + ...SENSITIVE_READ_FILES, + '.env', + '.env.local', + 'id_rsa', + 'id_ed25519', +] + /** * Dangerous directories that should be protected from auto-editing. * These directories contain sensitive configuration or executable files. @@ -93,6 +119,82 @@ export function normalizeCaseForComparison(path: string): string { return path.toLowerCase() } +export function isSensitiveReadPath(path: string): boolean { + const parts = normalizeCaseForComparison(path) + .replace(/[\\]+/g, '/') + .split('/') + .filter(Boolean) + const fileName = parts.at(-1) + if (!fileName) return false + if (fileName === '.env') return true + if ( + fileName.startsWith('.env.') && + !SAFE_ENV_FILE_SUFFIXES.has(fileName.slice('.env.'.length)) + ) { + return true + } + if (SENSITIVE_READ_FILES.some(name => name === fileName)) return true + return ( + parts.at(-2) === '.ssh' && + fileName.startsWith('id_') && + !fileName.endsWith('.pub') + ) +} + +export function isSensitiveReadFilePattern(pattern: string): boolean { + if ( + isSensitiveReadPath(pattern) || + isSensitiveReadPath(join('.ssh', pattern)) + ) { + return true + } + return SENSITIVE_READ_GLOB_CANDIDATES.some(candidate => + picomatch.isMatch(candidate, pattern, { nocase: true }), + ) +} + +export function getSensitiveReadPermission( + path: string, + sensitivePath: string, + toolPermissionContext: ToolPermissionContext, +): PermissionDecision | null { + if (isBypassPermissionsActive(toolPermissionContext)) return null + const reason = `Reading ${sensitivePath} may expose secrets and requires explicit approval` + return { + behavior: 'ask', + message: `Claude requested permission to read ${path}, which may contain secrets.`, + decisionReason: { + type: 'safetyCheck', + reason, + classifierApprovable: false, + }, + } +} + +export function checkSensitiveReadPermission( + path: string, + toolPermissionContext: ToolPermissionContext, + precomputedPathsToCheck?: readonly string[], +): PermissionDecision | null { + if (isBypassPermissionsActive(toolPermissionContext)) return null + const pathsToCheck = + precomputedPathsToCheck ?? getPathsForPermissionCheck(path) + const sensitivePath = pathsToCheck.find(candidate => { + if (!isSensitiveReadPath(candidate)) return false + try { + return !getFsImplementation().lstatSync(candidate).isDirectory() + } catch { + return true + } + }) + if (!sensitivePath) return null + return getSensitiveReadPermission( + path, + sensitivePath, + toolPermissionContext, + ) +} + /** * Rule prefix for DreamCoder's own config folder, without the trailing `**`: * `~/.dreamcoder/` when it is under the home directory, else an absolute @@ -1161,6 +1263,15 @@ export function checkReadPermissionForTool( } } + const sensitiveReadResult = checkSensitiveReadPermission( + path, + toolPermissionContext, + pathsToCheck, + ) + if (sensitiveReadResult) { + return sensitiveReadResult + } + // 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( diff --git a/src/utils/permissions/pathValidation.ts b/src/utils/permissions/pathValidation.ts index 2028b69e..e146a43f 100644 --- a/src/utils/permissions/pathValidation.ts +++ b/src/utils/permissions/pathValidation.ts @@ -1,8 +1,11 @@ import memoize from 'lodash-es/memoize.js' +import picomatch from 'picomatch' import { allowsOwnArtifactWrite, artifactDenialFor, OWN_ARTIFACT_WRITE_REASON } from './artifactLocation.js' +import { readdirSync } from 'node:fs' import { homedir } from 'os' -import { dirname, isAbsolute, resolve } from 'path' +import { basename, dirname, isAbsolute, join, resolve } from 'path' import type { ToolPermissionContext } from '../../Tool.js' +import { logForDebugging } from '../debug.js' import { getPlatform } from '../../utils/platform.js' import { getFsImplementation, @@ -16,11 +19,17 @@ import { checkEditableInternalPath, checkPathSafetyForAutoEdit, checkReadableInternalPath, + checkSensitiveReadPermission, + getSensitiveReadPermission, + isSensitiveReadFilePattern, matchingRuleForInput, pathInAllowedWorkingPath, pathInWorkingPath, } from './filesystem.js' -import type { PermissionDecisionReason } from './PermissionResult.js' +import type { + PermissionDecision, + PermissionDecisionReason, +} from './PermissionResult.js' const MAX_DIRS_TO_LIST = 5 const GLOB_PATTERN_REGEX = /[*?[\]{}]/ @@ -89,6 +98,48 @@ export function expandTilde(path: string): string { return path } +function checkSensitiveGlobReadPermission( + path: string, + cwd: string, + context: ToolPermissionContext, +): PermissionDecision | null { + const absolutePattern = isAbsolute(path) ? path : resolve(cwd, path) + const directory = dirname(absolutePattern) + if (GLOB_PATTERN_REGEX.test(directory)) { + const filePattern = basename(absolutePattern) + if (!isSensitiveReadFilePattern(filePattern)) return null + return getSensitiveReadPermission( + absolutePattern, + absolutePattern, + context, + ) + } + + let entries: string[] + try { + entries = readdirSync(directory) + } catch (error) { + logForDebugging( + `Unable to inspect glob directory ${directory}: ${String(error)}`, + ) + return checkSensitiveReadPermission(absolutePattern, context, [ + absolutePattern, + ]) + } + + const pattern = basename(absolutePattern) + for (const entry of entries) { + if (!picomatch.isMatch(entry, pattern)) continue + const matchedPath = join(directory, entry) + const result = checkSensitiveReadPermission(matchedPath, context) + if (result) return result + } + + return checkSensitiveReadPermission(absolutePattern, context, [ + absolutePattern, + ]) +} + /** * Checks if a resolved path is writable according to the sandbox write allowlist. * When the sandbox is enabled, the user has explicitly configured which directories @@ -145,6 +196,10 @@ export function isPathAllowed( operationType: FileOperationType, precomputedPathsToCheck?: readonly string[], ): PathCheckResult { + const pathsToCheck = + operationType === 'read' + ? (precomputedPathsToCheck ?? getPathsForPermissionCheck(resolvedPath)) + : precomputedPathsToCheck // Determine which permission type to check based on operation const permissionType = operationType === 'read' ? 'read' : 'edit' @@ -162,7 +217,10 @@ export function isPathAllowed( } } - const artifactDenial = artifactDenialFor(precomputedPathsToCheck ?? [resolvedPath], operationType) + const artifactDenial = artifactDenialFor( + pathsToCheck ?? [resolvedPath], + operationType, + ) if (artifactDenial) { return { allowed: false, @@ -170,6 +228,20 @@ export function isPathAllowed( } } + if (operationType === 'read') { + const sensitiveReadResult = checkSensitiveReadPermission( + resolvedPath, + context, + pathsToCheck, + ) + if (sensitiveReadResult) { + return { + allowed: false, + decisionReason: sensitiveReadResult.decisionReason, + } + } + } + // 2. For write/create operations, check internal editable paths (plan files, scratchpad, agent memory, job dirs) // This MUST come before checkPathSafetyForAutoEdit since .claude is a dangerous directory // and internal editable paths live under ~/.claude/ — matching the ordering in @@ -217,7 +289,7 @@ export function isPathAllowed( const isInWorkingDir = pathInAllowedWorkingPath( resolvedPath, context, - precomputedPathsToCheck, + pathsToCheck, ) if (isInWorkingDir) { if (operationType === 'read' || context.mode === 'acceptEdits') { @@ -469,13 +541,27 @@ export function validatePath( } } - // For read operations, validate the base directory where the glob would expand - return validateGlobPattern( + const result = validateGlobPattern( cleanPath, cwd, toolPermissionContext, operationType, ) + if (!result.allowed) return result + const absolutePath = isAbsolute(cleanPath) + ? cleanPath + : resolve(cwd, cleanPath) + const sensitiveReadResult = checkSensitiveGlobReadPermission( + cleanPath, + cwd, + toolPermissionContext, + ) + if (!sensitiveReadResult) return result + return { + allowed: false, + resolvedPath: absolutePath, + decisionReason: sensitiveReadResult.decisionReason, + } } // Resolve path @@ -493,6 +579,19 @@ export function validatePath( operationType, isCanonical ? [resolvedPath] : undefined, ) + if (result.allowed && operationType === 'read') { + const sensitiveReadResult = checkSensitiveReadPermission( + absolutePath, + toolPermissionContext, + ) + if (sensitiveReadResult) { + return { + allowed: false, + resolvedPath: absolutePath, + decisionReason: sensitiveReadResult.decisionReason, + } + } + } return { allowed: result.allowed, resolvedPath, diff --git a/src/utils/permissions/permissions.ts b/src/utils/permissions/permissions.ts index 99db042d..24b46cd4 100644 --- a/src/utils/permissions/permissions.ts +++ b/src/utils/permissions/permissions.ts @@ -22,7 +22,10 @@ import { SETTING_SOURCES, } from '../settings/constants.js' import { plural } from '../stringUtils.js' -import { permissionModeTitle } from './PermissionMode.js' +import { + isBypassPermissionsActive, + permissionModeTitle, +} from './PermissionMode.js' import type { PermissionAskDecision, PermissionDecision, @@ -1265,10 +1268,9 @@ async function hasPermissionsToUseToolInner( // Check if permissions should be bypassed: // - Direct bypassPermissions mode // - Plan mode when the user originally started with bypass mode (isBypassPermissionsModeAvailable) - const shouldBypassPermissions = - appState.toolPermissionContext.mode === 'bypassPermissions' || - (appState.toolPermissionContext.mode === 'plan' && - appState.toolPermissionContext.isBypassPermissionsModeAvailable) + const shouldBypassPermissions = isBypassPermissionsActive( + appState.toolPermissionContext, + ) if (shouldBypassPermissions) { return { behavior: 'allow',