Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
230 changes: 229 additions & 1 deletion src/tools/BashTool/bashPermissions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})
46 changes: 45 additions & 1 deletion src/tools/BashTool/pathValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ export type PathCommand =
| 'mv'
| 'cp'
| 'cat'
| 'tac'
| 'rev'
| 'fold'
| 'expand'
| 'unexpand'
| 'fmt'
| 'pr'
| 'comm'
| 'cmp'
| 'tsort'
| 'head'
| 'tail'
| 'sort'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -522,6 +542,16 @@ const ACTION_VERBS: Record<PathCommand, string> = {
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',
Expand Down Expand Up @@ -561,6 +591,16 @@ export const COMMAND_OPERATION_TYPE: Record<PathCommand, FileOperationType> = {
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',
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/tools/BashTool/readOnlyValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/utils/permissions/PermissionMode.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
Loading
Loading