fix: auto-allow safe read-only commands in acceptEdits mode (#341)

* fix: auto-allow safe read-only commands in acceptEdits mode

In acceptEdits mode, read-only commands like grep, cat, ls, find, head,
tail were still prompting for approval. This created unnecessary friction
since these commands cannot modify or delete files.

Add safe read-only commands to ACCEPT_EDITS_ALLOWED_COMMANDS:
  grep, cat, ls, find, head, tail, echo, pwd, wc, sort, uniq, diff

These are all read-only — they cannot cause data loss or modify the
filesystem. Auto-allowing them reduces approval fatigue in acceptEdits
mode without introducing any safety risk.

Write commands (rm, rmdir, mv, cp, sed, mkdir, touch) are unchanged.
The dangerous path guard for rm/rmdir remains in place.

Fixes #251.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(bash): block unsafe acceptEdits auto-allow

Keep the new read-only acceptEdits commands behind the existing read-only validator and block shell redirection based on the original command text. This prevents commands like echo > file and find -delete from being silently auto-approved while preserving safe read-only commands.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
KRATOS
2026-04-04 20:23:09 +05:30
committed by GitHub
parent e4cf810e14
commit 01acc4c10e
2 changed files with 137 additions and 8 deletions

View File

@@ -0,0 +1,44 @@
import { expect, test } from 'bun:test'
import { getEmptyToolPermissionContext } from '../../Tool.js'
import { checkPermissionMode } from './modeValidation.js'
const acceptEditsContext = {
...getEmptyToolPermissionContext(),
mode: 'acceptEdits' as const,
}
test('acceptEdits does not auto-allow read commands with output redirection', () => {
const result = checkPermissionMode(
{ command: 'echo hello > output.txt' } as never,
acceptEditsContext,
)
expect(result.behavior).toBe('passthrough')
})
test('acceptEdits does not auto-allow mutating find invocations', () => {
const result = checkPermissionMode(
{ command: 'find . -delete' } as never,
acceptEditsContext,
)
expect(result.behavior).toBe('passthrough')
})
test('acceptEdits still auto-allows safe read-only commands', () => {
const result = checkPermissionMode(
{ command: 'grep foo package.json' } as never,
acceptEditsContext,
)
expect(result.behavior).toBe('allow')
})
test('acceptEdits still blocks dangerous rm paths even in auto-allow mode', () => {
const result = checkPermissionMode(
{ command: 'rm -rf ~' } as never,
acceptEditsContext,
)
expect(result.behavior).toBe('ask')
})

View File

@@ -1,12 +1,15 @@
import type { z } from 'zod/v4'
import type { ToolPermissionContext } from '../../Tool.js'
import { splitCommand_DEPRECATED } from '../../utils/bash/commands.js'
import { tryParseShellCommand } from '../../utils/bash/shellQuote.js'
import { getCwd } from '../../utils/cwd.js'
import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'
import type { BashTool } from './BashTool.js'
import { checkReadOnlyConstraints } from './readOnlyValidation.js'
import { checkDangerousRemovalPaths } from './pathValidation.js'
const ACCEPT_EDITS_ALLOWED_COMMANDS = [
const ACCEPT_EDITS_WRITE_COMMANDS = [
// Filesystem write commands
'mkdir',
'touch',
'rm',
@@ -16,15 +19,64 @@ const ACCEPT_EDITS_ALLOWED_COMMANDS = [
'sed',
] as const
type FilesystemCommand = (typeof ACCEPT_EDITS_ALLOWED_COMMANDS)[number]
const ACCEPT_EDITS_READ_ONLY_COMMANDS = [
// Safe read-only commands — cannot modify files or cause data loss.
// These still need to pass the existing read-only validator so redirects and
// dangerous flags fall through to the normal permission flow.
'grep',
'cat',
'ls',
'find',
'head',
'tail',
'echo',
'pwd',
'wc',
'sort',
'uniq',
'diff',
] as const
function isFilesystemCommand(command: string): command is FilesystemCommand {
return ACCEPT_EDITS_ALLOWED_COMMANDS.includes(command as FilesystemCommand)
type AcceptEditsWriteCommand = (typeof ACCEPT_EDITS_WRITE_COMMANDS)[number]
type AcceptEditsReadOnlyCommand =
(typeof ACCEPT_EDITS_READ_ONLY_COMMANDS)[number]
function isAcceptEditsWriteCommand(
command: string,
): command is AcceptEditsWriteCommand {
return ACCEPT_EDITS_WRITE_COMMANDS.includes(command as AcceptEditsWriteCommand)
}
function isAcceptEditsReadOnlyCommand(
command: string,
): command is AcceptEditsReadOnlyCommand {
return ACCEPT_EDITS_READ_ONLY_COMMANDS.includes(
command as AcceptEditsReadOnlyCommand,
)
}
function hasShellRedirection(cmd: string): boolean {
const parsed = tryParseShellCommand(cmd, env => `$${env}`)
if (!parsed.success) {
// Fail closed: unparseable commands should go through the normal prompt flow.
return true
}
return parsed.tokens.some(
token =>
typeof token === 'object' &&
token !== null &&
'op' in token &&
['>', '>>', '>|', '&>', '&>>', '1>', '1>>', '2>', '2>>'].includes(
String(token.op),
),
)
}
function validateCommandForMode(
cmd: string,
toolPermissionContext: ToolPermissionContext,
originalInput: string,
): PermissionResult {
const trimmedCmd = cmd.trim()
const [baseCmd] = trimmedCmd.split(/\s+/)
@@ -36,10 +88,10 @@ function validateCommandForMode(
}
}
// In Accept Edits mode, auto-allow filesystem operations
// In Accept Edits mode, auto-allow filesystem write operations.
if (
toolPermissionContext.mode === 'acceptEdits' &&
isFilesystemCommand(baseCmd)
isAcceptEditsWriteCommand(baseCmd)
) {
// Guard: always run dangerous path check for rm/rmdir before auto-allowing.
// This prevents rm -rf ~ / rm -rf / from bypassing checkDangerousRemovalPaths
@@ -62,6 +114,37 @@ function validateCommandForMode(
}
}
// In Accept Edits mode, only auto-allow read-only commands if they still
// pass the full read-only validator. This prevents redirects and mutating
// find forms from being silently auto-approved.
if (
toolPermissionContext.mode === 'acceptEdits' &&
isAcceptEditsReadOnlyCommand(baseCmd)
) {
if (hasShellRedirection(originalInput)) {
return {
behavior: 'passthrough',
message:
'Read-only commands with shell redirection require normal permission checks',
}
}
const readOnlyResult = checkReadOnlyConstraints(
{ command: cmd } as z.infer<typeof BashTool.inputSchema>,
false,
)
if (readOnlyResult.behavior === 'allow') {
return {
behavior: 'allow',
updatedInput: { command: cmd },
decisionReason: {
type: 'mode',
mode: 'acceptEdits',
},
}
}
}
return {
behavior: 'passthrough',
message: `No mode-specific handling for '${baseCmd}' in ${toolPermissionContext.mode} mode`,
@@ -106,7 +189,7 @@ export function checkPermissionMode(
// Check each subcommand
for (const cmd of commands) {
const result = validateCommandForMode(cmd, toolPermissionContext)
const result = validateCommandForMode(cmd, toolPermissionContext, input.command)
// If any command triggers mode-specific behavior, return that result
if (result.behavior !== 'passthrough') {
@@ -124,5 +207,7 @@ export function checkPermissionMode(
export function getAutoAllowedCommands(
mode: ToolPermissionContext['mode'],
): readonly string[] {
return mode === 'acceptEdits' ? ACCEPT_EDITS_ALLOWED_COMMANDS : []
return mode === 'acceptEdits'
? [...ACCEPT_EDITS_WRITE_COMMANDS, ...ACCEPT_EDITS_READ_ONLY_COMMANDS]
: []
}