diff --git a/docs/design/2026-08-08-read-only-git-config-safety.md b/docs/design/2026-08-08-read-only-git-config-safety.md new file mode 100644 index 00000000000..a7f3224bcb2 --- /dev/null +++ b/docs/design/2026-08-08-read-only-git-config-safety.md @@ -0,0 +1,9 @@ +# Read-only Git config safety + +Issue #8575 proves two repository-local configuration paths that turn an otherwise read-only command into program execution: `diff.external` for `git diff`, and `core.fsmonitor` for `git status`. + +The classifier will ask only for those reproduced command/config pairs. It will query effective local and worktree values through `git config --includes --show-scope`, so Git owns config syntax, include handling, precedence, and worktree behavior. Git probe and parse errors fail closed; a cwd that cannot be entered has no config execution path. + +Commands that change directory before the relevant Git command also ask. The classifier will not simulate shell cwd state or resolve `git -C`; the latter is already outside the read-only allowlist. + +Other execution-bearing Git settings are follow-up work only after an independent reproduction identifies the affected read-only subcommand. diff --git a/packages/core/src/core/plan-mode-shell-policy.ts b/packages/core/src/core/plan-mode-shell-policy.ts index 318b91d5cfa..799ce4b87e0 100644 --- a/packages/core/src/core/plan-mode-shell-policy.ts +++ b/packages/core/src/core/plan-mode-shell-policy.ts @@ -16,6 +16,7 @@ import type { import { ToolConfirmationOutcome } from '../tools/tools.js'; import { classifyShellCommandSafety, + classifyShellCommandSafetyInDirectory, type ShellCommandSafety, } from '../utils/shellAstParser.js'; import { normalizeMonitorCommand } from '../utils/shell-utils.js'; @@ -164,7 +165,13 @@ export async function evaluatePlanModeShellPolicy(input: { let classification: ShellCommandSafety; try { classification = await raceWithAbort( - () => classifyShellCommandSafety(safetyCommand), + () => + permissionContext.cwd + ? classifyShellCommandSafetyInDirectory( + safetyCommand, + permissionContext.cwd, + ) + : classifyShellCommandSafety(safetyCommand), input.signal, ); } catch (error) { diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 814ff583cf5..e5ed0f2d0a4 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -299,6 +299,7 @@ async function runSpeculativeLoop( args, state.overlayFs!, approvalMode, + config.getTargetDir?.(), ); if (gate.action === 'boundary') { diff --git a/packages/core/src/followup/speculationToolGate.ts b/packages/core/src/followup/speculationToolGate.ts index e06e39e984f..51997b49c4c 100644 --- a/packages/core/src/followup/speculationToolGate.ts +++ b/packages/core/src/followup/speculationToolGate.ts @@ -16,7 +16,10 @@ */ import { ToolNames } from '../tools/tool-names.js'; -import { classifyShellCommandSafety } from '../utils/shellAstParser.js'; +import { + classifyShellCommandSafety, + classifyShellCommandSafetyInDirectory, +} from '../utils/shellAstParser.js'; import { ApprovalMode } from '../config/config.js'; import { unescapePath, PATH_ARG_KEYS } from '../utils/paths.js'; import type { OverlayFs } from './overlayFs.js'; @@ -61,6 +64,7 @@ const BOUNDARY_TOOLS = new Set([ * @param args - The tool call arguments * @param overlayFs - The overlay filesystem for path rewriting * @param approvalMode - The user's current approval mode + * @param cwd - Default execution directory for shell commands * @returns Gate result: allow, redirect, or boundary */ export async function evaluateToolCall( @@ -68,6 +72,7 @@ export async function evaluateToolCall( args: Record, overlayFs: OverlayFs, approvalMode: ApprovalMode, + cwd?: string, ): Promise { // Safe read-only tools — allow, but resolve paths through overlay if (SAFE_READ_ONLY_TOOLS.has(toolName)) { @@ -95,9 +100,15 @@ export async function evaluateToolCall( // Shell — use AST parser for accurate read-only detection if (toolName === ToolNames.SHELL) { const command = typeof args['command'] === 'string' ? args['command'] : ''; + const directory = + typeof args['directory'] === 'string' && args['directory'] + ? args['directory'] + : cwd; if ( command && - (await classifyShellCommandSafety(command)) === 'read-only' + (await (directory + ? classifyShellCommandSafetyInDirectory(command, directory) + : classifyShellCommandSafety(command))) === 'read-only' ) { return { action: 'allow' }; } diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index ca3784c3d4c..a1cd672b48d 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -13,7 +13,7 @@ import type { PermissionDecision, } from '../permissions/types.js'; import { ToolNames } from '../tools/tool-names.js'; -import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; +import { isShellCommandReadOnlyASTInDirectory } from '../utils/shellAstParser.js'; import { stripShellWrapper } from '../utils/shell-utils.js'; import { AUTO_MEMORY_PINNED_DIRNAME, @@ -250,8 +250,9 @@ async function evaluateScopedDecision( if (!opts.allowShell || !ctx.command) { return 'deny'; } - const isReadOnly = await isShellCommandReadOnlyAST( + const isReadOnly = await isShellCommandReadOnlyASTInDirectory( stripShellWrapper(ctx.command), + ctx.cwd ?? projectRoot, ); return isReadOnly ? 'allow' : 'deny'; } diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index dead3984ec8..15964b89746 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -2083,6 +2083,20 @@ describe('PermissionManager', () => { }); describe('compound command evaluation', () => { + it('keeps Git after a directory change in the confirmation boundary', async () => { + pm = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash(cd *)'] }), + ); + pm.initialize(); + expect( + await pm.evaluate({ + toolName: 'run_shell_command', + command: 'cd /tmp && git status', + cwd: process.cwd(), + }), + ).toBe('ask'); + }); + it('all sub-commands allowed → allow', async () => { pm = new PermissionManager( makeConfig({ diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index df8851895e2..8e53833e099 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -16,7 +16,10 @@ import { import type { PathMatchContext } from './rule-parser.js'; import { extractShellOperationsAcrossCommand } from './shell-semantics.js'; import type { ShellOperation } from './shell-semantics.js'; -import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; +import { + isShellCommandReadOnlyAST, + isShellCommandReadOnlyASTInDirectory, +} from '../utils/shellAstParser.js'; import { normalizeMonitorCommand } from '../utils/shell-utils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { @@ -233,7 +236,10 @@ export class PermissionManager { SHELL_TOOL_NAMES.has(toolName) && command !== undefined ) { - bashDecision = await this.resolveDefaultPermission(command); + bashDecision = await this.resolveDefaultPermission( + command, + ctx.cwd ?? this.config.getCwd?.(), + ); } } } else { @@ -449,6 +455,9 @@ export class PermissionManager { }; let mostRestrictive: ResolvedDecision = 'allow'; + const changesDirectory = subCommands.some((command) => + /^\s*(?:cd|pushd)(?:\s|$)/.test(command), + ); for (const subCmd of subCommands) { const subCtx: PermissionCheckContext = { @@ -461,7 +470,10 @@ export class PermissionManager { // (same logic as ShellToolInvocation.getDefaultPermission) const decision: ResolvedDecision = rawDecision === 'default' - ? await this.resolveDefaultPermission(subCmd) + ? await this.resolveDefaultPermission( + changesDirectory ? ctx.command! : subCmd, + ctx.cwd ?? this.config.getCwd?.(), + ) : (rawDecision as ResolvedDecision); if (PRIORITY[decision] > PRIORITY[mostRestrictive]) { @@ -495,9 +507,12 @@ export class PermissionManager { */ private async resolveDefaultPermission( command: string, + cwd?: string, ): Promise<'allow' | 'ask'> { try { - const isReadOnly = await isShellCommandReadOnlyAST(command); + const isReadOnly = cwd + ? await isShellCommandReadOnlyASTInDirectory(command, cwd) + : await isShellCommandReadOnlyAST(command); if (isReadOnly) { return 'allow'; } diff --git a/packages/core/src/tools/monitor.test.ts b/packages/core/src/tools/monitor.test.ts index e442690383c..73aa05c49dc 100644 --- a/packages/core/src/tools/monitor.test.ts +++ b/packages/core/src/tools/monitor.test.ts @@ -134,7 +134,7 @@ vi.mock('../utils/shell-utils.js', async (importOriginal) => { const mockIsShellCommandReadOnlyAST = vi.hoisted(() => vi.fn()); const mockExtractCommandRules = vi.hoisted(() => vi.fn()); vi.mock('../utils/shellAstParser.js', () => ({ - isShellCommandReadOnlyAST: mockIsShellCommandReadOnlyAST, + isShellCommandReadOnlyASTInDirectory: mockIsShellCommandReadOnlyAST, extractCommandRules: mockExtractCommandRules, })); diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index ab2c925c467..fcb20d93064 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -51,7 +51,7 @@ import { import { MAX_CONCURRENT_MONITORS } from '../services/monitorRegistry.js'; import { extractCommandRules, - isShellCommandReadOnlyAST, + isShellCommandReadOnlyASTInDirectory, } from '../utils/shellAstParser.js'; import { getCurrentAgentId } from '../agents/runtime/agent-context.js'; import { getShellContextEnvVars } from '../utils/shellContextEnv.js'; @@ -171,9 +171,10 @@ class MonitorToolInvocation extends BaseToolInvocation< } override async getDefaultPermission(): Promise { - const command = normalizeMonitorShellCommand( - this.params.command, - ).safetyCommand; + const normalized = normalizeMonitorShellCommand(this.params.command); + const command = normalized.safetyCommand; + const cwd = + this.params.directory || this.config.getTargetDir?.() || process.cwd(); // Command substitution ($(), ``, <(), >()) is NOT a hard deny here — // it falls through to 'ask' along with every other non-read-only @@ -188,7 +189,10 @@ class MonitorToolInvocation extends BaseToolInvocation< // Bash(...) — see comment in getConfirmationDetails); only the // substitution-deny half is removed. try { - const isReadOnly = await isShellCommandReadOnlyAST(command); + const isReadOnly = await isShellCommandReadOnlyASTInDirectory( + command, + cwd, + ); if (isReadOnly) { return 'allow'; } @@ -203,6 +207,8 @@ class MonitorToolInvocation extends BaseToolInvocation< _abortSignal: AbortSignal, ): Promise { const normalized = normalizeMonitorShellCommand(this.params.command); + const cwd = + this.params.directory || this.config.getTargetDir?.() || process.cwd(); const subCommands = splitCommands(normalized.safetyCommand); const confirmableSubCommands: string[] = []; @@ -216,7 +222,7 @@ class MonitorToolInvocation extends BaseToolInvocation< // permission boundary. let isReadOnly = false; try { - isReadOnly = await isShellCommandReadOnlyAST(sub); + isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd); } catch (e) { // Conservative fallback: if AST analysis fails, keep the sub-command // in the confirmation scope instead of accidentally dropping it. diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index c7dece30138..9cea2848603 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -73,7 +73,7 @@ import { parse, type ControlOperator } from 'shell-quote'; import { createDebugLogger } from '../utils/debugLogger.js'; import { checkPriorRead, StructuredToolError } from './priorReadEnforcement.js'; import { - isShellCommandReadOnlyAST, + isShellCommandReadOnlyASTInDirectory, extractCommandRules, } from '../utils/shellAstParser.js'; import { @@ -2040,7 +2040,10 @@ export class ShellToolInvocation extends BaseToolInvocation< // AST-based read-only detection try { - const isReadOnly = await isShellCommandReadOnlyAST(command); + const isReadOnly = await isShellCommandReadOnlyASTInDirectory( + command, + this.params.directory || this.config.getTargetDir(), + ); if (isReadOnly) { return 'allow'; } @@ -2114,7 +2117,7 @@ export class ShellToolInvocation extends BaseToolInvocation< for (const sub of subCommands) { let isReadOnly = false; try { - isReadOnly = await isShellCommandReadOnlyAST(sub); + isReadOnly = await isShellCommandReadOnlyASTInDirectory(sub, cwd); } catch { // conservative: treat unknown commands as requiring confirmation } diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts new file mode 100644 index 00000000000..2c470a3fca7 --- /dev/null +++ b/packages/core/src/utils/git-config-safety.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawnSync } from 'node:child_process'; +import { statSync } from 'node:fs'; + +interface LocalGitConfigRisk { + diffExternal: boolean; + fsmonitor: boolean; +} + +const NO_RISK: LocalGitConfigRisk = { + diffExternal: false, + fsmonitor: false, +}; +const PROBE_FAILED: LocalGitConfigRisk = { + diffExternal: true, + fsmonitor: true, +}; + +export function getLocalGitConfigRisk(cwd: string): LocalGitConfigRisk { + try { + if (!statSync(cwd).isDirectory()) return NO_RISK; + } catch { + return NO_RISK; + } + + const result = spawnSync( + 'git', + [ + '-C', + cwd, + 'config', + '--includes', + '--show-scope', + '--null', + '--get-regexp', + '^diff\\.external$|^core\\.fsmonitor$', + ], + { + encoding: 'utf8', + maxBuffer: 64 * 1024, + timeout: 1000, + windowsHide: true, + }, + ); + + if (result.status === 1) return NO_RISK; + if (result.status !== 0 || typeof result.stdout !== 'string') { + return PROBE_FAILED; + } + + const effective = new Map(); + const fields = result.stdout.split('\0'); + for (let i = 0; i + 1 < fields.length; i += 2) { + const entry = fields[i + 1]!; + const newline = entry.indexOf('\n'); + if (newline < 0) return PROBE_FAILED; + effective.set(entry.slice(0, newline), { + scope: fields[i]!, + value: entry.slice(newline + 1), + }); + } + + const localValue = (key: string): string | undefined => { + const entry = effective.get(key); + return entry && (entry.scope === 'local' || entry.scope === 'worktree') + ? entry.value.trim() + : undefined; + }; + const diffExternal = localValue('diff.external'); + const fsmonitor = localValue('core.fsmonitor'); + + return { + diffExternal: diffExternal !== undefined && diffExternal !== '', + fsmonitor: + fsmonitor !== undefined && + fsmonitor !== '' && + !/^(?:true|false|yes|no|on|off|0|1)$/i.test(fsmonitor), + }; +} diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index 6b2c1278951..73f7058a2ef 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -5,10 +5,15 @@ */ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; import { classifyShellCommandSafety, initParser, isShellCommandReadOnlyAST, + isShellCommandReadOnlyASTInDirectory, extractCommandRules, _resetParser, _setParserFailedForTesting, @@ -44,6 +49,82 @@ describe('isShellCommandReadOnlyAST', () => { expect(await isShellCommandReadOnlyAST('echo $(touch file)')).toBe(false); }); + describe('repository-local Git config (#8575)', () => { + const tempDirs: string[] = []; + const createRepo = (): string => { + const dir = mkdtempSync(path.join(tmpdir(), 'qwen-git-config-')); + tempDirs.push(dir); + execFileSync('git', ['init', '-q'], { cwd: dir }); + return dir; + }; + const gitConfig = (cwd: string, ...args: string[]): void => { + execFileSync('git', ['config', ...args], { cwd }); + }; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('downgrades only the two reproduced command/config pairs', async () => { + const cwd = createRepo(); + gitConfig(cwd, 'diff.external', 'example-external-diff'); + expect(await isShellCommandReadOnlyASTInDirectory('git diff', cwd)).toBe( + false, + ); + expect( + await isShellCommandReadOnlyASTInDirectory('git status', cwd), + ).toBe(true); + + gitConfig(cwd, '--unset', 'diff.external'); + gitConfig(cwd, 'core.fsmonitor', 'example-fsmonitor'); + expect( + await isShellCommandReadOnlyASTInDirectory('git status', cwd), + ).toBe(false); + expect(await isShellCommandReadOnlyASTInDirectory('git diff', cwd)).toBe( + true, + ); + + gitConfig(cwd, 'core.fsmonitor', 'false'); + expect( + await isShellCommandReadOnlyASTInDirectory('git status', cwd), + ).toBe(true); + }); + + it('uses Git include and precedence semantics', async () => { + const cwd = createRepo(); + const included = path.join(cwd, 'included.config'); + writeFileSync(included, '[diff]\n\texternal = included-driver\n'); + gitConfig(cwd, 'include.path', included); + expect( + await isShellCommandReadOnlyASTInDirectory("git 'diff'", cwd), + ).toBe(false); + + gitConfig(cwd, 'diff.external', ''); + expect(await isShellCommandReadOnlyASTInDirectory('git diff', cwd)).toBe( + true, + ); + }); + + it('fails closed instead of simulating a changed directory', async () => { + const cwd = createRepo(); + const target = createRepo(); + expect( + await isShellCommandReadOnlyASTInDirectory( + `cd ${JSON.stringify(target)} && git status`, + cwd, + ), + ).toBe(false); + expect( + await isShellCommandReadOnlyASTInDirectory( + `git status && cd ${JSON.stringify(target)}`, + cwd, + ), + ).toBe(true); + }); + }); + // Regression coverage for PR #4386 round 4: the AST walker previously // only checked substitution inside the `command` node type, missing it // inside `variable_assignment` (e.g. `FOO=$(curl evil)`) and inside @@ -1019,6 +1100,22 @@ describe('isShellCommandReadOnlyAST fallback to regex-based checker', () => { expect(await isShellCommandReadOnlyAST('git status')).toBe(true); }); + it('keeps the Git config gate when the parser is unavailable', async () => { + const cwd = mkdtempSync(path.join(tmpdir(), 'qwen-git-fallback-')); + try { + execFileSync('git', ['init', '-q'], { cwd }); + execFileSync('git', ['config', 'core.fsmonitor', 'example-fsmonitor'], { + cwd, + }); + _setParserFailedForTesting(); + expect( + await isShellCommandReadOnlyASTInDirectory('git status', cwd), + ).toBe(false); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } + }); + it('treats syntax errors as unknown without widening the boolean API', async () => { expect(isShellCommandReadOnly('ls |')).toBe(false); expect(await classifyShellCommandSafety('ls |')).toBe('unknown'); diff --git a/packages/core/src/utils/shellAstParser.ts b/packages/core/src/utils/shellAstParser.ts index ec4e6db840e..741dd83bec1 100644 --- a/packages/core/src/utils/shellAstParser.ts +++ b/packages/core/src/utils/shellAstParser.ts @@ -19,6 +19,7 @@ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { getLocalGitConfigRisk } from './git-config-safety.js'; import { isShellCommandReadOnly } from './shellReadOnlyChecker.js'; import { classifyAwkCommandSafety, @@ -1098,12 +1099,60 @@ function evaluateStatementSafety(node: SyntaxNode): ShellCommandSafety { return childrenSafety(node, 'unknown'); } -async function classifyInternal(command: string): Promise { +function localGitConfigMakesCommandUnsafe( + root: SyntaxNode, + cwd: string, +): boolean { + let changedDirectory = false; + let usesDiff = false; + let usesStatus = false; + + for (const command of collectDescendants(root, new Set(['command']))) { + const name = getCommandName(command); + if (name === 'cd' || name === 'pushd') { + changedDirectory = true; + continue; + } + if (name !== 'git') continue; + const subcommand = stripOuterQuotes( + getArgumentNodes(command)[0]?.text ?? '', + ).toLowerCase(); + if (subcommand !== 'diff' && subcommand !== 'status') continue; + if (changedDirectory) return true; + usesDiff ||= subcommand === 'diff'; + usesStatus ||= subcommand === 'status'; + } + + if (!usesDiff && !usesStatus) return false; + const risk = getLocalGitConfigRisk(cwd); + return (usesDiff && risk.diffExternal) || (usesStatus && risk.fsmonitor); +} + +function fallbackGitConfigMakesCommandUnsafe( + command: string, + cwd: string, +): boolean { + if (/\b(?:cd|pushd)\b[\s\S]*\bgit\b/i.test(command)) return true; + if (!/\bgit\b/i.test(command)) return false; + const risk = getLocalGitConfigRisk(cwd); + return risk.diffExternal || risk.fsmonitor; +} + +async function classifyInternal( + command: string, + cwd?: string, +): Promise { const tree = await parseShellCommand(command); try { const root = tree.rootNode; if (root.namedChildCount === 0 || root.hasError) return 'unknown'; - return mergeSafety(...root.namedChildren.map(evaluateStatementSafety)); + const safety = mergeSafety( + ...root.namedChildren.map(evaluateStatementSafety), + ); + if (safety !== 'read-only' || !cwd) return safety; + return localGitConfigMakesCommandUnsafe(root, cwd) + ? 'unknown' + : 'read-only'; } finally { tree.delete(); } @@ -1115,6 +1164,14 @@ export async function classifyShellCommandSafety( return classifyInternal(command).catch(() => 'unknown'); } +export async function classifyShellCommandSafetyInDirectory( + command: string, + cwd: string, +): Promise { + if (typeof command !== 'string' || !command.trim()) return 'unknown'; + return classifyInternal(command, cwd).catch(() => 'unknown'); +} + /** * AST-based check whether a shell command is read-only. * @@ -1130,6 +1187,20 @@ export async function classifyShellCommandSafety( */ export async function isShellCommandReadOnlyAST( command: string, +): Promise { + return isShellCommandReadOnlyInternal(command); +} + +export async function isShellCommandReadOnlyASTInDirectory( + command: string, + cwd: string, +): Promise { + return isShellCommandReadOnlyInternal(command, cwd); +} + +async function isShellCommandReadOnlyInternal( + command: string, + cwd?: string, ): Promise { if (typeof command !== 'string' || !command.trim()) return false; @@ -1137,15 +1208,21 @@ export async function isShellCommandReadOnlyAST( // after a symlinked install), fall back to the regex-based checker so the // agent remains functional instead of hanging or crashing. if (parserInitFailed) { - return isShellCommandReadOnly(command); + return ( + isShellCommandReadOnly(command) && + !(cwd && fallbackGitConfigMakesCommandUnsafe(command, cwd)) + ); } try { - return (await classifyInternal(command)) === 'read-only'; + return (await classifyInternal(command, cwd)) === 'read-only'; } catch { // Unexpected runtime failure (e.g. WASM init error on first call) – // fall back to the regex-based checker rather than propagating the error. - return isShellCommandReadOnly(command); + return ( + isShellCommandReadOnly(command) && + !(cwd && fallbackGitConfigMakesCommandUnsafe(command, cwd)) + ); } }