From 17fa6b60603d377971e09ace1a8219ea466e7ecd Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 6 Aug 2026 21:57:36 +0800 Subject: [PATCH 01/15] fix(core): confirm read-only git commands when repo config executes programs (#8575) Whitelisted read-only git sub-commands (status, diff, log, show, ...) are auto-approved based purely on command text, but git can execute programs configured in the repository-local config while running them: diff.external, core.fsmonitor, core.pager / pager overrides, diff driver textconv, core.askpass, credential.helper, core.sshCommand, remote proxies, ext:: remote URLs, gpg.program. A planted .git/config could turn an auto-approved command into arbitrary code execution. Add a synchronous repo-local config probe (bounded stat walk + small file reads, fail-closed) shared by the AST and regex classifiers: when a git command would classify as read-only and the repo-local config reachable from the execution cwd contains program-executing keys, the verdict is downgraded so the command requires confirmation. Global/system config is deliberately out of scope (the user's own setup, not a cloned-repo attack surface). All permission entry points (shell tool, monitor tool, permission manager, memory-scoped agent policy) now pass the execution cwd to the classifier. Classifier APIs only gain an optional parameter; behavior without cwd is unchanged. --- .../src/memory/memory-scoped-agent-config.ts | 1 + .../src/permissions/permission-manager.ts | 24 +- packages/core/src/tools/monitor.test.ts | 13 + packages/core/src/tools/monitor.ts | 6 +- packages/core/src/tools/shell.test.ts | 30 ++ packages/core/src/tools/shell.ts | 5 +- .../core/src/utils/git-config-safety.test.ts | 193 +++++++++++++ packages/core/src/utils/git-config-safety.ts | 258 ++++++++++++++++++ .../core/src/utils/shellAstParser.test.ts | 97 ++++++- packages/core/src/utils/shellAstParser.ts | 90 ++++-- .../src/utils/shellReadOnlyChecker.test.ts | 63 ++++- .../core/src/utils/shellReadOnlyChecker.ts | 52 +++- 12 files changed, 787 insertions(+), 45 deletions(-) create mode 100644 packages/core/src/utils/git-config-safety.test.ts create mode 100644 packages/core/src/utils/git-config-safety.ts diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index ca3784c3d4c..5eb3def0ee7 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -252,6 +252,7 @@ async function evaluateScopedDecision( } const isReadOnly = await isShellCommandReadOnlyAST( stripShellWrapper(ctx.command), + ctx.cwd ? { cwd: ctx.cwd } : undefined, ); return isReadOnly ? 'allow' : 'deny'; } diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index df8851895e2..7b31816339e 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -233,7 +233,10 @@ export class PermissionManager { SHELL_TOOL_NAMES.has(toolName) && command !== undefined ) { - bashDecision = await this.resolveDefaultPermission(command); + bashDecision = await this.resolveDefaultPermission( + command, + this.probeCwd(ctx), + ); } } } else { @@ -461,7 +464,7 @@ export class PermissionManager { // (same logic as ShellToolInvocation.getDefaultPermission) const decision: ResolvedDecision = rawDecision === 'default' - ? await this.resolveDefaultPermission(subCmd) + ? await this.resolveDefaultPermission(subCmd, this.probeCwd(ctx)) : (rawDecision as ResolvedDecision); if (PRIORITY[decision] > PRIORITY[mostRestrictive]) { @@ -491,13 +494,19 @@ export class PermissionManager { * "relevant" rules for the surrounding compound command. * * @param command - The shell command to analyze. + * @param cwd - Execution directory; lets the classifier downgrade git + * commands whose repository-local config executes programs (#8575). * @returns 'allow' for read-only, 'ask' otherwise. */ private async resolveDefaultPermission( command: string, + cwd?: string, ): Promise<'allow' | 'ask'> { try { - const isReadOnly = await isShellCommandReadOnlyAST(command); + const isReadOnly = await isShellCommandReadOnlyAST( + command, + cwd ? { cwd } : undefined, + ); if (isReadOnly) { return 'allow'; } @@ -513,6 +522,15 @@ export class PermissionManager { return 'ask'; } + /** + * Best-effort execution directory for the git-config probe (#8575). + * Returns `undefined` when unknown — the probe is skipped and the + * classifier keeps its text-only verdict. + */ + private probeCwd(ctx: PermissionCheckContext): string | undefined { + return ctx.cwd ?? this.config.getCwd?.(); + } + private normalizePermissionContext( ctx: PermissionCheckContext, ): PermissionCheckContext { diff --git a/packages/core/src/tools/monitor.test.ts b/packages/core/src/tools/monitor.test.ts index e442690383c..77099587495 100644 --- a/packages/core/src/tools/monitor.test.ts +++ b/packages/core/src/tools/monitor.test.ts @@ -518,6 +518,19 @@ describe('MonitorTool', () => { await expect(invocation.getDefaultPermission()).resolves.toBe('allow'); }); + it('passes the execution cwd to the read-only classifier (#8575)', async () => { + mockIsShellCommandReadOnlyAST.mockResolvedValueOnce(true); + const invocation = createInvocation({ + command: 'git status', + }); + + await expect(invocation.getDefaultPermission()).resolves.toBe('allow'); + expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledWith( + expect.any(String), + { cwd: '/test/dir' }, + ); + }); + it('surfaces a command-substitution warning via getConfirmationDetails (issue #4093)', async () => { const invocation = createInvocation({ command: 'echo $(cat secret.txt)', diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index ab2c925c467..119cfcd6237 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -188,7 +188,8 @@ class MonitorToolInvocation extends BaseToolInvocation< // Bash(...) — see comment in getConfirmationDetails); only the // substitution-deny half is removed. try { - const isReadOnly = await isShellCommandReadOnlyAST(command); + const cwd = this.params.directory || this.config.getTargetDir(); + const isReadOnly = await isShellCommandReadOnlyAST(command, { cwd }); if (isReadOnly) { return 'allow'; } @@ -203,6 +204,7 @@ class MonitorToolInvocation extends BaseToolInvocation< _abortSignal: AbortSignal, ): Promise { const normalized = normalizeMonitorShellCommand(this.params.command); + const cwd = this.params.directory || this.config.getTargetDir(); const subCommands = splitCommands(normalized.safetyCommand); const confirmableSubCommands: string[] = []; @@ -216,7 +218,7 @@ class MonitorToolInvocation extends BaseToolInvocation< // permission boundary. let isReadOnly = false; try { - isReadOnly = await isShellCommandReadOnlyAST(sub); + isReadOnly = await isShellCommandReadOnlyAST(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.test.ts b/packages/core/src/tools/shell.test.ts index a13d7156647..cd36310d215 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -35,6 +35,10 @@ vi.mock('../utils/debugLogger.js', () => ({ vi.mock('fs'); vi.mock('os'); vi.mock('crypto'); +const mockGitConfigMayExecutePrograms = vi.hoisted(() => vi.fn(() => false)); +vi.mock('../utils/git-config-safety.js', () => ({ + gitConfigMayExecutePrograms: mockGitConfigMayExecutePrograms, +})); import { isCommandAllowed } from '../utils/shell-utils.js'; import { @@ -6868,6 +6872,32 @@ describe('ShellTool', () => { expect(permission).toBe('allow'); }); + // Regression coverage for issue #8575: whitelisted read-only git + // sub-commands execute programs configured in the repository-local + // `.git/config` (diff.external, core.fsmonitor, pagers, credential/ssh + // helpers). When such keys are present the command must be confirmed + // instead of auto-approved. + it('asks for read-only git commands when repo config executes programs (#8575)', async () => { + mockGitConfigMayExecutePrograms.mockReturnValue(true); + const invocation = shellTool.build({ + command: 'git status', + is_background: false, + }); + + expect(await invocation.getDefaultPermission()).toBe('ask'); + expect(mockGitConfigMayExecutePrograms).toHaveBeenCalledWith('/test/dir'); + }); + + it('still allows read-only git commands when repo config is clean', async () => { + mockGitConfigMayExecutePrograms.mockReturnValue(false); + const invocation = shellTool.build({ + command: 'git status', + is_background: false, + }); + + expect(await invocation.getDefaultPermission()).toBe('allow'); + }); + // Regression coverage for PR #4386 round 6 (cid 3298521039): the // env-prefix wrapper substitution bypass. `getDefaultPermission` // calls `stripShellWrapper(this.params.command)` BEFORE the AST diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 7fcc1776a4f..c22db9731b7 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -2039,7 +2039,8 @@ export class ShellToolInvocation extends BaseToolInvocation< // AST-based read-only detection try { - const isReadOnly = await isShellCommandReadOnlyAST(command); + const cwd = this.params.directory || this.config.getTargetDir(); + const isReadOnly = await isShellCommandReadOnlyAST(command, { cwd }); if (isReadOnly) { return 'allow'; } @@ -2113,7 +2114,7 @@ export class ShellToolInvocation extends BaseToolInvocation< for (const sub of subCommands) { let isReadOnly = false; try { - isReadOnly = await isShellCommandReadOnlyAST(sub); + isReadOnly = await isShellCommandReadOnlyAST(sub, { cwd }); } catch { // conservative: treat unknown commands as requiring confirmation } diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts new file mode 100644 index 00000000000..2a4976d2e61 --- /dev/null +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { gitConfigMayExecutePrograms } from './git-config-safety.js'; + +describe('gitConfigMayExecutePrograms', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-config-safety-')); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function makeRepo(name: string, config = ''): string { + const repo = path.join(root, name); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + if (config) { + fs.writeFileSync(path.join(repo, '.git', 'config'), config); + } + return repo; + } + + it('returns false outside a git repository', () => { + expect(gitConfigMayExecutePrograms(root)).toBe(false); + }); + + it('returns false for an undefined cwd', () => { + expect(gitConfigMayExecutePrograms(undefined)).toBe(false); + }); + + it('returns false for a clean repo config (including nested cwd)', () => { + const repo = makeRepo( + 'clean', + '[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote "origin"]\n\turl = https://example.com/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + + const nested = path.join(repo, 'src', 'deep'); + fs.mkdirSync(nested, { recursive: true }); + expect(gitConfigMayExecutePrograms(nested)).toBe(false); + }); + + it('returns false when .git/config does not exist', () => { + const repo = makeRepo('no-config'); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it.each([ + ['[diff]\n\texternal = /tmp/evil\n', 'diff.external'], + ['[core]\n\tpager = less -R\n', 'core.pager'], + ['[core]\n\tfsmonitor = /tmp/evil\n', 'core.fsmonitor'], + ['[core]\n\taskpass = /tmp/evil\n', 'core.askpass'], + ['[core]\n\tsshCommand = /tmp/evil\n', 'core.sshCommand'], + ['[credential]\n\thelper = !/tmp/evil\n', 'credential.helper'], + ['[gpg]\n\tprogram = /tmp/evil\n', 'gpg.program'], + ] as Array<[string, string]>)( + 'flags program-valued key %s', + (config, label) => { + const repo = makeRepo(label.replace(/\W+/g, '-'), config); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }, + ); + + it('is case-insensitive for section and key names', () => { + const repo = makeRepo('case', '[DIFF]\n\tEXTERNAL = /tmp/evil\n'); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('handles quoted values', () => { + const repo = makeRepo( + 'quoted', + '[core]\n\tpager = "delta --paging=never"\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it.each([ + ['[pager]\n\tlog = delta\n', 'pager-cmd-override'], + ['[diff "drv"]\n\ttextconv = /tmp/evil\n', 'diff-driver-textconv'], + [ + '[credential "https://example.com"]\n\thelper = store\n', + 'credential-url-helper', + ], + ['[gpg "ssh"]\n\tprogram = /tmp/evil\n', 'gpg-format-program'], + [ + '[remote "origin"]\n\tproxy = nc -X 5 -x proxy:1080 %h %p\n', + 'remote-proxy', + ], + ['[remote "origin"]\n\turl = ext::sh -c evil%% %S %u\n', 'remote-ext-url'], + ] as Array<[string, string]>)('flags subsection key %s', (config, label) => { + const repo = makeRepo(label, config); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('does not flag core.fsmonitor booleans (built-in daemon / disabled)', () => { + const enabled = makeRepo('fsm-true', '[core]\n\tfsmonitor = true\n'); + expect(gitConfigMayExecutePrograms(enabled)).toBe(false); + const disabled = makeRepo('fsm-false', '[core]\n\tfsmonitor = false\n'); + expect(gitConfigMayExecutePrograms(disabled)).toBe(false); + }); + + it('does not flag empty values or non-executing keys', () => { + const repo = makeRepo( + 'benign', + '[diff]\n\texternal =\n[pager]\n\tlog =\n[core]\n\teditor = vim\n[init]\n\tdefaultBranch = main\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it('ignores comments', () => { + const repo = makeRepo( + 'comments', + '# diff.external = /tmp/evil\n; pager.log = evil\n[core]\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + describe('linked worktrees and submodules', () => { + it('reads config.worktree and the common config via the .git file', () => { + const main = makeRepo('main-repo', '[core]\n\tbare = false\n'); + const commonGitDir = path.join(main, '.git'); + + // Linked worktree: /.git is a file pointing into + //
/.git/worktrees/. + const wtGitDir = path.join(commonGitDir, 'worktrees', 'wt'); + fs.mkdirSync(wtGitDir, { recursive: true }); + fs.writeFileSync(path.join(wtGitDir, 'commondir'), '../..\n'); + const wt = path.join(root, 'wt-clean'); + fs.mkdirSync(wt, { recursive: true }); + fs.writeFileSync(path.join(wt, '.git'), `gitdir: ${wtGitDir}\n`); + expect(gitConfigMayExecutePrograms(wt)).toBe(false); + + // Planted key in the per-worktree config. + fs.writeFileSync( + path.join(wtGitDir, 'config.worktree'), + '[core]\n\tpager = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(wt)).toBe(true); + + // Planted key in the common config instead. + fs.rmSync(path.join(wtGitDir, 'config.worktree')); + fs.writeFileSync( + path.join(commonGitDir, 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(wt)).toBe(true); + }); + + it('reads the gitdir target config for submodule-style .git files', () => { + const store = path.join(root, 'store', 'modules', 'sub'); + fs.mkdirSync(store, { recursive: true }); + fs.writeFileSync( + path.join(store, 'config'), + '[core]\n\tfsmonitor = /tmp/evil\n', + ); + const sub = path.join(root, 'sub-checkout'); + fs.mkdirSync(sub, { recursive: true }); + fs.writeFileSync(path.join(sub, '.git'), `gitdir: ${store}\n`); + expect(gitConfigMayExecutePrograms(sub)).toBe(true); + }); + }); + + it('fails closed when the config exists but cannot be read', () => { + const repo = path.join(root, 'unreadable'); + fs.mkdirSync(path.join(repo, '.git', 'config'), { recursive: true }); + // `.git/config` is a directory → readFileSync throws EISDIR. + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('fails closed when the .git pointer file cannot be read', () => { + const repo = path.join(root, 'bad-pointer'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + fs.rmdirSync(path.join(repo, '.git')); + fs.mkdirSync(path.join(repo, '.git.d'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.git'), 'gitdir: .git.d\n'); + fs.chmodSync(path.join(repo, '.git'), 0o000); + try { + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + } finally { + fs.chmodSync(path.join(repo, '.git'), 0o644); + } + }); +}); 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..4fb3eacd97f --- /dev/null +++ b/packages/core/src/utils/git-config-safety.ts @@ -0,0 +1,258 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Repository-local git config execution probe. + * + * The shell read-only classifier auto-approves whitelisted git sub-commands + * (`status`, `diff`, `log`, ...) based purely on command text. But git can + * execute programs that are *configured in the repository's local config* + * while running those otherwise read-only commands: + * + * - `diff.external`, `diff..textconv` — diff / log / show + * - `core.fsmonitor` — status + * - `core.pager`, `pager.` — log / show / diff / blame on a TTY + * - `core.askpass`, `credential.helper`, `core.sshCommand`, + * `remote..proxy`, `ext::` remote URLs — `remote show` network auth + * - `gpg.program` — signature verification helpers + * + * A `.git/config` planted by an attacker (prompt-injection chain with local + * file write, shared workspace) could therefore turn an auto-approved + * "read-only" command into arbitrary code execution. See issue #8575. + * + * Scope: repository-local config only (`.git/config`, linked-worktree + * `config.worktree`, and the common-dir config of linked worktrees). + * Global/system config is the user's own deliberate setup and is not an + * attack surface of cloned repositories — it is intentionally not probed. + * + * The probe is synchronous (bounded stat walk + small file reads) so it can + * be shared by the AST classifier and the synchronous regex fallback + * without changing either API's async shape. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** Options accepted by the read-only classifiers. */ +export interface ShellReadOnlyCheckOptions { + /** + * Directory the command will execute in. When provided, git commands that + * would otherwise classify as read-only are downgraded (require + * confirmation) if the repository-local config reachable from this + * directory contains keys that make git execute a program. + */ + cwd?: string; +} + +/** Bound on the upward search for the enclosing `.git`. */ +const MAX_REPO_SEARCH_DEPTH = 64; + +/** + * Flat `section.key` names (lowercased — git config names are + * case-insensitive) whose value names a program git may execute while + * running a whitelisted read-only sub-command. + */ +const PROGRAM_VALUED_KEYS = new Set([ + 'core.askpass', // credential prompts (e.g. `git remote show `) + 'core.fsmonitor', // fsmonitor hook command (`git status`) + 'core.pager', // pager program for log / show / diff output + 'core.sshcommand', // ssh override for authenticated remotes + 'credential.helper', // credential helpers during network auth + 'diff.external', // external diff program + 'gpg.program', // signature verification helper +]); + +interface ConfigEntry { + /** Lowercased section name. */ + section: string; + /** Case-sensitive subsection name, if any. */ + subsection: string | null; + /** Lowercased key name. */ + key: string; + /** Raw value text (quotes and continuation marker left intact). */ + value: string; +} + +const SECTION_HEADER = /^([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*$/; + +/** + * Minimal git config parser: enough to identify section/key pairs and raw + * values. Understands `[section]` and `[section "subsection"]` headers, + * `key = value` lines, and `#` / `;` comments. Does not resolve includes — + * an attacker who can write an include target can write `.git/config` + * directly, so includes add no attack surface beyond a direct write. + */ +function parseGitConfig(content: string): ConfigEntry[] { + const entries: ConfigEntry[] = []; + let section = ''; + let subsection: string | null = null; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line.startsWith(';')) continue; + + if (line.startsWith('[')) { + const close = line.indexOf(']'); + if (close < 0) continue; + const match = line.slice(1, close).trim().match(SECTION_HEADER); + if (!match) continue; + section = match[1]!.toLowerCase(); + subsection = match[2] ?? null; + continue; + } + + if (!section) continue; + const eq = line.indexOf('='); + const key = (eq < 0 ? line : line.slice(0, eq)).trim().toLowerCase(); + const value = eq < 0 ? '' : line.slice(eq + 1).trim(); + if (!key) continue; + entries.push({ section, subsection, key, value }); + } + + return entries; +} + +/** Strip a trailing line-continuation marker and surrounding quotes. */ +function normalizeValue(raw: string): string { + let value = raw.trim(); + if (value.endsWith('\\')) value = value.slice(0, -1).trim(); + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1); + } + return value; +} + +/** True when any entry names a program git would execute. */ +function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { + for (const entry of entries) { + const value = normalizeValue(entry.value); + if (value === '') continue; + + if (entry.subsection === null) { + // `[pager] = ` overrides live in the flat section. + if (entry.section === 'pager') return true; + const name = `${entry.section}.${entry.key}`; + if (!PROGRAM_VALUED_KEYS.has(name)) continue; + // core.fsmonitor true/false selects the built-in daemon or disables + // monitoring — neither executes an external program. + if (name === 'core.fsmonitor' && /^(?:true|false)$/i.test(value)) { + continue; + } + return true; + } + + switch (entry.section) { + case 'diff': + if (entry.key === 'textconv') return true; + break; + case 'credential': + if (entry.key === 'helper') return true; + break; + case 'gpg': + if (entry.key === 'program') return true; + break; + case 'remote': + if (entry.key === 'proxy') return true; + if (entry.key === 'url' && /^ext::/.test(value)) return true; + break; + default: + break; + } + } + return false; +} + +/** + * Locate the repository-local config files for the repo enclosing `cwd`: + * + * - `.git` directory → `.git/config` + * - `.git` file (`gitdir: `, linked worktree or submodule) → + * `/config`, `/config.worktree`, and the common dir's + * `config` when a `commondir` file marks a linked worktree. + */ +function findLocalGitConfigFiles(cwd: string): string[] { + let dir = path.resolve(cwd); + + for (let depth = 0; depth < MAX_REPO_SEARCH_DEPTH; depth++) { + const gitPath = path.join(dir, '.git'); + let stat: fs.Stats | undefined; + try { + stat = fs.statSync(gitPath); + } catch { + // No `.git` here; walk up. + } + + if (stat) { + if (stat.isDirectory()) { + return [path.join(gitPath, 'config')]; + } + if (stat.isFile()) { + let pointer: string; + try { + pointer = fs.readFileSync(gitPath, 'utf8'); + } catch { + // `.git` exists but cannot be read — fail closed (the outer + // catch converts this into "may execute programs"). + throw new Error(`unreadable git pointer file: ${gitPath}`); + } + const match = pointer.match(/^\s*gitdir:\s*(.+?)\s*$/m); + if (!match) return []; + const gitDir = path.resolve(dir, match[1]!); + const files = [ + path.join(gitDir, 'config'), + path.join(gitDir, 'config.worktree'), + ]; + try { + const commonDir = fs + .readFileSync(path.join(gitDir, 'commondir'), 'utf8') + .trim(); + if (commonDir) { + files.push(path.join(path.resolve(gitDir, commonDir), 'config')); + } + } catch { + // Submodule git dir (no commondir) — the two paths above suffice. + } + return files; + } + } + + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + + return []; +} + +/** + * True when the repository-local git config reachable from `cwd` contains + * keys that make git execute a program while running a whitelisted + * read-only sub-command. + * + * Fail-closed: a config file that exists but cannot be read (or any + * unexpected probe error) reports `true` so the command is confirmed + * instead of auto-approved. + */ +export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { + if (!cwd) return false; + + try { + for (const file of findLocalGitConfigFiles(cwd)) { + let content: string; + try { + content = fs.readFileSync(file, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') continue; + return true; // exists but unreadable — fail closed + } + if (entriesMayExecutePrograms(parseGitConfig(content))) return true; + } + return false; + } catch { + return true; // unexpected probe failure — fail closed + } +} diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index 6b2c1278951..056989da4d3 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -5,6 +5,9 @@ */ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { classifyShellCommandSafety, initParser, @@ -813,7 +816,7 @@ describe('classifyShellCommandSafety', () => { } const startedAt = performance.now(); await expect( - Promise.all(commands.map(classifyShellCommandSafety)), + Promise.all(commands.map((c) => classifyShellCommandSafety(c))), ).resolves.toEqual(['unknown', 'unknown']); expect(performance.now() - startedAt).toBeLessThan(1000); }); @@ -834,7 +837,7 @@ describe('classifyShellCommandSafety', () => { ]; const startedAt = performance.now(); await expect( - Promise.all(commands.map(classifyShellCommandSafety)), + Promise.all(commands.map((c) => classifyShellCommandSafety(c))), ).resolves.toEqual([ 'unknown', 'read-only', @@ -1217,3 +1220,93 @@ describe('consistency: isShellCommandReadOnly (regex) vs isShellCommandReadOnlyA }); }); }); + +// ========================================================================= +// Git config execution probe (issue #8575) — read-only git sub-commands +// must be downgraded when the repo-local config executes programs. +// ========================================================================= + +describe('git config execution probe (#8575)', () => { + let root: string; + let cleanRepo: string; + let dirtyRepo: string; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-ast-git-config-')); + + cleanRepo = path.join(root, 'clean'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(cleanRepo, '.git', 'config'), + '[core]\n\tbare = false\n[remote "origin"]\n\turl = https://example.com/repo.git\n', + ); + + dirtyRepo = path.join(root, 'dirty'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each(['git diff', 'git status', 'git log -p', 'git show HEAD'])( + 'downgrades %s when repo config executes programs', + async (command) => { + expect(await isShellCommandReadOnlyAST(command, { cwd: dirtyRepo })).toBe( + false, + ); + expect( + await classifyShellCommandSafety(command, { cwd: dirtyRepo }), + ).toBe('unknown'); + // Same command stays read-only in a clean repo. + expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( + true, + ); + }, + ); + + it('downgrades compound commands touching a dirty repo cwd', async () => { + expect( + await isShellCommandReadOnlyAST('git status && git diff', { + cwd: dirtyRepo, + }), + ).toBe(false); + }); + + it('keeps git commands that never consult repo config execution keys', async () => { + for (const command of ['git', 'git --version', 'git --help']) { + expect(await isShellCommandReadOnlyAST(command, { cwd: dirtyRepo })).toBe( + true, + ); + } + }); + + it('does not affect non-git commands', async () => { + expect(await isShellCommandReadOnlyAST('ls -la', { cwd: dirtyRepo })).toBe( + true, + ); + }); + + it('keeps text-side helper detection intact under a dirty cwd', async () => { + // --ext-diff is already non-read-only regardless of config. + expect( + await isShellCommandReadOnlyAST('git diff --ext-diff', { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('is backward compatible without cwd', async () => { + expect(await isShellCommandReadOnlyAST('git diff')).toBe(true); + }); + + it('keeps git read-only when cwd is not inside a repository', async () => { + expect(await isShellCommandReadOnlyAST('git diff', { cwd: root })).toBe( + true, + ); + }); +}); diff --git a/packages/core/src/utils/shellAstParser.ts b/packages/core/src/utils/shellAstParser.ts index ec4e6db840e..6fa18645941 100644 --- a/packages/core/src/utils/shellAstParser.ts +++ b/packages/core/src/utils/shellAstParser.ts @@ -25,6 +25,10 @@ import { classifySedCommandSafety, hasShellPatternExpansion, } from './shell-safety-rules.js'; +import { + gitConfigMayExecutePrograms, + type ShellReadOnlyCheckOptions, +} from './git-config-safety.js'; export type ShellCommandSafety = 'read-only' | 'write' | 'unknown'; type Safety = ShellCommandSafety; @@ -958,7 +962,10 @@ function processSafety(root: string, args: string[]): Safety { return 'write'; } -function evaluateSubstitutions(node: SyntaxNode): ShellCommandSafety { +function evaluateSubstitutions( + node: SyntaxNode, + checkOptions?: ShellReadOnlyCheckOptions, +): ShellCommandSafety { const substitutions = collectDescendants( node, new Set(['command_substitution', 'process_substitution']), @@ -969,11 +976,14 @@ function evaluateSubstitutions(node: SyntaxNode): ShellCommandSafety { 'unknown', ...substitutions .flatMap((substitution) => substitution.namedChildren) - .map(evaluateStatementSafety), + .map((child) => evaluateStatementSafety(child, checkOptions)), ); } -function evaluateCommandSafety(commandNode: SyntaxNode): ShellCommandSafety { +function evaluateCommandSafety( + commandNode: SyntaxNode, + checkOptions?: ShellReadOnlyCheckOptions, +): ShellCommandSafety { const rawRoot = commandNode.childForFieldName('name')?.text; const root = getCommandName(commandNode); const argNodes = getArgumentNodes(commandNode); @@ -985,8 +995,24 @@ function evaluateCommandSafety(commandNode: SyntaxNode): ShellCommandSafety { result = hasHelp(args) ? 'unknown' : 'write'; } else if (/^(kill|killall|pkill)$/.test(root)) { result = processSafety(root, args); - } else if (root === 'git') result = evaluateGitSafety(args); - else if (root === 'find') result = evaluateFindSafety(args); + } else if (root === 'git') { + result = evaluateGitSafety(args); + // Whitelisted read-only sub-commands can still execute programs + // configured in the repository-local `.git/config` (diff.external, + // core.fsmonitor, pagers, credential/ssh helpers). Require confirmation + // when such keys are present. Bare `git`, `git --version` and + // `git --help` are left as-is: they do not run repo-config programs. + // See issue #8575. + if ( + result === 'read-only' && + args.length > 0 && + !args[0]!.startsWith('-') && + checkOptions?.cwd && + gitConfigMayExecutePrograms(checkOptions.cwd) + ) { + result = 'unknown'; + } + } else if (root === 'find') result = evaluateFindSafety(args); else if (root === 'sed') result = evaluateSedSafety(args); else if (root === 'awk') result = evaluateAwkSafety(args); else if (root === 'sort' || root === 'tree') { @@ -1046,7 +1072,7 @@ function evaluateCommandSafety(commandNode: SyntaxNode): ShellCommandSafety { evaluateRedirectionSafety(commandNode), ...commandNode.namedChildren .filter((child) => !child.type.endsWith('_redirect')) - .map(evaluateSubstitutions), + .map((child) => evaluateSubstitutions(child, checkOptions)), ); } @@ -1075,44 +1101,65 @@ function evaluateRedirectionSafety(node: SyntaxNode): ShellCommandSafety { return result; } -function childrenSafety(node: SyntaxNode, floor: Safety = 'read-only'): Safety { - return mergeSafety(floor, ...node.namedChildren.map(evaluateStatementSafety)); +function childrenSafety( + node: SyntaxNode, + floor: Safety = 'read-only', + checkOptions?: ShellReadOnlyCheckOptions, +): Safety { + return mergeSafety( + floor, + ...node.namedChildren.map((child) => + evaluateStatementSafety(child, checkOptions), + ), + ); } -function evaluateStatementSafety(node: SyntaxNode): ShellCommandSafety { - if (node.type === 'command') return evaluateCommandSafety(node); - if (CHILD_STATEMENT.test(node.type)) return childrenSafety(node); +function evaluateStatementSafety( + node: SyntaxNode, + checkOptions?: ShellReadOnlyCheckOptions, +): ShellCommandSafety { + if (node.type === 'command') return evaluateCommandSafety(node, checkOptions); + if (CHILD_STATEMENT.test(node.type)) + return childrenSafety(node, 'read-only', checkOptions); if (node.type === 'redirected_statement') return mergeSafety( ...node.namedChildren .filter((child) => !child.type.endsWith('_redirect')) - .map((child) => evaluateStatementSafety(child)), + .map((child) => evaluateStatementSafety(child, checkOptions)), evaluateRedirectionSafety(node), ); if (/^variable_assignments?$/.test(node.type)) return mergeSafety( node.parent?.namedChildCount === 1 ? 'read-only' : 'unknown', - evaluateSubstitutions(node), + evaluateSubstitutions(node, checkOptions), ); if (node.type === 'function_definition') return 'unknown'; - return childrenSafety(node, 'unknown'); + return childrenSafety(node, 'unknown', checkOptions); } -async function classifyInternal(command: string): Promise { +async function classifyInternal( + command: string, + checkOptions?: ShellReadOnlyCheckOptions, +): 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)); + return mergeSafety( + ...root.namedChildren.map((child) => + evaluateStatementSafety(child, checkOptions), + ), + ); } finally { tree.delete(); } } export async function classifyShellCommandSafety( command: string, + checkOptions?: ShellReadOnlyCheckOptions, ): Promise { if (typeof command !== 'string' || !command.trim()) return 'unknown'; - return classifyInternal(command).catch(() => 'unknown'); + return classifyInternal(command, checkOptions).catch(() => 'unknown'); } /** @@ -1126,10 +1173,13 @@ export async function classifyShellCommandSafety( * - Sub-shells, heredocs, etc. * * @param command - The shell command string to evaluate. + * @param checkOptions - Optional `cwd` so git commands can be downgraded when + * the repository-local config contains program-executing keys (#8575). * @returns `true` if the command only performs read-only operations. */ export async function isShellCommandReadOnlyAST( command: string, + checkOptions?: ShellReadOnlyCheckOptions, ): Promise { if (typeof command !== 'string' || !command.trim()) return false; @@ -1137,15 +1187,15 @@ 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, checkOptions); } try { - return (await classifyInternal(command)) === 'read-only'; + return (await classifyInternal(command, checkOptions)) === '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, checkOptions); } } diff --git a/packages/core/src/utils/shellReadOnlyChecker.test.ts b/packages/core/src/utils/shellReadOnlyChecker.test.ts index 6e0530b3147..0e0b703fe27 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.test.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.test.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { isShellCommandReadOnly } from './shellReadOnlyChecker.js'; describe('evaluateShellCommandReadOnly', () => { @@ -457,7 +460,7 @@ describe('evaluateShellCommandReadOnly', () => { `git status ${'\\{'.repeat(10_000)}`, ]; const startedAt = performance.now(); - expect(commands.map(isShellCommandReadOnly)).toEqual([ + expect(commands.map((c) => isShellCommandReadOnly(c))).toEqual([ false, true, true, @@ -467,3 +470,59 @@ describe('evaluateShellCommandReadOnly', () => { }); }); }); + +// ========================================================================= +// Git config execution probe (issue #8575) — the regex fallback must apply +// the same repo-local config downgrade as the AST classifier. +// ========================================================================= + +describe('git config execution probe (#8575)', () => { + let root: string; + let cleanRepo: string; + let dirtyRepo: string; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-regex-git-config-')); + + cleanRepo = path.join(root, 'clean'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(cleanRepo, '.git', 'config'), + '[core]\n\tbare = false\n', + ); + + dirtyRepo = path.join(root, 'dirty'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[core]\n\tfsmonitor = /tmp/evil\n', + ); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each(['git diff', 'git status', 'git log'])( + 'downgrades %s when repo config executes programs', + (command) => { + expect(isShellCommandReadOnly(command, { cwd: dirtyRepo })).toBe(false); + expect(isShellCommandReadOnly(command, { cwd: cleanRepo })).toBe(true); + }, + ); + + it('keeps git --version and bare git read-only under a dirty cwd', () => { + expect(isShellCommandReadOnly('git --version', { cwd: dirtyRepo })).toBe( + true, + ); + expect(isShellCommandReadOnly('git', { cwd: dirtyRepo })).toBe(true); + }); + + it('does not affect non-git commands', () => { + expect(isShellCommandReadOnly('ls -la', { cwd: dirtyRepo })).toBe(true); + }); + + it('is backward compatible without cwd', () => { + expect(isShellCommandReadOnly('git diff')).toBe(true); + }); +}); diff --git a/packages/core/src/utils/shellReadOnlyChecker.ts b/packages/core/src/utils/shellReadOnlyChecker.ts index 206213a1f13..461045ff494 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.ts @@ -21,6 +21,10 @@ import { classifySedCommandSafety, hasShellBraceExpansion, } from './shell-safety-rules.js'; +import { + gitConfigMayExecutePrograms, + type ShellReadOnlyCheckOptions, +} from './git-config-safety.js'; const READ_ONLY_ROOT_COMMANDS = new Set([ 'awk', @@ -214,7 +218,10 @@ function evaluateGitBranchArgs(args: string[]): boolean { return args.length === 0 || (args.length === 1 && args[0] === '--list'); } -function evaluateGitCommand(tokens: string[]): boolean { +function evaluateGitCommand( + tokens: string[], + checkOptions?: ShellReadOnlyCheckOptions, +): boolean { let index = 1; while (index < tokens.length && tokens[index]!.startsWith('-')) { const flag = tokens[index++]!.toLowerCase(); @@ -242,21 +249,31 @@ function evaluateGitCommand(tokens: string[]): boolean { return false; if (options.some((arg) => /^(?:--help|--version)$/i.test(arg))) return false; + let allowed: boolean; if (subcommand === 'remote') { - return evaluateGitRemoteArgs(args); - } - - if (subcommand === 'branch') { - return evaluateGitBranchArgs(args); + allowed = evaluateGitRemoteArgs(args); + } else if (subcommand === 'branch') { + allowed = evaluateGitBranchArgs(args); + } else if (['blame', 'diff', 'log', 'show'].includes(subcommand)) { + allowed = !options.some((arg) => /^--output(?:=|$)/.test(arg)); + } else { + allowed = true; } - if (['blame', 'diff', 'log', 'show'].includes(subcommand)) { - return !options.some((arg) => /^--output(?:=|$)/.test(arg)); - } - return true; + // A whitelisted sub-command can still execute programs configured in the + // repository-local `.git/config` (diff.external, core.fsmonitor, pagers, + // credential/ssh helpers). Require confirmation when such keys are + // present. See issue #8575. + return ( + allowed && + !(checkOptions?.cwd && gitConfigMayExecutePrograms(checkOptions.cwd)) + ); } -function evaluateShellSegment(segment: string): boolean { +function evaluateShellSegment( + segment: string, + checkOptions?: ShellReadOnlyCheckOptions, +): boolean { if (!segment.trim()) { return true; } @@ -324,7 +341,7 @@ function evaluateShellSegment(segment: string): boolean { } if (normalizedRoot === 'git') { - return evaluateGitCommand([normalizedRoot, ...args]); + return evaluateGitCommand([normalizedRoot, ...args], checkOptions); } return true; @@ -334,8 +351,15 @@ function evaluateShellSegment(segment: string): boolean { * @deprecated Use `isShellCommandReadOnlyAST` from `./shellAstParser.js` instead. * This function uses regex + shell-quote for command parsing with known edge-case * limitations. The AST-based replacement provides accurate parsing via tree-sitter-bash. + * + * @param command - The shell command string to evaluate. + * @param checkOptions - Optional `cwd` so git commands can be downgraded when + * the repository-local config contains program-executing keys (#8575). */ -export function isShellCommandReadOnly(command: string): boolean { +export function isShellCommandReadOnly( + command: string, + checkOptions?: ShellReadOnlyCheckOptions, +): boolean { if (typeof command !== 'string' || !command.trim()) { return false; } @@ -349,7 +373,7 @@ export function isShellCommandReadOnly(command: string): boolean { const segments = splitCommands(command); for (const segment of segments) { - if (!evaluateShellSegment(segment)) { + if (!evaluateShellSegment(segment, checkOptions)) { return false; } } From 3c46d350bbbf847bb8a06f0e31e10653c2624c67 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 6 Aug 2026 22:37:41 +0800 Subject: [PATCH 02/15] fix(core): close two probe gaps from review of #8575 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Speculation gate now receives the execution cwd: speculated shell calls bypass the permission flow, so evaluateToolCall passes cwd (and the shell directory arg, which takes precedence) into classifyShellCommandSafety. A speculated `git diff` in a repo with diff.external planted now hits the boundary instead of executing. - Probe reads `.git/config.worktree` of the main checkout too — with extensions.worktreeConfig enabled git reads it for the main worktree, so a key planted there no longer bypasses the probe. - plan-mode shell policy passes its effective cwd to the classifier for consistent classification (no execution hole there; consistency). - Document bare repos as out of scope. - Add end-to-end integration test driving the real probe + classifier through ShellToolInvocation.getDefaultPermission (no fs mocking). --- .../core/src/core/plan-mode-shell-policy.ts | 6 +- packages/core/src/followup/speculation.ts | 1 + .../src/followup/speculationToolGate.test.ts | 67 +++++++++++++ .../core/src/followup/speculationToolGate.ts | 14 ++- .../src/tools/shell-git-config.integ.test.ts | 95 +++++++++++++++++++ .../core/src/utils/git-config-safety.test.ts | 9 ++ packages/core/src/utils/git-config-safety.ts | 15 ++- 7 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/tools/shell-git-config.integ.test.ts diff --git a/packages/core/src/core/plan-mode-shell-policy.ts b/packages/core/src/core/plan-mode-shell-policy.ts index 318b91d5cfa..60306f62981 100644 --- a/packages/core/src/core/plan-mode-shell-policy.ts +++ b/packages/core/src/core/plan-mode-shell-policy.ts @@ -164,7 +164,11 @@ export async function evaluatePlanModeShellPolicy(input: { let classification: ShellCommandSafety; try { classification = await raceWithAbort( - () => classifyShellCommandSafety(safetyCommand), + () => + classifyShellCommandSafety( + safetyCommand, + permissionContext.cwd ? { cwd: permissionContext.cwd } : undefined, + ), input.signal, ); } catch (error) { diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 323de822093..9ae43ee8e85 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.test.ts b/packages/core/src/followup/speculationToolGate.test.ts index 52e7814a76a..831d13b8168 100644 --- a/packages/core/src/followup/speculationToolGate.test.ts +++ b/packages/core/src/followup/speculationToolGate.test.ts @@ -149,6 +149,73 @@ describe('speculationToolGate', () => { ); expect(result.action).toBe('boundary'); }); + + // Issue #8575: speculation bypasses the permission flow, so the gate + // itself must downgrade read-only git commands whose repo-local config + // executes programs. + describe('git config execution probe (#8575)', () => { + let cleanRepo: string; + let dirtyRepo: string; + + beforeEach(async () => { + cleanRepo = join(testDir, 'clean-repo'); + await mkdir(join(cleanRepo, '.git'), { recursive: true }); + await writeFile( + join(cleanRepo, '.git', 'config'), + '[core]\n\tbare = false\n', + ); + + dirtyRepo = join(testDir, 'dirty-repo'); + await mkdir(join(dirtyRepo, '.git'), { recursive: true }); + await writeFile( + join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + }); + + it('hits boundary for read-only git when cwd config executes programs', async () => { + const result = await evaluateToolCall( + ToolNames.SHELL, + { command: 'git diff' }, + overlayFs, + ApprovalMode.DEFAULT, + dirtyRepo, + ); + expect(result.action).toBe('boundary'); + }); + + it('allows read-only git when cwd config is clean', async () => { + const result = await evaluateToolCall( + ToolNames.SHELL, + { command: 'git diff' }, + overlayFs, + ApprovalMode.DEFAULT, + cleanRepo, + ); + expect(result.action).toBe('allow'); + }); + + it('honors the directory arg over the ambient cwd', async () => { + const result = await evaluateToolCall( + ToolNames.SHELL, + { command: 'git status', directory: dirtyRepo }, + overlayFs, + ApprovalMode.DEFAULT, + cleanRepo, + ); + expect(result.action).toBe('boundary'); + }); + + it('keeps backward compatibility without cwd', async () => { + const result = await evaluateToolCall( + ToolNames.SHELL, + { command: 'git diff' }, + overlayFs, + ApprovalMode.DEFAULT, + ); + expect(result.action).toBe('allow'); + }); + }); }); describe('BOUNDARY_TOOLS', () => { diff --git a/packages/core/src/followup/speculationToolGate.ts b/packages/core/src/followup/speculationToolGate.ts index e06e39e984f..959108c5ff1 100644 --- a/packages/core/src/followup/speculationToolGate.ts +++ b/packages/core/src/followup/speculationToolGate.ts @@ -61,6 +61,10 @@ 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 - Execution directory for shell commands; lets the classifier + * downgrade git commands whose repo-local config executes programs + * (#8575). Speculation bypasses the permission flow, so this gate is the + * only place that check can happen for speculated shell calls. * @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,16 @@ 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 classifyShellCommandSafety( + command, + directory ? { cwd: directory } : undefined, + )) === 'read-only' ) { return { action: 'allow' }; } diff --git a/packages/core/src/tools/shell-git-config.integ.test.ts b/packages/core/src/tools/shell-git-config.integ.test.ts new file mode 100644 index 00000000000..ffb243cabcf --- /dev/null +++ b/packages/core/src/tools/shell-git-config.integ.test.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * End-to-end coverage for issue #8575 with the REAL probe and classifier + * (no fs mocking): ShellToolInvocation.getDefaultPermission must ask for + * whitelisted read-only git commands when the repo-local `.git/config` + * contains program-executing keys, and keep allowing them in clean repos. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { ShellTool } from './shell.js'; +import type { Config } from '../config/config.js'; + +describe('ShellTool git config probe end-to-end (#8575)', () => { + let root: string; + let cleanRepo: string; + let dirtyRepo: string; + + function makeShellTool(targetDir: string): ShellTool { + const config = { + getTargetDir: () => targetDir, + storage: { getUserSkillsDirs: () => [] }, + getWorkspaceContext: () => ({ isPathWithinWorkspace: () => true }), + } as unknown as Config; + return new ShellTool(config); + } + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-git-config-integ-')); + + cleanRepo = path.join(root, 'clean'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(cleanRepo, '.git', 'config'), + '[core]\n\tbare = false\n', + ); + + dirtyRepo = path.join(root, 'dirty'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n[core]\n\tfsmonitor = /tmp/evil\n', + ); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it.each(['git status', 'git diff', 'git log -p'])( + 'asks for %s when repo config executes programs', + async (command) => { + const invocation = makeShellTool(dirtyRepo).build({ + command, + is_background: false, + }); + expect(await invocation.getDefaultPermission()).toBe('ask'); + }, + ); + + it.each(['git status', 'git diff', 'git log -p'])( + 'allows %s when repo config is clean', + async (command) => { + const invocation = makeShellTool(cleanRepo).build({ + command, + is_background: false, + }); + expect(await invocation.getDefaultPermission()).toBe('allow'); + }, + ); + + it('allows non-git commands even in a dirty repo', async () => { + const invocation = makeShellTool(dirtyRepo).build({ + command: 'ls -la', + is_background: false, + }); + expect(await invocation.getDefaultPermission()).toBe('allow'); + }); + + it('honors the directory parameter over the target dir', async () => { + const invocation = makeShellTool(cleanRepo).build({ + command: 'git status', + directory: dirtyRepo, + is_background: false, + }); + expect(await invocation.getDefaultPermission()).toBe('ask'); + }); +}); diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index 2a4976d2e61..b253041ad84 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -125,6 +125,15 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(false); }); + it('reads config.worktree of the main checkout (extensions.worktreeConfig)', () => { + const repo = makeRepo('wtcfg', '[core]\n\tbare = false\n'); + fs.writeFileSync( + path.join(repo, '.git', 'config.worktree'), + '[diff]\n\texternal = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + describe('linked worktrees and submodules', () => { it('reads config.worktree and the common config via the .git file', () => { const main = makeRepo('main-repo', '[core]\n\tbare = false\n'); diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 4fb3eacd97f..dd38d0fb047 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -23,10 +23,14 @@ * file write, shared workspace) could therefore turn an auto-approved * "read-only" command into arbitrary code execution. See issue #8575. * - * Scope: repository-local config only (`.git/config`, linked-worktree - * `config.worktree`, and the common-dir config of linked worktrees). + * Scope: repository-local config only (`.git/config`, `config.worktree` + * where git reads it — the main checkout under `extensions.worktreeConfig` + * and linked worktrees — and the common-dir config of linked worktrees). * Global/system config is the user's own deliberate setup and is not an * attack surface of cloned repositories — it is intentionally not probed. + * Bare repositories (no `.git` entry in the layout) are not probed either; + * running read-only git commands inside one is exotic enough to stay out + * of scope. * * The probe is synchronous (bounded stat walk + small file reads) so it can * be shared by the AST classifier and the synchronous regex fallback @@ -187,7 +191,12 @@ function findLocalGitConfigFiles(cwd: string): string[] { if (stat) { if (stat.isDirectory()) { - return [path.join(gitPath, 'config')]; + // With extensions.worktreeConfig enabled, git also reads + // `config.worktree` for the MAIN worktree — probe both. + return [ + path.join(gitPath, 'config'), + path.join(gitPath, 'config.worktree'), + ]; } if (stat.isFile()) { let pointer: string; From 089c98781edac200eefaab26edd2030b1f9ba2b2 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 6 Aug 2026 23:00:57 +0800 Subject: [PATCH 03/15] fix(core): honor Git worktree config semantics --- .../src/followup/speculationToolGate.test.ts | 2 +- .../core/src/utils/git-config-safety.test.ts | 22 +++++- packages/core/src/utils/git-config-safety.ts | 76 ++++++++++++++++--- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/packages/core/src/followup/speculationToolGate.test.ts b/packages/core/src/followup/speculationToolGate.test.ts index 831d13b8168..d9090651cc3 100644 --- a/packages/core/src/followup/speculationToolGate.test.ts +++ b/packages/core/src/followup/speculationToolGate.test.ts @@ -176,7 +176,7 @@ describe('speculationToolGate', () => { it('hits boundary for read-only git when cwd config executes programs', async () => { const result = await evaluateToolCall( ToolNames.SHELL, - { command: 'git diff' }, + { command: 'git diff', directory: '' }, overlayFs, ApprovalMode.DEFAULT, dirtyRepo, diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index b253041ad84..36b056fde04 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -126,7 +126,10 @@ describe('gitConfigMayExecutePrograms', () => { }); it('reads config.worktree of the main checkout (extensions.worktreeConfig)', () => { - const repo = makeRepo('wtcfg', '[core]\n\tbare = false\n'); + const repo = makeRepo( + 'wtcfg', + '[extensions]\n\tworktreeConfig = false # ignored\\\n\tworktreeConfig = 0x\\\n1k # enabled\n', + ); fs.writeFileSync( path.join(repo, '.git', 'config.worktree'), '[diff]\n\texternal = /tmp/evil\n', @@ -134,9 +137,24 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); + it('ignores config.worktree unless worktreeConfig is enabled', () => { + const repo = makeRepo( + 'disabled-worktree-config', + '[extensions]\n\tworktreeConfig = 0x0k\n', + ); + fs.writeFileSync( + path.join(repo, '.git', 'config.worktree'), + '[diff]\n\texternal = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + describe('linked worktrees and submodules', () => { it('reads config.worktree and the common config via the .git file', () => { - const main = makeRepo('main-repo', '[core]\n\tbare = false\n'); + const main = makeRepo( + 'main-repo', + '[core]\n\tbare = false\n[extensions]\n\tworktreeConfig = true\n', + ); const commonGitDir = path.join(main, '.git'); // Linked worktree: /.git is a file pointing into diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index dd38d0fb047..104e389c187 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -76,7 +76,7 @@ interface ConfigEntry { subsection: string | null; /** Lowercased key name. */ key: string; - /** Raw value text (quotes and continuation marker left intact). */ + /** Raw value text (quotes left intact). */ value: string; } @@ -85,16 +85,53 @@ const SECTION_HEADER = /^([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*$/; /** * Minimal git config parser: enough to identify section/key pairs and raw * values. Understands `[section]` and `[section "subsection"]` headers, - * `key = value` lines, and `#` / `;` comments. Does not resolve includes — - * an attacker who can write an include target can write `.git/config` - * directly, so includes add no attack surface beyond a direct write. + * `key = value` lines, continuations, and `#` / `;` comments. Does not + * resolve includes — an attacker who can write an include target can write + * `.git/config` directly, so includes add no attack surface beyond a direct + * write. */ function parseGitConfig(content: string): ConfigEntry[] { const entries: ConfigEntry[] = []; let section = ''; let subsection: string | null = null; + const lines: string[] = []; + let continued = ''; for (const rawLine of content.split(/\r?\n/)) { + const line = continued + rawLine; + let quoted = false; + let escaped = false; + let comment = line.length; + for (let i = 0; i < line.length; i++) { + const char = line[i]!; + if (escaped) { + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === '"') { + quoted = !quoted; + } else if (!quoted && (char === '#' || char === ';')) { + comment = i; + break; + } + } + const logicalContent = line.slice(0, comment); + let trailingBackslashes = 0; + while ( + logicalContent[logicalContent.length - 1 - trailingBackslashes] === '\\' + ) { + trailingBackslashes++; + } + if (trailingBackslashes % 2 === 1) { + continued = logicalContent.slice(0, -1); + } else { + lines.push(logicalContent); + continued = ''; + } + } + if (continued) lines.push(continued); + + for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith('#') || line.startsWith(';')) continue; @@ -169,6 +206,23 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { return false; } +function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { + let enabled = false; + for (const entry of entries) { + if ( + entry.section === 'extensions' && + entry.subsection === null && + entry.key === 'worktreeconfig' + ) { + const value = normalizeValue(entry.value); + // Git also accepts hexadecimal integers and k/m/g suffixes. Treat + // anything except its definite false forms as enabled (fail closed). + enabled = !/^(?:false|no|off|[+-]?(?:0+|0x0+)[kmg]?)$/i.test(value); + } + } + return enabled; +} + /** * Locate the repository-local config files for the repo enclosing `cwd`: * @@ -210,20 +264,18 @@ function findLocalGitConfigFiles(cwd: string): string[] { const match = pointer.match(/^\s*gitdir:\s*(.+?)\s*$/m); if (!match) return []; const gitDir = path.resolve(dir, match[1]!); - const files = [ - path.join(gitDir, 'config'), - path.join(gitDir, 'config.worktree'), - ]; + const files = [path.join(gitDir, 'config')]; try { const commonDir = fs .readFileSync(path.join(gitDir, 'commondir'), 'utf8') .trim(); if (commonDir) { - files.push(path.join(path.resolve(gitDir, commonDir), 'config')); + files[0] = path.join(path.resolve(gitDir, commonDir), 'config'); } } catch { // Submodule git dir (no commondir) — the two paths above suffice. } + files.push(path.join(gitDir, 'config.worktree')); return files; } } @@ -249,7 +301,9 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { if (!cwd) return false; try { + let readWorktreeConfig = false; for (const file of findLocalGitConfigFiles(cwd)) { + if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; let content: string; try { content = fs.readFileSync(file, 'utf8'); @@ -258,7 +312,9 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { if (code === 'ENOENT' || code === 'ENOTDIR') continue; return true; // exists but unreadable — fail closed } - if (entriesMayExecutePrograms(parseGitConfig(content))) return true; + const entries = parseGitConfig(content); + if (entriesMayExecutePrograms(entries)) return true; + readWorktreeConfig ||= worktreeConfigEnabled(entries); } return false; } catch { From ab63f6b9a916dc90a91bd6114bce69e5642eaeff Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Thu, 6 Aug 2026 23:26:03 +0800 Subject: [PATCH 04/15] fix(core): fail closed on opaque git config constructs (#8575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-3 hardening of the config probe, closing bypasses found in local security review (all empirically reachable via attacker-written .git/config): - Section headers the minimal parser cannot interpret (e.g. `]` inside a quoted subsection) now fail closed instead of silently dropping the entries beneath them. - Inline `[section] key = value` lines are parsed instead of discarded. - Unparseable `.git` pointer files fail closed like unreadable ones. - include/includeIf entries are flagged rather than resolved: their targets can live outside `.git` (e.g. tracked working-tree files). - core.gitProxy added to the program-valued keys (git:// transport via whitelisted `git remote show`). - Document the cd-into-another-repo limitation in the module doc. - Add the missing PermissionManager cwd-threading contract test (dirty repo config → ask, clean → allow) and regression tests for each behavior above. --- .../permissions/permission-manager.test.ts | 48 ++++++++++++++- .../core/src/utils/git-config-safety.test.ts | 39 ++++++++++++ packages/core/src/utils/git-config-safety.ts | 59 ++++++++++++++----- 3 files changed, 129 insertions(+), 17 deletions(-) diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index dead3984ec8..78636bfc66f 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -3887,3 +3887,49 @@ describe('matchesRule — param matcher type guards', () => { ).toBe(true); }); }); + +describe('git config execution probe wiring (#8575)', () => { + let root: string; + let cleanRepo: string; + let dirtyRepo: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-git-config-')); + cleanRepo = path.join(root, 'clean'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync(path.join(cleanRepo, '.git', 'config'), '[core]\n'); + dirtyRepo = path.join(root, 'dirty'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('resolves the default shell permission against ctx.cwd', async () => { + const manager = new PermissionManager(makeConfig()); + manager.initialize(); + + // No rules match, so 'default' resolves through the classifier, which + // must see the execution cwd: dirty repo config → ask, clean → allow. + await expect( + manager.evaluate({ + toolName: 'run_shell_command', + command: 'git status', + cwd: dirtyRepo, + }), + ).resolves.toBe('ask'); + + await expect( + manager.evaluate({ + toolName: 'run_shell_command', + command: 'git status', + cwd: cleanRepo, + }), + ).resolves.toBe('allow'); + }); +}); diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index 36b056fde04..ecab17ecb8d 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -125,6 +125,28 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(false); }); + it('parses inline `[section] key = value` lines', () => { + const dirty = makeRepo('inline-dirty', '[diff] external = /tmp/evil\n'); + expect(gitConfigMayExecutePrograms(dirty)).toBe(true); + const clean = makeRepo('inline-clean', '[core] bare = false\n'); + expect(gitConfigMayExecutePrograms(clean)).toBe(false); + }); + + it('flags core.gitProxy (git:// transport helper)', () => { + const repo = makeRepo('gitproxy', '[core]\n\tgitProxy = /tmp/evil\n'); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('flags include/includeIf entries instead of resolving them', () => { + const inc = makeRepo('include', '[include]\n\tpath = ../other-config\n'); + expect(gitConfigMayExecutePrograms(inc)).toBe(true); + const incIf = makeRepo( + 'include-if', + '[includeIf "gitdir:~/src/"]\n\tpath = /tmp/other-config\n', + ); + expect(gitConfigMayExecutePrograms(incIf)).toBe(true); + }); + it('reads config.worktree of the main checkout (extensions.worktreeConfig)', () => { const repo = makeRepo( 'wtcfg', @@ -217,4 +239,21 @@ describe('gitConfigMayExecutePrograms', () => { fs.chmodSync(path.join(repo, '.git'), 0o644); } }); + + it('fails closed on an unparseable .git pointer file', () => { + const repo = path.join(root, 'garbage-pointer'); + fs.mkdirSync(repo, { recursive: true }); + fs.writeFileSync(path.join(repo, '.git'), 'not a gitdir pointer\n'); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('fails closed on section headers it cannot parse', () => { + // `]` inside a quoted subsection is valid to git but opaque to the + // minimal parser — must not silently drop the entries below it. + const repo = makeRepo( + 'bracket-subsection', + '[diff "a]b"]\n\ttextconv = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); }); diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 104e389c187..c65b97b142d 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -16,7 +16,8 @@ * - `core.fsmonitor` — status * - `core.pager`, `pager.` — log / show / diff / blame on a TTY * - `core.askpass`, `credential.helper`, `core.sshCommand`, - * `remote..proxy`, `ext::` remote URLs — `remote show` network auth + * `remote..proxy`, `ext::` remote URLs, `core.gitProxy` — + * `remote show` network/transport helpers * - `gpg.program` — signature verification helpers * * A `.git/config` planted by an attacker (prompt-injection chain with local @@ -35,6 +36,12 @@ * The probe is synchronous (bounded stat walk + small file reads) so it can * be shared by the AST classifier and the synchronous regex fallback * without changing either API's async shape. + * + * Known limitation: a compound command that `cd`s into a DIFFERENT + * repository before running git (e.g. `cd ../other-repo && git status`) + * is probed against the tool's own cwd, so the other repo's config is not + * checked. `cd` within the same repository resolves to the same config and + * is covered. */ import fs from 'node:fs'; @@ -62,6 +69,7 @@ const MAX_REPO_SEARCH_DEPTH = 64; const PROGRAM_VALUED_KEYS = new Set([ 'core.askpass', // credential prompts (e.g. `git remote show `) 'core.fsmonitor', // fsmonitor hook command (`git status`) + 'core.gitproxy', // git:// transport proxy (`git remote show git://…`) 'core.pager', // pager program for log / show / diff output 'core.sshcommand', // ssh override for authenticated remotes 'credential.helper', // credential helpers during network auth @@ -84,16 +92,24 @@ const SECTION_HEADER = /^([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*$/; /** * Minimal git config parser: enough to identify section/key pairs and raw - * values. Understands `[section]` and `[section "subsection"]` headers, - * `key = value` lines, continuations, and `#` / `;` comments. Does not - * resolve includes — an attacker who can write an include target can write - * `.git/config` directly, so includes add no attack surface beyond a direct - * write. + * values. Understands `[section]` and `[section "subsection"]` headers + * (including the inline `[section] key = value` form), `key = value` lines, + * continuations, and `#` / `;` comments. Includes are not resolved — + * `include` / `includeIf` entries make the probe fail closed instead, + * because their targets can live outside `.git` (e.g. tracked files). */ function parseGitConfig(content: string): ConfigEntry[] { const entries: ConfigEntry[] = []; let section = ''; let subsection: string | null = null; + + const recordEntry = (text: string): void => { + const eq = text.indexOf('='); + const key = (eq < 0 ? text : text.slice(0, eq)).trim().toLowerCase(); + const value = eq < 0 ? '' : text.slice(eq + 1).trim(); + if (!key) return; + entries.push({ section, subsection, key, value }); + }; const lines: string[] = []; let continued = ''; @@ -137,29 +153,31 @@ function parseGitConfig(content: string): ConfigEntry[] { if (line.startsWith('[')) { const close = line.indexOf(']'); - if (close < 0) continue; + if (close < 0) continue; // unclosed header — git aborts config load const match = line.slice(1, close).trim().match(SECTION_HEADER); - if (!match) continue; + if (!match) { + // Valid to git but opaque here (e.g. `]` inside a quoted + // subsection) — fail closed. + throw new Error('unrecognized git config section header'); + } section = match[1]!.toLowerCase(); subsection = match[2] ?? null; + // Inline form: `[section] key = value` on the same line. + const rest = line.slice(close + 1).trim(); + if (rest) recordEntry(rest); continue; } if (!section) continue; - const eq = line.indexOf('='); - const key = (eq < 0 ? line : line.slice(0, eq)).trim().toLowerCase(); - const value = eq < 0 ? '' : line.slice(eq + 1).trim(); - if (!key) continue; - entries.push({ section, subsection, key, value }); + recordEntry(line); } return entries; } -/** Strip a trailing line-continuation marker and surrounding quotes. */ +/** Strip surrounding quotes from a raw config value. */ function normalizeValue(raw: string): string { let value = raw.trim(); - if (value.endsWith('\\')) value = value.slice(0, -1).trim(); if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { value = value.slice(1, -1); } @@ -172,6 +190,12 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { const value = normalizeValue(entry.value); if (value === '') continue; + // Include targets can live outside `.git` (e.g. files tracked in the + // working tree), so flag them instead of resolving them. + if (entry.section === 'include' || entry.section === 'includeif') { + return true; + } + if (entry.subsection === null) { // `[pager] = ` overrides live in the flat section. if (entry.section === 'pager') return true; @@ -262,7 +286,10 @@ function findLocalGitConfigFiles(cwd: string): string[] { throw new Error(`unreadable git pointer file: ${gitPath}`); } const match = pointer.match(/^\s*gitdir:\s*(.+?)\s*$/m); - if (!match) return []; + if (!match) { + // Unparseable pointer — fail closed like the unreadable case. + throw new Error(`unparseable git pointer file: ${gitPath}`); + } const gitDir = path.resolve(dir, match[1]!); const files = [path.join(gitDir, 'config')]; try { From b1ff1e42855f5390da61036a56f3a78d3d9d4cb6 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Fri, 7 Aug 2026 00:41:40 +0800 Subject: [PATCH 05/15] fix(core): track cd in git config probe; close filter/url bypasses (#8575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 hardening from the local security/correctness review — each item was empirically demonstrated against the prior head: - Compound commands now track cd/pushd/popd: statically resolvable targets move the probe's base directory (same-repo `cd subdir` stays read-only), unresolvable targets (`cd`, `cd -`, `cd $VAR`, `popd`, quoted/expanded targets) downgrade later git segments. Closes the `cd && git status` bypass in both the AST and regex classifiers, including tree-sitter's nested-list chains. - filter..clean/smudge/process flagged: `git diff` runs worktree content through the configured clean filter with no extra flags. - url..insteadOf rewrite targets starting with ext:: flagged (combined with protocol.ext.allow in the same file this executes on whitelisted `git remote show`). - Config reads are size-capped at 1 MiB and fail closed above it (DoS guard for the synchronous permission path). - Boolean pager overrides (pager. = true/false) no longer flagged. - Added the missing wiring contract tests: PermissionManager config.getCwd() fallback, memory-scoped agent shell policy, plan-mode shell policy (including the directory-param override). --- .../src/core/plan-mode-shell-policy.test.ts | 53 +++++++++- .../memory/memory-scoped-agent-config.test.ts | 32 ++++++ .../permissions/permission-manager.test.ts | 20 ++++ .../core/src/utils/git-config-safety.test.ts | 30 ++++++ packages/core/src/utils/git-config-safety.ts | 39 +++++++- .../core/src/utils/shellAstParser.test.ts | 98 +++++++++++++++++++ packages/core/src/utils/shellAstParser.ts | 69 ++++++++++++- .../src/utils/shellReadOnlyChecker.test.ts | 68 +++++++++++++ .../core/src/utils/shellReadOnlyChecker.ts | 60 +++++++++++- 9 files changed, 462 insertions(+), 7 deletions(-) diff --git a/packages/core/src/core/plan-mode-shell-policy.test.ts b/packages/core/src/core/plan-mode-shell-policy.test.ts index 7b922423881..9fe9a8d3cd9 100644 --- a/packages/core/src/core/plan-mode-shell-policy.test.ts +++ b/packages/core/src/core/plan-mode-shell-policy.test.ts @@ -4,7 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it, vi } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import type { PermissionManager } from '../permissions/permission-manager.js'; @@ -457,3 +460,51 @@ describe('plan-mode shell policy', () => { }); }); }); + +describe('git config probe cwd threading (#8575)', () => { + let root: string; + let cleanRepo: string; + let dirtyRepo: string; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-mode-git-config-')); + + cleanRepo = path.join(root, 'clean'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync(path.join(cleanRepo, '.git', 'config'), '[core]\n'); + + dirtyRepo = path.join(root, 'dirty'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('classifies dirty-repo git commands as unknown', async () => { + await expect( + evaluate('git status', { + config: createConfig({ targetDir: () => dirtyRepo }), + }), + ).resolves.toMatchObject({ classification: 'unknown' }); + + await expect( + evaluate('git status', { + config: createConfig({ targetDir: () => cleanRepo }), + }), + ).resolves.toMatchObject({ classification: 'read-only' }); + }); + + it('honors the directory invocation param over the target dir', async () => { + await expect( + evaluate('git status', { + config: createConfig({ targetDir: () => cleanRepo }), + invocationParams: { command: 'git status', directory: dirtyRepo }, + }), + ).resolves.toMatchObject({ classification: 'unknown' }); + }); +}); diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index a1978aab330..d22abdc08ab 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -477,6 +477,38 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('deny'); }); + it('threads cwd into the shell read-only classifier (#8575)', async () => { + const cleanRepo = path.join(tempDir, 'clean-repo'); + await fs.mkdir(path.join(cleanRepo, '.git'), { recursive: true }); + await fs.writeFile(path.join(cleanRepo, '.git', 'config'), '[core]\n'); + const dirtyRepo = path.join(tempDir, 'dirty-repo'); + await fs.mkdir(path.join(dirtyRepo, '.git'), { recursive: true }); + await fs.writeFile( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + + const enabled = permissionManager( + createMemoryScopedAgentConfig({} as Config, projectRoot, { + allowShell: true, + }), + ); + await expect( + enabled.evaluate({ + toolName: ToolNames.SHELL, + command: 'git status', + cwd: dirtyRepo, + }), + ).resolves.toBe('deny'); + await expect( + enabled.evaluate({ + toolName: ToolNames.SHELL, + command: 'git status', + cwd: cleanRepo, + }), + ).resolves.toBe('allow'); + }); + it('lets base deny rules override scoped allows', async () => { const basePm: Pick< PermissionManager, diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index 78636bfc66f..1c0d1863800 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -3932,4 +3932,24 @@ describe('git config execution probe wiring (#8575)', () => { }), ).resolves.toBe('allow'); }); + + it('falls back to config.getCwd() when ctx.cwd is absent', async () => { + const dirtyManager = new PermissionManager(makeConfig({ cwd: dirtyRepo })); + dirtyManager.initialize(); + await expect( + dirtyManager.evaluate({ + toolName: 'run_shell_command', + command: 'git status', + }), + ).resolves.toBe('ask'); + + const cleanManager = new PermissionManager(makeConfig({ cwd: cleanRepo })); + cleanManager.initialize(); + await expect( + cleanManager.evaluate({ + toolName: 'run_shell_command', + command: 'git status', + }), + ).resolves.toBe('allow'); + }); }); diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index ecab17ecb8d..7146e94ee15 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -147,6 +147,36 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(incIf)).toBe(true); }); + it('flags filter clean/smudge/process programs (git diff triggers them)', () => { + const repo = makeRepo('filter', '[filter "evil"]\n\tclean = /tmp/evil\n'); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('flags ext:: url..insteadOf rewrite targets', () => { + const repo = makeRepo( + 'url-insteadof', + '[url "ext::sh -c evil"]\n\tinsteadOf = https://example.com/\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('does not flag boolean pager overrides', () => { + const repo = makeRepo( + 'pager-bool', + '[pager]\n\tlog = false\n\tdiff = true\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it('fails closed on implausibly large config files', () => { + const repo = makeRepo('huge', ''); + fs.writeFileSync( + path.join(repo, '.git', 'config'), + `[core]\n\tbare = false\n# ${'x'.repeat(1 << 20)}\n`, + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + it('reads config.worktree of the main checkout (extensions.worktreeConfig)', () => { const repo = makeRepo( 'wtcfg', diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index c65b97b142d..196ddb33cd9 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -56,11 +56,20 @@ export interface ShellReadOnlyCheckOptions { * directory contains keys that make git execute a program. */ cwd?: string; + /** + * A directory-changing segment (`cd`/`pushd`) precedes this command and + * its target could not be resolved statically. Downgrades git commands + * the same way a dirty config does — the effective repo is unknown. + */ + unknownDir?: boolean; } /** Bound on the upward search for the enclosing `.git`. */ const MAX_REPO_SEARCH_DEPTH = 64; +/** Config files larger than this fail closed instead of being read. */ +const MAX_CONFIG_FILE_BYTES = 1 << 20; // 1 MiB + /** * Flat `section.key` names (lowercased — git config names are * case-insensitive) whose value names a program git may execute while @@ -198,7 +207,11 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { if (entry.subsection === null) { // `[pager] = ` overrides live in the flat section. - if (entry.section === 'pager') return true; + if (entry.section === 'pager') { + // true/false enable or disable paging without naming a program. + if (/^(?:true|false)$/i.test(value)) continue; + return true; + } const name = `${entry.section}.${entry.key}`; if (!PROGRAM_VALUED_KEYS.has(name)) continue; // core.fsmonitor true/false selects the built-in daemon or disables @@ -223,6 +236,23 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { if (entry.key === 'proxy') return true; if (entry.key === 'url' && /^ext::/.test(value)) return true; break; + case 'filter': + // `git diff` cleans worktree content through the configured filter + // when comparing against the index — no flag needed. + if ( + entry.key === 'clean' || + entry.key === 'smudge' || + entry.key === 'process' + ) { + return true; + } + break; + case 'url': + // `url..insteadOf` rewrites remote URLs before connecting; + // an ext:: rewrite target executes a program (the transport + // block can be lifted via protocol.ext.allow in the same file). + if (entry.subsection!.startsWith('ext::')) return true; + break; default: break; } @@ -331,6 +361,13 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { let readWorktreeConfig = false; for (const file of findLocalGitConfigFiles(cwd)) { if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; + try { + if (fs.statSync(file).size > MAX_CONFIG_FILE_BYTES) { + return true; // implausibly large config — fail closed + } + } catch { + // stat can race with the read below; fall through. + } let content: string; try { content = fs.readFileSync(file, 'utf8'); diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index 056989da4d3..bc4c8bddb43 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -1310,3 +1310,101 @@ describe('git config execution probe (#8575)', () => { ); }); }); + +// ========================================================================= +// cd tracking for the git config probe (issue #8575) — compound commands +// must be probed against the repository they actually run in. +// ========================================================================= + +describe('git config probe cd tracking (#8575)', () => { + let root: string; + let cleanRepo: string; + let dirtyRepo: string; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-ast-cd-tracking-')); + + cleanRepo = path.join(root, 'clean-repo'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(cleanRepo, '.git', 'config'), + '[core]\n\tbare = false\n', + ); + fs.mkdirSync(path.join(cleanRepo, 'sub'), { recursive: true }); + + dirtyRepo = path.join(root, 'dirty-repo'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('probes the post-cd repository for absolute targets', async () => { + expect( + await isShellCommandReadOnlyAST(`cd ${dirtyRepo} && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST(`cd ${cleanRepo} && git status`, { + cwd: dirtyRepo, + }), + ).toBe(true); + }); + + it('probes the post-cd repository for relative targets', async () => { + expect( + await isShellCommandReadOnlyAST('cd ../dirty-repo && git status', { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('keeps same-repo cd read-only', async () => { + expect( + await isShellCommandReadOnlyAST('cd sub && git status', { + cwd: cleanRepo, + }), + ).toBe(true); + expect( + await classifyShellCommandSafety('cd sub && git status', { + cwd: cleanRepo, + }), + ).toBe('read-only'); + }); + + it('downgrades git after an unresolvable cd', async () => { + for (const command of [ + 'cd $TARGET && git status', + 'cd && git status', + 'cd - && git status', + 'popd && git status', + ]) { + expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( + false, + ); + } + }); + + it('tracks chained cd segments', async () => { + expect( + await isShellCommandReadOnlyAST( + 'cd sub && cd ../../dirty-repo && git status', + { cwd: cleanRepo }, + ), + ).toBe(false); + }); + + it('leaves non-git commands after cd untouched', async () => { + expect( + await isShellCommandReadOnlyAST(`cd ${dirtyRepo} && ls -la`, { + cwd: cleanRepo, + }), + ).toBe(true); + }); +}); diff --git a/packages/core/src/utils/shellAstParser.ts b/packages/core/src/utils/shellAstParser.ts index 6fa18645941..f0aad05889c 100644 --- a/packages/core/src/utils/shellAstParser.ts +++ b/packages/core/src/utils/shellAstParser.ts @@ -1000,15 +1000,16 @@ function evaluateCommandSafety( // Whitelisted read-only sub-commands can still execute programs // configured in the repository-local `.git/config` (diff.external, // core.fsmonitor, pagers, credential/ssh helpers). Require confirmation - // when such keys are present. Bare `git`, `git --version` and + // when such keys are present, or when the effective directory after an + // unresolvable `cd` is unknown. Bare `git`, `git --version` and // `git --help` are left as-is: they do not run repo-config programs. // See issue #8575. if ( result === 'read-only' && args.length > 0 && !args[0]!.startsWith('-') && - checkOptions?.cwd && - gitConfigMayExecutePrograms(checkOptions.cwd) + (checkOptions?.unknownDir || + (checkOptions?.cwd && gitConfigMayExecutePrograms(checkOptions.cwd))) ) { result = 'unknown'; } @@ -1114,11 +1115,73 @@ function childrenSafety( ); } +/** + * Statements in a `list` (`&&`, `||`, `;`) run sequentially, and `cd` / + * `pushd` change the directory later segments execute in. Track the + * directory so the git-config probe is applied to the repository each git + * segment actually reaches (#8575). Nested lists are flattened so cd state + * propagates across the whole chain (tree-sitter nests `a && b && c`). + */ +function evaluateListSafety( + node: SyntaxNode, + checkOptions?: ShellReadOnlyCheckOptions, +): ShellCommandSafety { + let context = checkOptions; + let result: ShellCommandSafety = 'read-only'; + + const visit = (child: SyntaxNode): void => { + if (child.type === 'list') { + for (const nested of child.namedChildren) visit(nested); + return; + } + if (child.type === 'command') { + const name = getCommandName(child); + if (name === 'cd' || name === 'pushd') { + context = resolveCdContext(child, context); + } else if (name === 'popd') { + context = { ...context, cwd: undefined, unknownDir: true }; + } + } + result = mergeSafety(result, evaluateStatementSafety(child, context)); + }; + + for (const child of node.namedChildren) visit(child); + return result; +} + +function resolveCdContext( + commandNode: SyntaxNode, + context?: ShellReadOnlyCheckOptions, +): ShellReadOnlyCheckOptions | undefined { + const argNodes = getArgumentNodes(commandNode); + const target = argNodes[0] ? stripOuterQuotes(argNodes[0]!.text) : undefined; + if ( + target === undefined || + target === '-' || // previous directory (OLDPWD) — unknown + target.startsWith('~') || // home-relative — unknown without $HOME + argNodes.some((arg) => hasShellExpansion(arg)) + ) { + return { ...context, cwd: undefined, unknownDir: true }; + } + if (path.isAbsolute(target)) { + return { ...context, cwd: target, unknownDir: false }; + } + if (!context?.cwd) { + return { ...context, cwd: undefined, unknownDir: true }; + } + return { + ...context, + cwd: path.resolve(context.cwd, target), + unknownDir: false, + }; +} + function evaluateStatementSafety( node: SyntaxNode, checkOptions?: ShellReadOnlyCheckOptions, ): ShellCommandSafety { if (node.type === 'command') return evaluateCommandSafety(node, checkOptions); + if (node.type === 'list') return evaluateListSafety(node, checkOptions); if (CHILD_STATEMENT.test(node.type)) return childrenSafety(node, 'read-only', checkOptions); if (node.type === 'redirected_statement') diff --git a/packages/core/src/utils/shellReadOnlyChecker.test.ts b/packages/core/src/utils/shellReadOnlyChecker.test.ts index 0e0b703fe27..f516ff90192 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.test.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.test.ts @@ -526,3 +526,71 @@ describe('git config execution probe (#8575)', () => { expect(isShellCommandReadOnly('git diff')).toBe(true); }); }); + +describe('git config probe cd tracking (#8575)', () => { + let root: string; + let cleanRepo: string; + let dirtyRepo: string; + + beforeAll(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-regex-cd-tracking-')); + + cleanRepo = path.join(root, 'clean-repo'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(cleanRepo, '.git', 'config'), + '[core]\n\tbare = false\n', + ); + fs.mkdirSync(path.join(cleanRepo, 'sub'), { recursive: true }); + + dirtyRepo = path.join(root, 'dirty-repo'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[core]\n\tfsmonitor = /tmp/evil\n', + ); + }); + + afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('probes the post-cd repository', () => { + expect( + isShellCommandReadOnly(`cd ${dirtyRepo} && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly('cd ../dirty-repo && git status', { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly(`cd ${cleanRepo} && git status`, { + cwd: dirtyRepo, + }), + ).toBe(true); + }); + + it('keeps same-repo cd read-only', () => { + expect( + isShellCommandReadOnly('cd sub && git status', { cwd: cleanRepo }), + ).toBe(true); + }); + + it('downgrades git after an unresolvable cd', () => { + expect( + isShellCommandReadOnly('cd $TARGET && git status', { cwd: cleanRepo }), + ).toBe(false); + expect(isShellCommandReadOnly('cd && git status', { cwd: cleanRepo })).toBe( + false, + ); + }); + + it('leaves non-git commands after cd untouched', () => { + expect( + isShellCommandReadOnly(`cd ${dirtyRepo} && ls -la`, { cwd: cleanRepo }), + ).toBe(true); + }); +}); diff --git a/packages/core/src/utils/shellReadOnlyChecker.ts b/packages/core/src/utils/shellReadOnlyChecker.ts index 461045ff494..6de49489253 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.ts @@ -11,6 +11,7 @@ */ import { parse } from 'shell-quote'; +import path from 'node:path'; import { detectCommandSubstitution, splitCommands, @@ -263,9 +264,11 @@ function evaluateGitCommand( // A whitelisted sub-command can still execute programs configured in the // repository-local `.git/config` (diff.external, core.fsmonitor, pagers, // credential/ssh helpers). Require confirmation when such keys are - // present. See issue #8575. + // present, or when the effective directory after an unresolvable `cd` is + // unknown. See issue #8575. return ( allowed && + !checkOptions?.unknownDir && !(checkOptions?.cwd && gitConfigMayExecutePrograms(checkOptions.cwd)) ); } @@ -347,6 +350,44 @@ function evaluateShellSegment( return true; } +/** + * Update the tracked execution directory across compound segments. + * `cd`/`pushd` with a statically resolvable target move the probe's base + * directory; anything unresolvable (`cd` alone, `cd -`, expansions, + * `popd`) marks the directory as unknown so later git segments are + * downgraded (#8575). + */ +const DIR_CHANGE_COMMAND = /^(?:cd|pushd|popd)(?:\s|$)/; + +function trackDirectoryChange( + segment: string, + currentCwd: string | undefined, +): { currentCwd?: string; unknownDir: boolean } { + const trimmed = segment.trim(); + if (!DIR_CHANGE_COMMAND.test(trimmed)) { + return { currentCwd, unknownDir: false }; + } + if (/^popd/.test(trimmed)) { + return { currentCwd: undefined, unknownDir: true }; + } + const target = trimmed.split(/\s+/)[1]; + if ( + target === undefined || + target === '-' || + target.startsWith('~') || + /[$`'"\\*?[\]{}()<>|;&]/.test(target) + ) { + return { currentCwd: undefined, unknownDir: true }; + } + if (path.isAbsolute(target)) { + return { currentCwd: target, unknownDir: false }; + } + if (!currentCwd) { + return { currentCwd: undefined, unknownDir: true }; + } + return { currentCwd: `${currentCwd}/${target}`, unknownDir: false }; +} + /** * @deprecated Use `isShellCommandReadOnlyAST` from `./shellAstParser.js` instead. * This function uses regex + shell-quote for command parsing with known edge-case @@ -372,10 +413,25 @@ export function isShellCommandReadOnly( const segments = splitCommands(command); + let currentCwd = checkOptions?.cwd; + let unknownDir = checkOptions?.unknownDir === true; + for (const segment of segments) { - if (!evaluateShellSegment(segment, checkOptions)) { + const segmentOptions: ShellReadOnlyCheckOptions | undefined = unknownDir + ? { cwd: undefined, unknownDir: true } + : currentCwd + ? { cwd: currentCwd } + : undefined; + if (!evaluateShellSegment(segment, segmentOptions)) { return false; } + const tracked = trackDirectoryChange(segment, currentCwd); + if (tracked.unknownDir) { + unknownDir = true; + currentCwd = undefined; + } else if (tracked.currentCwd !== undefined) { + currentCwd = tracked.currentCwd; + } } return segments.length > 0; From 024689fc8d6af9a1a0f6b523d2b11799998828d4 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 7 Aug 2026 03:18:28 +0000 Subject: [PATCH 06/15] fix(core): provide getTargetDir in speculation test mocks (#8575) Co-authored-by: Qwen-Coder --- packages/core/src/followup/speculation.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index 9209ed2a3a3..b332eda3954 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -60,6 +60,7 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), + getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), @@ -124,6 +125,7 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), + getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), @@ -184,6 +186,7 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), + getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -245,6 +248,7 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), + getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -308,6 +312,7 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), + getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolOutputBatchBudget: vi.fn().mockReturnValue(10_000), @@ -371,6 +376,7 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), + getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; From 4200413931bb73d8e43eab89b357c031511cb080 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 7 Aug 2026 10:17:23 +0000 Subject: [PATCH 07/15] fix(core): harden git-config exec probe against cd-tracking bypasses (#8575) Address review round 1 findings on the repository-local git config execution probe: - Track cd/pushd across every sibling-statement sequence (program, brace group, subshell body), not only `&&` lists; propagate the directory out of brace groups and redirected/negated wrappers. - Respect list-operator semantics: cd state no longer leaks across `||` or `&`, and non-`&&` sequential statements keep the prior directory in the safety equation. - Resolve cd targets strictly: skip flag arguments (`-P`/`-L`/`-e`/`--`), reject flag-only and multi-operand forms, accept only statically unquotable word/string/raw_string targets (no concatenation, ANSI-C quoting, backslash escapes, or expansions), and fail closed when the target is missing or not a directory. - Probe git discovery more faithfully: resolve symlinks (realpath), treat a directory that is itself a git directory (bare repos, submodule storage) as a repo, fail closed when the search-depth budget exhausts, decode config values and subsections the way git does (quoted-segment concatenation, escapes), and add diff..command and core.alternateRefsCommand to the program-valued keys. - Scope fixes: fall back to the scoped execution root when the memory agent shell probe has no cwd; resolve compound-command defaults against the full command so a segment rule cannot override the cd-aware verdict; keep sub-commands after a directory change in the confirmation scope for both the shell and monitor tools. - Tests: regression coverage for every fix plus mutation-checked wiring tests; skip the chmod-based EACCES simulation on Windows/root; use a relative submodule gitdir pointer; parametrize filter clean/smudge/process. --- .../core/src/followup/speculation.test.ts | 68 +++++ .../memory/memory-scoped-agent-config.test.ts | 38 +++ .../src/memory/memory-scoped-agent-config.ts | 5 +- .../permissions/permission-manager.test.ts | 40 +++ .../src/permissions/permission-manager.ts | 13 +- packages/core/src/tools/monitor.test.ts | 44 +++ packages/core/src/tools/monitor.ts | 52 ++-- packages/core/src/tools/shell.test.ts | 35 +++ packages/core/src/tools/shell.ts | 44 +-- .../core/src/utils/git-config-safety.test.ts | 118 ++++++-- packages/core/src/utils/git-config-safety.ts | 170 ++++++++++-- packages/core/src/utils/shell-utils.ts | 59 ++-- .../core/src/utils/shellAstParser.test.ts | 137 ++++++++++ packages/core/src/utils/shellAstParser.ts | 252 ++++++++++++++---- .../src/utils/shellReadOnlyChecker.test.ts | 59 ++++ .../core/src/utils/shellReadOnlyChecker.ts | 91 +++++-- 16 files changed, 1054 insertions(+), 171 deletions(-) diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index b332eda3954..527c72929c3 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -5,6 +5,9 @@ */ import { afterEach, describe, it, expect, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { abortSpeculation, ensureToolResultPairing, @@ -108,6 +111,71 @@ describe('startSpeculation', () => { await abortSpeculation(state); }); + it('stops at a boundary for shell calls when the target repo config executes programs (#8575)', async () => { + const dirtyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dirty-')); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + try { + const execute = vi.fn(); + const toolRegistry = { + ensureTool: vi.fn().mockResolvedValue({ + build: vi.fn().mockReturnValue({ + params: { command: 'git status' }, + execute, + }), + }), + }; + const config = { + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getCwd: vi.fn().mockReturnValue(process.cwd()), + getTargetDir: vi.fn().mockReturnValue(dirtyRepo), + getFastModel: vi.fn().mockReturnValue(undefined), + getToolRegistry: vi.fn().mockReturnValue(toolRegistry), + getToolInvocationGuard: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + forkedAgentMocks.runForkedAgent.mockResolvedValue({ + jsonResult: { suggestion: '' }, + }); + forkedAgentMocks.sendMessageStream.mockImplementation(async function* () { + if (forkedAgentMocks.sendMessageStream.mock.calls.length === 1) { + yield { + type: 'chunk', + value: { + candidates: [ + { + content: { + parts: [ + { + functionCall: { + id: 'call-shell-git', + name: 'run_shell_command', + args: { command: 'git status' }, + }, + }, + ], + }, + }, + ], + }, + }; + } + }); + + const state = await startSpeculation(config, 'check the repo'); + await vi.waitFor(() => expect(state.status).toBe('boundary')); + + expect(execute).not.toHaveBeenCalled(); + + await abortSpeculation(state); + } finally { + fs.rmSync(dirtyRepo, { recursive: true, force: true }); + } + }); + it('proceeds to execution when the host guard allows a speculative invocation', async () => { const execute = vi.fn().mockResolvedValue({ llmContent: 'file contents', diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index d22abdc08ab..04f7f61290b 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -509,6 +509,44 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('allow'); }); + it('probes the scoped execution root when no cwd is provided (#8575)', async () => { + // Production shape: managed memory agents' shell calls carry no + // `directory` parameter, so ctx.cwd is absent and the probe must fall + // back to the scoped execution root. + const dirtyRoot = path.join(tempDir, 'dirty-root'); + await fs.mkdir(path.join(dirtyRoot, '.git'), { recursive: true }); + await fs.writeFile( + path.join(dirtyRoot, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + const dirty = permissionManager( + createMemoryScopedAgentConfig({} as Config, dirtyRoot, { + allowShell: true, + }), + ); + await expect( + dirty.evaluate({ + toolName: ToolNames.SHELL, + command: 'git status', + }), + ).resolves.toBe('deny'); + + const cleanRoot = path.join(tempDir, 'clean-root'); + await fs.mkdir(path.join(cleanRoot, '.git'), { recursive: true }); + await fs.writeFile(path.join(cleanRoot, '.git', 'config'), '[core]\n'); + const clean = permissionManager( + createMemoryScopedAgentConfig({} as Config, cleanRoot, { + allowShell: true, + }), + ); + await expect( + clean.evaluate({ + toolName: ToolNames.SHELL, + command: 'git status', + }), + ).resolves.toBe('allow'); + }); + it('lets base deny rules override scoped allows', async () => { const basePm: Pick< PermissionManager, diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index 5eb3def0ee7..ccba2d99a78 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -250,9 +250,12 @@ async function evaluateScopedDecision( if (!opts.allowShell || !ctx.command) { return 'deny'; } + // Managed memory agents' shell calls carry no `directory` parameter, + // so ctx.cwd is absent in production — fall back to the scoped + // execution root or the git-config probe never runs (#8575). const isReadOnly = await isShellCommandReadOnlyAST( stripShellWrapper(ctx.command), - ctx.cwd ? { cwd: ctx.cwd } : undefined, + { cwd: 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 1c0d1863800..bee82373eed 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -3952,4 +3952,44 @@ describe('git config execution probe wiring (#8575)', () => { }), ).resolves.toBe('allow'); }); + + it('resolves compound defaults against the full command with cwd', async () => { + const manager = new PermissionManager(makeConfig()); + manager.initialize(); + + await expect( + manager.evaluate({ + toolName: 'run_shell_command', + command: 'git status && git diff', + cwd: dirtyRepo, + }), + ).resolves.toBe('ask'); + + await expect( + manager.evaluate({ + toolName: 'run_shell_command', + command: 'git status && git diff', + cwd: cleanRepo, + }), + ).resolves.toBe('allow'); + }); + + it('does not let a segment rule override the cd-aware compound default', async () => { + // With a rule matching one segment, the other segment's 'default' must + // resolve against the FULL command (cd-aware), not the bare segment + // probed at the original cwd — otherwise `cd && git status` + // auto-executes (#8575). + const manager = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash(cd *)'] }), + ); + manager.initialize(); + + await expect( + manager.evaluate({ + toolName: 'run_shell_command', + command: `cd ${dirtyRepo} && git status`, + cwd: cleanRepo, + }), + ).resolves.toBe('ask'); + }); }); diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 7b31816339e..454fd98e4d7 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -453,6 +453,17 @@ export class PermissionManager { let mostRestrictive: ResolvedDecision = 'allow'; + // A 'default' sub-command resolves against the FULL original command: + // per-segment classification cannot see cd context across the split and + // would probe a stale cwd, letting `cd && git status` auto-run + // whenever any rule matches a sibling segment (#8575). The + // whole-command classifier tracks directory changes. + // Called only for compound commands, so ctx.command is defined. + const defaultDecision = await this.resolveDefaultPermission( + ctx.command!, + this.probeCwd(ctx), + ); + for (const subCmd of subCommands) { const subCtx: PermissionCheckContext = { ...ctx, @@ -464,7 +475,7 @@ export class PermissionManager { // (same logic as ShellToolInvocation.getDefaultPermission) const decision: ResolvedDecision = rawDecision === 'default' - ? await this.resolveDefaultPermission(subCmd, this.probeCwd(ctx)) + ? defaultDecision : (rawDecision as ResolvedDecision); if (PRIORITY[decision] > PRIORITY[mostRestrictive]) { diff --git a/packages/core/src/tools/monitor.test.ts b/packages/core/src/tools/monitor.test.ts index 77099587495..76d1fb6226e 100644 --- a/packages/core/src/tools/monitor.test.ts +++ b/packages/core/src/tools/monitor.test.ts @@ -531,6 +531,50 @@ describe('MonitorTool', () => { ); }); + it('lets the directory parameter win over the target dir (#8575)', async () => { + mockIsShellCommandReadOnlyAST.mockResolvedValueOnce(true); + const invocation = createInvocation({ + command: 'git status', + directory: '/other/dir', + }); + + await expect(invocation.getDefaultPermission()).resolves.toBe('allow'); + expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledWith( + expect.any(String), + { cwd: '/other/dir' }, + ); + }); + + it('passes the execution cwd to the confirmation-scope classifier (#8575)', async () => { + mockIsShellCommandReadOnlyAST.mockResolvedValue(false); + const invocation = createInvocation({ + command: 'git status && rm x', + }); + + await invocation.getConfirmationDetails(new AbortController().signal); + + expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledWith( + expect.any(String), + { cwd: '/test/dir' }, + ); + }); + + it('keeps sub-commands after a cd in the monitor confirmation scope (#8575)', async () => { + mockIsShellCommandReadOnlyAST.mockResolvedValueOnce(true); // the cd + const invocation = createInvocation({ + command: 'cd /tmp/repo && git status', + }); + + const details = (await invocation.getConfirmationDetails( + new AbortController().signal, + )) as { rootCommand: string }; + + expect(details.rootCommand).toContain('git'); + // Only the cd segment is classified; the segment after the cd is + // kept in scope instead of being re-probed against the stale cwd. + expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledTimes(1); + }); + it('surfaces a command-substitution warning via getConfirmationDetails (issue #4093)', async () => { const invocation = createInvocation({ command: 'echo $(cat secret.txt)', diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index 119cfcd6237..9a5ee407734 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -39,6 +39,7 @@ import { getCommandRoot, getShellConfiguration, hasUnsafeMonitorBackgroundOperator, + isDirectoryChangeSegment, normalizeMonitorCommand as normalizeMonitorShellCommand, splitCommands, } from '../utils/shell-utils.js'; @@ -208,28 +209,39 @@ class MonitorToolInvocation extends BaseToolInvocation< const subCommands = splitCommands(normalized.safetyCommand); const confirmableSubCommands: string[] = []; + // After a directory-changing segment the per-sub-command probe would + // classify against the pre-cd cwd and silently drop the git sub-command + // that triggered this confirmation, so everything after it stays in + // scope (#8575). + let sawDirectoryChange = false; for (const sub of subCommands) { - // Only filter out read-only commands via AST analysis. - // We intentionally do NOT consult pm.isCommandAllowed() here because - // that evaluates under 'run_shell_command' context, which would let - // existing Bash(...) allow rules shrink the monitor confirmation scope. - // Monitor is a long-running background process with a different risk - // profile than one-shot shell execution and should maintain its own - // permission boundary. - let isReadOnly = false; - try { - isReadOnly = await isShellCommandReadOnlyAST(sub, { cwd }); - } catch (e) { - // Conservative fallback: if AST analysis fails, keep the sub-command - // in the confirmation scope instead of accidentally dropping it. - debugLogger.warn( - 'AST read-only check failed for monitor sub-command, falling back to ask:', - e, - ); - } + const changesDirectory = isDirectoryChangeSegment(sub); + const filterable = !sawDirectoryChange; + if (changesDirectory) sawDirectoryChange = true; + + if (filterable) { + // Only filter out read-only commands via AST analysis. + // We intentionally do NOT consult pm.isCommandAllowed() here because + // that evaluates under 'run_shell_command' context, which would let + // existing Bash(...) allow rules shrink the monitor confirmation scope. + // Monitor is a long-running background process with a different risk + // profile than one-shot shell execution and should maintain its own + // permission boundary. + let isReadOnly = false; + try { + isReadOnly = await isShellCommandReadOnlyAST(sub, { cwd }); + } catch (e) { + // Conservative fallback: if AST analysis fails, keep the sub-command + // in the confirmation scope instead of accidentally dropping it. + debugLogger.warn( + 'AST read-only check failed for monitor sub-command, falling back to ask:', + e, + ); + } - if (isReadOnly) { - continue; + if (isReadOnly) { + continue; + } } confirmableSubCommands.push(sub); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index cd36310d215..57f7340604e 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -6898,6 +6898,41 @@ describe('ShellTool', () => { expect(await invocation.getDefaultPermission()).toBe('allow'); }); + it('keeps probed git sub-commands in the confirmation scope (#8575)', async () => { + // The confirmation-scope filter must pass the cwd to the classifier: + // without it the probe never runs and the git sub-command that + // triggered the confirmation is silently filtered out of the dialog. + mockGitConfigMayExecutePrograms.mockReturnValue(true); + const invocation = shellTool.build({ + command: 'git status && rm x', + is_background: false, + }); + + const details = (await invocation.getConfirmationDetails( + new AbortController().signal, + )) as { rootCommand: string }; + + expect(details.rootCommand).toContain('git'); + mockGitConfigMayExecutePrograms.mockReturnValue(false); + }); + + it('keeps sub-commands after a cd in the confirmation scope (#8575)', async () => { + mockGitConfigMayExecutePrograms.mockReturnValue(false); + const invocation = shellTool.build({ + command: 'cd /tmp/repo && git status && rm x', + is_background: false, + }); + + const details = (await invocation.getConfirmationDetails( + new AbortController().signal, + )) as { rootCommand: string }; + + // The git segment runs after the cd, so classifying it against the + // pre-cd cwd is unsound — it must stay in the confirmation scope. + expect(details.rootCommand).toContain('git'); + expect(details.rootCommand).toContain('rm'); + }); + // Regression coverage for PR #4386 round 6 (cid 3298521039): the // env-prefix wrapper substitution bypass. `getDefaultPermission` // calls `stripShellWrapper(this.params.command)` BEFORE the AST diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index c22db9731b7..8ddb00c0bdf 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -62,6 +62,7 @@ import { getCommandRoots, getShellConfiguration, hasShellSubstitution, + isDirectoryChangeSegment, SHELL_SELF_KILL_REJECTION, type ShellConfiguration, type ShellType, @@ -2108,28 +2109,39 @@ export class ShellToolInvocation extends BaseToolInvocation< } } - // Split compound command and filter out already-allowed (read-only) sub-commands + // Split compound command and filter out already-allowed (read-only) + // sub-commands. After a directory-changing segment the per-sub-command + // probe would classify against the pre-cd cwd and silently drop the git + // sub-command that triggered this confirmation, so everything after it + // stays in scope (#8575). const subCommands = splitCommands(command); const confirmableSubCommands: string[] = []; + let sawDirectoryChange = false; for (const sub of subCommands) { - let isReadOnly = false; - try { - isReadOnly = await isShellCommandReadOnlyAST(sub, { cwd }); - } catch { - // conservative: treat unknown commands as requiring confirmation - } - - if (isReadOnly) { - continue; - } + const changesDirectory = isDirectoryChangeSegment(sub); + const filterable = !sawDirectoryChange; + if (changesDirectory) sawDirectoryChange = true; - if (pm) { + if (filterable) { + let isReadOnly = false; try { - if ((await pm.isCommandAllowed(sub, cwd)) === 'allow') { - continue; + isReadOnly = await isShellCommandReadOnlyAST(sub, { cwd }); + } catch { + // conservative: treat unknown commands as requiring confirmation + } + + if (isReadOnly) { + continue; + } + + if (pm) { + try { + if ((await pm.isCommandAllowed(sub, cwd)) === 'allow') { + continue; + } + } catch (e) { + debugLogger.warn('PermissionManager command check failed:', e); } - } catch (e) { - debugLogger.warn('PermissionManager command check failed:', e); } } diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index 7146e94ee15..d49822dc506 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -63,6 +63,10 @@ describe('gitConfigMayExecutePrograms', () => { ['[core]\n\tsshCommand = /tmp/evil\n', 'core.sshCommand'], ['[credential]\n\thelper = !/tmp/evil\n', 'credential.helper'], ['[gpg]\n\tprogram = /tmp/evil\n', 'gpg.program'], + [ + '[core]\n\talternateRefsCommand = /tmp/evil\n', + 'core.alternateRefsCommand', + ], ] as Array<[string, string]>)( 'flags program-valued key %s', (config, label) => { @@ -87,6 +91,7 @@ describe('gitConfigMayExecutePrograms', () => { it.each([ ['[pager]\n\tlog = delta\n', 'pager-cmd-override'], ['[diff "drv"]\n\ttextconv = /tmp/evil\n', 'diff-driver-textconv'], + ['[diff "drv"]\n\tcommand = /tmp/evil\n', 'diff-driver-command'], [ '[credential "https://example.com"]\n\thelper = store\n', 'credential-url-helper', @@ -147,10 +152,16 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(incIf)).toBe(true); }); - it('flags filter clean/smudge/process programs (git diff triggers them)', () => { - const repo = makeRepo('filter', '[filter "evil"]\n\tclean = /tmp/evil\n'); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); + it.each(['clean', 'smudge', 'process'])( + 'flags filter %s programs (git diff triggers them)', + (key) => { + const repo = makeRepo( + `filter-${key}`, + `[filter "evil"]\n\t${key} = /tmp/evil\n`, + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }, + ); it('flags ext:: url..insteadOf rewrite targets', () => { const repo = makeRepo( @@ -244,7 +255,12 @@ describe('gitConfigMayExecutePrograms', () => { ); const sub = path.join(root, 'sub-checkout'); fs.mkdirSync(sub, { recursive: true }); - fs.writeFileSync(path.join(sub, '.git'), `gitdir: ${store}\n`); + // Real git writes RELATIVE pointers for submodules; resolution is + // against the pointer's containing directory. + fs.writeFileSync( + path.join(sub, '.git'), + `gitdir: ${path.relative(sub, store)}\n`, + ); expect(gitConfigMayExecutePrograms(sub)).toBe(true); }); }); @@ -256,19 +272,25 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); - it('fails closed when the .git pointer file cannot be read', () => { - const repo = path.join(root, 'bad-pointer'); - fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); - fs.rmdirSync(path.join(repo, '.git')); - fs.mkdirSync(path.join(repo, '.git.d'), { recursive: true }); - fs.writeFileSync(path.join(repo, '.git'), 'gitdir: .git.d\n'); - fs.chmodSync(path.join(repo, '.git'), 0o000); - try { - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - } finally { - fs.chmodSync(path.join(repo, '.git'), 0o644); - } - }); + // chmod(0o000) does not block reads on Windows (only the owner-write + // bit is honored) or for root (DAC bypass), so the simulation only + // means EACCES elsewhere. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'fails closed when the .git pointer file cannot be read', + () => { + const repo = path.join(root, 'bad-pointer'); + fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); + fs.rmdirSync(path.join(repo, '.git')); + fs.mkdirSync(path.join(repo, '.git.d'), { recursive: true }); + fs.writeFileSync(path.join(repo, '.git'), 'gitdir: .git.d\n'); + fs.chmodSync(path.join(repo, '.git'), 0o000); + try { + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + } finally { + fs.chmodSync(path.join(repo, '.git'), 0o644); + } + }, + ); it('fails closed on an unparseable .git pointer file', () => { const repo = path.join(root, 'garbage-pointer'); @@ -277,6 +299,66 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); + it('flags partially quoted ext:: url values (git concatenates segments)', () => { + const trailing = makeRepo( + 'ext-partial-quote', + '[remote "origin"]\n\turl = "ext::/tmp/evil.sh" x\n', + ); + expect(gitConfigMayExecutePrograms(trailing)).toBe(true); + const split = makeRepo( + 'ext-split-quotes', + '[remote "origin"]\n\turl = "ext""::/tmp/evil.sh"\n', + ); + expect(gitConfigMayExecutePrograms(split)).toBe(true); + }); + + it('flags url. subsections whose ext:: prefix is escape-encoded', () => { + const repo = makeRepo( + 'url-subsection-escape', + '[url "e\\xt::/tmp/evil.sh "]\n\tinsteadOf = https://example.com/\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('probes through a symlinked workspace directory', () => { + if (process.platform === 'win32') return; // symlink perms differ + const repo = makeRepo('sym-repo', '[diff]\n\texternal = /tmp/evil\n'); + const link = path.join(root, 'ws-link'); + fs.symlinkSync(repo, link); + expect(gitConfigMayExecutePrograms(link)).toBe(true); + }); + + it('fails closed when the repo search depth is exhausted', () => { + const repo = makeRepo('deep-repo', '[diff]\n\texternal = /tmp/evil\n'); + let deep = repo; + for (let i = 0; i < 70; i++) { + deep = path.join(deep, `d${i}`); + } + fs.mkdirSync(deep, { recursive: true }); + expect(gitConfigMayExecutePrograms(deep)).toBe(true); + }); + + it('reads the config of a git directory the cwd stands in', () => { + // Submodule storage layout: `/.git/modules/` is itself a + // git directory; git reads ITS config while standing in it, not the + // superproject's. + const moduleGitDir = path.join(root, 'super', '.git', 'modules', 'sub'); + fs.mkdirSync(path.join(moduleGitDir, 'objects'), { recursive: true }); + fs.mkdirSync(path.join(moduleGitDir, 'refs'), { recursive: true }); + fs.writeFileSync(path.join(moduleGitDir, 'HEAD'), 'ref: refs/heads/main\n'); + fs.writeFileSync(path.join(moduleGitDir, 'config'), '[core]\n'); + // Clean module config, clean superproject config. + const superConfig = path.join(root, 'super', '.git', 'config'); + fs.writeFileSync(superConfig, '[core]\n'); + expect(gitConfigMayExecutePrograms(moduleGitDir)).toBe(false); + + fs.writeFileSync( + path.join(moduleGitDir, 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(moduleGitDir)).toBe(true); + }); + it('fails closed on section headers it cannot parse', () => { // `]` inside a quoted subsection is valid to git but opaque to the // minimal parser — must not silently drop the entries below it. diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 196ddb33cd9..5a80ea99e6f 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -12,8 +12,10 @@ * execute programs that are *configured in the repository's local config* * while running those otherwise read-only commands: * - * - `diff.external`, `diff..textconv` — diff / log / show + * - `diff.external`, `diff..textconv`, `diff..command` — + * diff / log / show * - `core.fsmonitor` — status + * - `core.alternateRefsCommand` — `log --alternate-refs` * - `core.pager`, `pager.` — log / show / diff / blame on a TTY * - `core.askpass`, `credential.helper`, `core.sshCommand`, * `remote..proxy`, `ext::` remote URLs, `core.gitProxy` — @@ -29,19 +31,21 @@ * and linked worktrees — and the common-dir config of linked worktrees). * Global/system config is the user's own deliberate setup and is not an * attack surface of cloned repositories — it is intentionally not probed. - * Bare repositories (no `.git` entry in the layout) are not probed either; - * running read-only git commands inside one is exotic enough to stay out - * of scope. + * + * Discovery mirrors git's: each ancestor is checked for a `.git` entry, and + * the directory itself is checked as a git directory (bare repositories and + * submodule storage dirs like `/.git/modules/`, whose own + * config git reads when it stands in one). * * The probe is synchronous (bounded stat walk + small file reads) so it can * be shared by the AST classifier and the synchronous regex fallback * without changing either API's async shape. * - * Known limitation: a compound command that `cd`s into a DIFFERENT - * repository before running git (e.g. `cd ../other-repo && git status`) - * is probed against the tool's own cwd, so the other repo's config is not - * checked. `cd` within the same repository resolves to the same config and - * is covered. + * Directory changes are tracked by both classifiers: a `cd`/`pushd` segment + * moves the probe to the repository the following git segments actually run + * in, and an unresolvable target (expansions, `~`, flag-only forms, a + * `||`-diverged chain) downgrades later git segments the same way a dirty + * config does. */ import fs from 'node:fs'; @@ -76,6 +80,7 @@ const MAX_CONFIG_FILE_BYTES = 1 << 20; // 1 MiB * running a whitelisted read-only sub-command. */ const PROGRAM_VALUED_KEYS = new Set([ + 'core.alternaterefscommand', // alternate-refs lister (`git log --alternate-refs`) 'core.askpass', // credential prompts (e.g. `git remote show `) 'core.fsmonitor', // fsmonitor hook command (`git status`) 'core.gitproxy', // git:// transport proxy (`git remote show git://…`) @@ -184,19 +189,60 @@ function parseGitConfig(content: string): ConfigEntry[] { return entries; } -/** Strip surrounding quotes from a raw config value. */ -function normalizeValue(raw: string): string { - let value = raw.trim(); - if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { - value = value.slice(1, -1); +/** + * Decode a raw config value the way git does: quoted and bare segments + * concatenate (`url = "ext::"sh` is `ext::sh` to git), and the `\b \n \t + * \" \\` escapes apply inside quoted segments. Returns `null` when the + * value cannot be decoded (unbalanced quote, other escape) — callers fail + * closed on it. + */ +function decodeGitConfigValue(raw: string): string | null { + const value = raw.trim(); + let decoded = ''; + let quoted = false; + for (let i = 0; i < value.length; i++) { + const char = value[i]!; + if (!quoted) { + if (char === '"') { + quoted = true; + } else { + decoded += char; + } + continue; + } + if (char === '"') { + quoted = false; + } else if (char === '\\') { + switch (value[++i]) { + case 'b': + decoded += '\b'; + break; + case 'n': + decoded += '\n'; + break; + case 't': + decoded += '\t'; + break; + case '"': + decoded += '"'; + break; + case '\\': + decoded += '\\'; + break; + default: + return null; + } + } else { + decoded += char; + } } - return value; + return quoted ? null : decoded; } /** True when any entry names a program git would execute. */ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { for (const entry of entries) { - const value = normalizeValue(entry.value); + const value = decodeGitConfigValue(entry.value); if (value === '') continue; // Include targets can live outside `.git` (e.g. files tracked in the @@ -209,14 +255,18 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { // `[pager] = ` overrides live in the flat section. if (entry.section === 'pager') { // true/false enable or disable paging without naming a program. - if (/^(?:true|false)$/i.test(value)) continue; + if (value !== null && /^(?:true|false)$/i.test(value)) continue; return true; } const name = `${entry.section}.${entry.key}`; if (!PROGRAM_VALUED_KEYS.has(name)) continue; // core.fsmonitor true/false selects the built-in daemon or disables // monitoring — neither executes an external program. - if (name === 'core.fsmonitor' && /^(?:true|false)$/i.test(value)) { + if ( + name === 'core.fsmonitor' && + value !== null && + /^(?:true|false)$/i.test(value) + ) { continue; } return true; @@ -224,7 +274,7 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { switch (entry.section) { case 'diff': - if (entry.key === 'textconv') return true; + if (entry.key === 'textconv' || entry.key === 'command') return true; break; case 'credential': if (entry.key === 'helper') return true; @@ -234,7 +284,9 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { break; case 'remote': if (entry.key === 'proxy') return true; - if (entry.key === 'url' && /^ext::/.test(value)) return true; + if (entry.key === 'url' && (value === null || /^ext::/.test(value))) { + return true; + } break; case 'filter': // `git diff` cleans worktree content through the configured filter @@ -251,7 +303,11 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { // `url..insteadOf` rewrites remote URLs before connecting; // an ext:: rewrite target executes a program (the transport // block can be lifted via protocol.ext.allow in the same file). - if (entry.subsection!.startsWith('ext::')) return true; + // Git decodes subsection escapes first (`\x` → `x`), so + // over-approximate by dropping backslashes before comparing. + if (entry.subsection!.replace(/\\/g, '').startsWith('ext::')) { + return true; + } break; default: break; @@ -268,15 +324,39 @@ function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { entry.subsection === null && entry.key === 'worktreeconfig' ) { - const value = normalizeValue(entry.value); + const value = decodeGitConfigValue(entry.value); // Git also accepts hexadecimal integers and k/m/g suffixes. Treat // anything except its definite false forms as enabled (fail closed). - enabled = !/^(?:false|no|off|[+-]?(?:0+|0x0+)[kmg]?)$/i.test(value); + enabled = + value === null || + !/^(?:false|no|off|[+-]?(?:0+|0x0+)[kmg]?)$/i.test(value); } } return enabled; } +/** + * git's primary discovery rule: a directory that holds a HEAD file plus + * objects and refs/packed-refs is itself a git directory (is_git_directory + * in setup.c) — a bare repository, or a submodule's storage dir like + * `/.git/modules/` whose own config git reads while standing + * in it. + */ +function isGitDirectory(dir: string): boolean { + try { + if (!fs.statSync(path.join(dir, 'HEAD')).isFile()) return false; + if (!fs.statSync(path.join(dir, 'objects')).isDirectory()) return false; + try { + if (fs.statSync(path.join(dir, 'refs')).isDirectory()) return true; + } catch { + // No refs dir — packed-refs alone also qualifies. + } + return fs.statSync(path.join(dir, 'packed-refs')).isFile(); + } catch { + return false; + } +} + /** * Locate the repository-local config files for the repo enclosing `cwd`: * @@ -284,17 +364,37 @@ function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { * - `.git` file (`gitdir: `, linked worktree or submodule) → * `/config`, `/config.worktree`, and the common dir's * `config` when a `commondir` file marks a linked worktree. + * - `cwd` itself (or an ancestor) is a git directory → its `config`, the + * commondir `config` when present, and `config.worktree`. + * + * Throws when the search cannot conclude (unreadable pointer, search depth + * exhausted) — the caller converts that into "may execute programs". */ function findLocalGitConfigFiles(cwd: string): string[] { let dir = path.resolve(cwd); + try { + // git resolves the physical cwd; a symlink between the execution + // directory and the repo root must not send the walk up the link's + // ancestors instead of the target's. + dir = fs.realpathSync(dir); + } catch { + // Absent/unresolvable path — keep the logical form. + } + + for (let depth = 0; ; depth++) { + if (depth >= MAX_REPO_SEARCH_DEPTH) { + // git's discovery has no depth cap: exhausting the budget before + // reaching the filesystem root is "repository unknown", not "no + // repository" — fail closed. + throw new Error('repository search depth exhausted'); + } - for (let depth = 0; depth < MAX_REPO_SEARCH_DEPTH; depth++) { const gitPath = path.join(dir, '.git'); let stat: fs.Stats | undefined; try { stat = fs.statSync(gitPath); } catch { - // No `.git` here; walk up. + // No `.git` here; check the directory itself, then walk up. } if (stat) { @@ -337,12 +437,26 @@ function findLocalGitConfigFiles(cwd: string): string[] { } } + if (isGitDirectory(dir)) { + const files = [path.join(dir, 'config')]; + try { + const commonDir = fs + .readFileSync(path.join(dir, 'commondir'), 'utf8') + .trim(); + if (commonDir) { + files.push(path.join(path.resolve(dir, commonDir), 'config')); + } + } catch { + // No commondir — the git dir's own config is the common config. + } + files.push(path.join(dir, 'config.worktree')); + return files; + } + const parent = path.dirname(dir); - if (parent === dir) break; + if (parent === dir) return []; // reached the filesystem root — no repo dir = parent; } - - return []; } /** diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 8487f178253..5d66424b787 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -197,14 +197,26 @@ export function escapeShellArg(arg: string, shell: ShellType): string { } } +/** A command segment plus the control operator that terminated it. */ +export interface CommandSegment { + command: string; + /** Terminating operator (`&&`, `||`, `;`, `|`, `&`, newline) — `null` for the last segment. */ + separator: string | null; +} + /** - * Splits a shell command into a list of individual commands, respecting quotes. - * This is used to separate chained commands (e.g., using &&, ||, ;). - * @param command The shell command string to parse - * @returns An array of individual command strings + * Splits a shell command into segments, respecting quotes, and records the + * control operator terminating each segment. @see splitCommands for the + * separator-free variant. */ -export function splitCommands(command: string): string[] { - const commands: string[] = []; +export function splitCommandsWithSeparators(command: string): CommandSegment[] { + const commands: CommandSegment[] = []; + const push = (segment: string, separator: string | null): void => { + const trimmed = segment.trim(); + if (trimmed) { + commands.push({ command: trimmed, separator }); + } + }; let currentCommand = ''; let inSingleQuotes = false; let inDoubleQuotes = false; @@ -300,18 +312,18 @@ export function splitCommands(command: string): string[] { (char === '&' && nextChar === '&') || (char === '|' && (nextChar === '|' || nextChar === '&')) ) { - commands.push(currentCommand.trim()); + push(currentCommand, char + nextChar); currentCommand = ''; i++; // Skip the next character } else if (char === ';') { - commands.push(currentCommand.trim()); + push(currentCommand, ';'); currentCommand = ''; } else if (char === '&') { const prevChar = previousNonWhitespaceChar(i); if (prevChar === '>' || prevChar === '<') { currentCommand += char; } else { - commands.push(currentCommand.trim()); + push(currentCommand, '&'); currentCommand = ''; } } else if (char === '|') { @@ -319,17 +331,17 @@ export function splitCommands(command: string): string[] { if (prevChar === '>') { currentCommand += char; } else { - commands.push(currentCommand.trim()); + push(currentCommand, '|'); currentCommand = ''; } } else if (char === '\r' && nextChar === '\n') { // Windows-style \r\n newline - treat as command separator - commands.push(currentCommand.trim()); + push(currentCommand, '\n'); currentCommand = ''; i++; // Skip the \n } else if (char === '\n') { // Unix-style \n newline - treat as command separator - commands.push(currentCommand.trim()); + push(currentCommand, '\n'); currentCommand = ''; } else { currentCommand += char; @@ -340,11 +352,26 @@ export function splitCommands(command: string): string[] { i++; } - if (currentCommand.trim()) { - commands.push(currentCommand.trim()); - } + push(currentCommand, null); + + return commands; +} + +/** + * Splits a shell command into a list of individual commands, respecting quotes. + * This is used to separate chained commands (e.g., using &&, ||, ;). + * @param command The shell command string to parse + * @returns An array of individual command strings + */ +export function splitCommands(command: string): string[] { + return splitCommandsWithSeparators(command).map((entry) => entry.command); +} + +/** True when a split segment changes the working directory. */ +const DIRECTORY_CHANGE_SEGMENT = /^\(*\s*(?:cd|pushd|popd)(?:[\s);]|$)/; - return commands.filter(Boolean); // Filter out any empty strings +export function isDirectoryChangeSegment(segment: string): boolean { + return DIRECTORY_CHANGE_SEGMENT.test(segment.trim()); } /** diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index bc4c8bddb43..d01cb13f1c4 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -1407,4 +1407,141 @@ describe('git config probe cd tracking (#8575)', () => { }), ).toBe(true); }); + + it('does not take cd flags as the destination directory', async () => { + for (const flag of ['-P', '-L', '-e', '--']) { + expect( + await isShellCommandReadOnlyAST( + `cd ${flag} ${dirtyRepo} && git status`, + { + cwd: cleanRepo, + }, + ), + ).toBe(false); + } + expect( + await isShellCommandReadOnlyAST(`cd -- ${cleanRepo} && git status`, { + cwd: dirtyRepo, + }), + ).toBe(true); + }); + + it('downgrades git after operand-less cd flag forms (cd goes to $HOME)', async () => { + for (const command of [ + 'cd -- && git status', + 'cd -P && git status', + 'cd -e && git status', + ]) { + expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( + false, + ); + } + }); + + it('tracks cd across ; and newline separators', async () => { + expect( + await isShellCommandReadOnlyAST(`cd ${dirtyRepo}; git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST(`cd ${dirtyRepo}\ngit status`, { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('propagates cd out of brace groups (they run in the current shell)', async () => { + expect( + await isShellCommandReadOnlyAST(`{ cd ${dirtyRepo}; }; git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST(`{ cd ${dirtyRepo}; } && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('tracks cd wrapped in redirections', async () => { + expect( + await isShellCommandReadOnlyAST( + `cd ${dirtyRepo} { + expect( + await isShellCommandReadOnlyAST(`(cd ${dirtyRepo}; git status)`, { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('does not propagate cd state across || (RHS runs when cd failed)', async () => { + expect( + await isShellCommandReadOnlyAST(`cd ${cleanRepo} || git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST( + `cd ${dirtyRepo} || cd ${cleanRepo} && git status`, + { cwd: cleanRepo }, + ), + ).toBe(false); + }); + + it('downgrades git after a multi-argument cd (bash stays put)', async () => { + expect( + await isShellCommandReadOnlyAST(`cd ${cleanRepo} extra; git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + }); + + it('downgrades git when the cd target does not exist (bash stays put)', async () => { + expect( + await isShellCommandReadOnlyAST(`cd ${root}/no-such-dir; git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + }); + + it('treats ANSI-C-quoted and backslash-escaped cd targets as unknown', async () => { + expect( + await isShellCommandReadOnlyAST(`cd $'${dirtyRepo}' && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST('cd dirty\\ repo && git status', { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('treats concatenated quoted/unquoted cd targets as unknown', async () => { + expect( + await isShellCommandReadOnlyAST(`cd "${root}/"dirty-repo && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('never auto-approves commands containing pushd', async () => { + expect( + await isShellCommandReadOnlyAST(`pushd ${dirtyRepo} && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST(`pushd ${cleanRepo} && git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + }); }); diff --git a/packages/core/src/utils/shellAstParser.ts b/packages/core/src/utils/shellAstParser.ts index f0aad05889c..6575934b53b 100644 --- a/packages/core/src/utils/shellAstParser.ts +++ b/packages/core/src/utils/shellAstParser.ts @@ -676,8 +676,7 @@ type SyntaxNode = Parser.SyntaxNode; const SHELL_EXPANSION_TYPES = new Set( 'simple_expansion expansion arithmetic_expansion'.split(' '), ); -const CHILD_STATEMENT = - /^(?:pipeline|list|subshell|compound_statement|negated_command)$/; +const CHILD_STATEMENT = /^(?:pipeline|negated_command)$/; /** Collect all descendant nodes of given types. */ function collectDescendants( node: SyntaxNode, @@ -1116,64 +1115,229 @@ function childrenSafety( } /** - * Statements in a `list` (`&&`, `||`, `;`) run sequentially, and `cd` / - * `pushd` change the directory later segments execute in. Track the - * directory so the git-config probe is applied to the repository each git - * segment actually reaches (#8575). Nested lists are flattened so cd state - * propagates across the whole chain (tree-sitter nests `a && b && c`). + * Statements in a sequence run one after another (`;` or newline + * separators at program/brace-group level), and `cd`/`pushd` change the + * directory later statements execute in. Track the directory so the + * git-config probe is applied to the repository each git statement + * actually reaches (#8575). + */ +function evaluateSequenceSafety( + statements: SyntaxNode[], + checkOptions?: ShellReadOnlyCheckOptions, +): ShellCommandSafety { + let context = checkOptions; + let result: ShellCommandSafety = 'read-only'; + for (const node of statements) { + result = mergeSafety(result, evaluateStatementSafety(node, context)); + context = contextAfterStatement(node, context); + } + return result; +} + +/** + * Flatten a `list` node into (statement, joining-operator) pairs. Nested + * lists are inlined so `a && b && c` — which tree-sitter may nest — is + * traversed as one chain with its operators intact. + */ +function* iterateListStatements( + node: SyntaxNode, + leadingOperator?: string, +): Generator<{ statement: SyntaxNode; operator?: string }> { + let operator = leadingOperator; + for (const child of node.children) { + if (!child.isNamed) { + if (child.type === '&&' || child.type === '||' || child.type === '&') { + operator = child.type; + } + continue; + } + if (child.type === 'list') { + yield* iterateListStatements(child, operator); + } else { + yield { statement: child, operator }; + } + operator = undefined; + } +} + +/** + * Evaluate a `list` (`&&`/`||` chain), tracking directory changes across + * the segments (#8575). cd state only propagates across `&&`: the segment + * after `||` (or `&`) runs precisely when the preceding chain did not + * complete (or runs in a background subshell), so once a cd was tracked + * the effective directory for everything after a non-`&&` operator is + * unknown. */ function evaluateListSafety( node: SyntaxNode, checkOptions?: ShellReadOnlyCheckOptions, ): ShellCommandSafety { let context = checkOptions; + let diverged = false; + let directoryTracked = false; let result: ShellCommandSafety = 'read-only'; - const visit = (child: SyntaxNode): void => { - if (child.type === 'list') { - for (const nested of child.namedChildren) visit(nested); - return; + for (const { statement, operator } of iterateListStatements(node)) { + if (operator && operator !== '&&' && directoryTracked) { + diverged = true; } - if (child.type === 'command') { - const name = getCommandName(child); - if (name === 'cd' || name === 'pushd') { - context = resolveCdContext(child, context); - } else if (name === 'popd') { - context = { ...context, cwd: undefined, unknownDir: true }; - } + if (diverged) { + result = mergeSafety( + result, + evaluateStatementSafety(statement, { + cwd: undefined, + unknownDir: true, + }), + ); + continue; } - result = mergeSafety(result, evaluateStatementSafety(child, context)); - }; - - for (const child of node.namedChildren) visit(child); + result = mergeSafety(result, evaluateStatementSafety(statement, context)); + const next = contextAfterStatement(statement, context, true); + if (next !== context) { + context = next; + directoryTracked = true; + } + } return result; } -function resolveCdContext( - commandNode: SyntaxNode, +/** + * The execution-directory context after `node` finishes, for the benefit + * of the statements that follow it. Returns the SAME object when the node + * cannot change the directory. `certain` means the next statement only + * runs when this one succeeded (`&&` chaining); otherwise the next + * statement also runs when a cd fails and bash stays put. + */ +function contextAfterStatement( + node: SyntaxNode, context?: ShellReadOnlyCheckOptions, + certain = false, ): ShellReadOnlyCheckOptions | undefined { - const argNodes = getArgumentNodes(commandNode); - const target = argNodes[0] ? stripOuterQuotes(argNodes[0]!.text) : undefined; + if (node.type === 'redirected_statement' || node.type === 'negated_command') { + // Redirection/negation still runs the body in the current shell. + const body = node.namedChildren[0]; + return body + ? contextAfterStatement(body, context, certain) + : { ...context, cwd: undefined, unknownDir: true }; + } + if (node.type === 'command') { + const name = getCommandName(node); + if (name === 'cd' || name === 'pushd') { + return contextAfterCd(node, context, certain); + } + if (name === 'popd') { + return { ...context, cwd: undefined, unknownDir: true }; + } + return context; + } + if (node.type === 'compound_statement') { + // Brace groups run in the current shell — fold the net effect of + // the group's `;`/newline-separated body. + let ctx = context; + for (const child of node.namedChildren) { + ctx = contextAfterStatement(child, ctx); + } + return ctx; + } + if (node.type === 'subshell') { + // Child process — directory changes stay inside. + return context; + } + // if/for/while/case/function bodies run in the current shell but only + // conditionally — a cd inside leaves the following directory unknown. + return containsCurrentShellCd(node) + ? { ...context, cwd: undefined, unknownDir: true } + : context; +} + +function contextAfterCd( + commandNode: SyntaxNode, + context?: ShellReadOnlyCheckOptions, + certain = false, +): ShellReadOnlyCheckOptions { + const resolved = resolveCdContext(commandNode, context); + if (!resolved.cwd || resolved.unknownDir) { + return { ...context, cwd: undefined, unknownDir: true }; + } + if (certain) return resolved; + // The following statement also runs when the cd fails (bash stays in + // the prior directory), so the prior directory must be clean too before + // the resolved target can be trusted. + const priorMayExecute = + context?.unknownDir === true || + (!!context?.cwd && gitConfigMayExecutePrograms(context.cwd)); + return priorMayExecute + ? { ...context, cwd: undefined, unknownDir: true } + : resolved; +} + +/** True when a cd/pushd/popd runs in the current shell below `node`. */ +function containsCurrentShellCd(node: SyntaxNode): boolean { if ( - target === undefined || - target === '-' || // previous directory (OLDPWD) — unknown - target.startsWith('~') || // home-relative — unknown without $HOME - argNodes.some((arg) => hasShellExpansion(arg)) + node.type === 'subshell' || + node.type === 'command_substitution' || + node.type === 'process_substitution' ) { - return { ...context, cwd: undefined, unknownDir: true }; + return false; // child process } - if (path.isAbsolute(target)) { - return { ...context, cwd: target, unknownDir: false }; + if (node.type === 'command') { + const name = getCommandName(node); + if (name === 'cd' || name === 'pushd' || name === 'popd') return true; } - if (!context?.cwd) { - return { ...context, cwd: undefined, unknownDir: true }; + return node.namedChildren.some((child) => containsCurrentShellCd(child)); +} + +/** + * The directory a cd/pushd argument points to, when it can be resolved + * statically. Anything else (concatenated quote segments, ANSI-C quoting, + * backslash escapes, expansions) is unresolvable — the probe would inspect + * a fabricated path while bash cds to the real one. + */ +function staticallyResolvableCdTarget(node: SyntaxNode): string | undefined { + const { text } = node; + if (node.type === 'raw_string') return text.slice(1, -1); + if (node.type === 'string') { + const inner = text.slice(1, -1); + return /[\\"$`]/.test(inner) ? undefined : inner; + } + if (node.type === 'word') { + return /[\\"'$`]/.test(text) ? undefined : text; } - return { - ...context, - cwd: path.resolve(context.cwd, target), - unknownDir: false, - }; + return undefined; +} + +function resolveCdContext( + commandNode: SyntaxNode, + context?: ShellReadOnlyCheckOptions, +): ShellReadOnlyCheckOptions { + const unknown = { ...context, cwd: undefined, unknownDir: true }; + const argNodes = getArgumentNodes(commandNode); + if (argNodes.some((arg) => hasShellExpansion(arg))) return unknown; + const operands: SyntaxNode[] = []; + for (const arg of argNodes) { + if (arg.text === '-') return unknown; // `cd -` goes to OLDPWD + if (arg.text.startsWith('-')) continue; // -P/-L/-e/-- are flags, not targets + operands.push(arg); + } + // No operand cds to $HOME; more than one is rejected by bash (`cd: too + // many arguments`) or rewrites $PWD (`cd old new`) — neither resolvable. + if (operands.length !== 1) return unknown; + const target = staticallyResolvableCdTarget(operands[0]!); + if (target === undefined || target.startsWith('~')) return unknown; + const resolved = path.isAbsolute(target) + ? target + : context?.cwd + ? path.resolve(context.cwd, target) + : undefined; + if (!resolved) return unknown; + // bash refuses to enter a missing target or a non-directory and stays + // put; fail closed regardless — the target can appear before execution. + try { + if (!fs.statSync(resolved).isDirectory()) return unknown; + } catch { + return unknown; + } + return { ...context, cwd: resolved, unknownDir: false }; } function evaluateStatementSafety( @@ -1182,6 +1346,8 @@ function evaluateStatementSafety( ): ShellCommandSafety { if (node.type === 'command') return evaluateCommandSafety(node, checkOptions); if (node.type === 'list') return evaluateListSafety(node, checkOptions); + if (node.type === 'compound_statement' || node.type === 'subshell') + return evaluateSequenceSafety(node.namedChildren, checkOptions); if (CHILD_STATEMENT.test(node.type)) return childrenSafety(node, 'read-only', checkOptions); if (node.type === 'redirected_statement') @@ -1208,11 +1374,7 @@ async function classifyInternal( try { const root = tree.rootNode; if (root.namedChildCount === 0 || root.hasError) return 'unknown'; - return mergeSafety( - ...root.namedChildren.map((child) => - evaluateStatementSafety(child, checkOptions), - ), - ); + return evaluateSequenceSafety(root.namedChildren, checkOptions); } finally { tree.delete(); } diff --git a/packages/core/src/utils/shellReadOnlyChecker.test.ts b/packages/core/src/utils/shellReadOnlyChecker.test.ts index f516ff90192..b61b8a1c7c8 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.test.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.test.ts @@ -593,4 +593,63 @@ describe('git config probe cd tracking (#8575)', () => { isShellCommandReadOnly(`cd ${dirtyRepo} && ls -la`, { cwd: cleanRepo }), ).toBe(true); }); + + it('does not take cd flags as the destination directory', () => { + expect( + isShellCommandReadOnly(`cd -P ${dirtyRepo} && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly(`cd -- ${cleanRepo} && git status`, { + cwd: dirtyRepo, + }), + ).toBe(true); + }); + + it('downgrades git after operand-less cd flag forms (cd goes to $HOME)', () => { + expect( + isShellCommandReadOnly('cd -- && git status', { cwd: cleanRepo }), + ).toBe(false); + expect( + isShellCommandReadOnly('cd -P && git status', { cwd: cleanRepo }), + ).toBe(false); + }); + + it('does not propagate cd state across non-&& separators', () => { + expect( + isShellCommandReadOnly(`cd ${cleanRepo} || git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly(`cd ${dirtyRepo}; git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('fails closed for a subshell-wrapped cd', () => { + expect( + isShellCommandReadOnly(`(cd ${dirtyRepo} && git status)`, { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('downgrades git after a multi-argument cd (bash stays put)', () => { + expect( + isShellCommandReadOnly(`cd ${cleanRepo} extra; git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + }); + + it('downgrades git when the cd target does not exist (bash stays put)', () => { + expect( + isShellCommandReadOnly(`cd ${root}/no-such-dir; git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + }); }); diff --git a/packages/core/src/utils/shellReadOnlyChecker.ts b/packages/core/src/utils/shellReadOnlyChecker.ts index 6de49489253..c890c4d5f34 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.ts @@ -11,10 +11,11 @@ */ import { parse } from 'shell-quote'; +import fs from 'node:fs'; import path from 'node:path'; import { detectCommandSubstitution, - splitCommands, + splitCommandsWithSeparators, stripShellWrapper, } from './shell-utils.js'; import { @@ -351,41 +352,60 @@ function evaluateShellSegment( } /** - * Update the tracked execution directory across compound segments. - * `cd`/`pushd` with a statically resolvable target move the probe's base - * directory; anything unresolvable (`cd` alone, `cd -`, expansions, - * `popd`) marks the directory as unknown so later git segments are - * downgraded (#8575). + * Update the tracked execution directory across compound segments. `cd` + * with a statically resolvable target moves the probe's base directory; + * anything unresolvable (`cd` alone, `cd -`, flag-only forms, multi-arg + * forms, expansions, a subshell-wrapped cd) marks the directory as unknown + * so later git segments are downgraded (#8575). `pushd`/`popd` never reach + * this function — they are not whitelisted read-only roots, so their + * segments are rejected before tracking runs. */ -const DIR_CHANGE_COMMAND = /^(?:cd|pushd|popd)(?:\s|$)/; +const CD_COMMAND = /^cd(?:[)\s]|$)/; function trackDirectoryChange( segment: string, currentCwd: string | undefined, ): { currentCwd?: string; unknownDir: boolean } { const trimmed = segment.trim(); - if (!DIR_CHANGE_COMMAND.test(trimmed)) { + const wrapped = trimmed.startsWith('('); + const bare = wrapped ? trimmed.replace(/^\(+\s*/, '') : trimmed; + if (!CD_COMMAND.test(bare)) { return { currentCwd, unknownDir: false }; } - if (/^popd/.test(trimmed)) { + if (wrapped) { + // cd inside a subshell: its effect on the segments that follow the + // flattened split cannot be determined — fail closed. return { currentCwd: undefined, unknownDir: true }; } - const target = trimmed.split(/\s+/)[1]; - if ( - target === undefined || - target === '-' || - target.startsWith('~') || - /[$`'"\\*?[\]{}()<>|;&]/.test(target) - ) { - return { currentCwd: undefined, unknownDir: true }; + const unknown = { currentCwd: undefined, unknownDir: true }; + const tokens = bare.split(/\s+/).slice(1); + const operands: string[] = []; + for (const token of tokens) { + if (token === '-') return unknown; // `cd -` goes to OLDPWD + if (token.startsWith('-')) continue; // -P/-L/-e/-- are flags, not targets + operands.push(token); } - if (path.isAbsolute(target)) { - return { currentCwd: target, unknownDir: false }; + // No operand cds to $HOME; more than one is rejected by bash (`cd: too + // many arguments`) or rewrites $PWD (`cd old new`) — neither resolvable. + if (operands.length !== 1) return unknown; + const target = operands[0]!; + if (target.startsWith('~') || /[$`'"\\*?[\]{}()<>|;&]/.test(target)) { + return unknown; } - if (!currentCwd) { - return { currentCwd: undefined, unknownDir: true }; + const resolved = path.isAbsolute(target) + ? target + : currentCwd + ? path.resolve(currentCwd, target) + : undefined; + if (!resolved) return unknown; + // bash refuses to enter a missing target or a non-directory and stays + // put; fail closed regardless — the target can appear before execution. + try { + if (!fs.statSync(resolved).isDirectory()) return unknown; + } catch { + return unknown; } - return { currentCwd: `${currentCwd}/${target}`, unknownDir: false }; + return { currentCwd: resolved, unknownDir: false }; } /** @@ -411,12 +431,25 @@ export function isShellCommandReadOnly( ) return false; - const segments = splitCommands(command); + const segments = splitCommandsWithSeparators(command); let currentCwd = checkOptions?.cwd; let unknownDir = checkOptions?.unknownDir === true; - - for (const segment of segments) { + let dirChanged = false; + let diverged = false; + + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]!.command; + // A segment after a non-`&&` operator (`;`, `||`, `|`, newline, `&`) + // also runs when a preceding cd did not take effect, so the tracked + // directory no longer applies once one was involved (#8575). + if (index > 0 && segments[index - 1]!.separator !== '&&' && dirChanged) { + diverged = true; + } + if (diverged) { + unknownDir = true; + currentCwd = undefined; + } const segmentOptions: ShellReadOnlyCheckOptions | undefined = unknownDir ? { cwd: undefined, unknownDir: true } : currentCwd @@ -425,12 +458,18 @@ export function isShellCommandReadOnly( if (!evaluateShellSegment(segment, segmentOptions)) { return false; } + if (diverged) continue; const tracked = trackDirectoryChange(segment, currentCwd); if (tracked.unknownDir) { unknownDir = true; currentCwd = undefined; - } else if (tracked.currentCwd !== undefined) { + dirChanged = true; + } else if ( + tracked.currentCwd !== undefined && + tracked.currentCwd !== currentCwd + ) { currentCwd = tracked.currentCwd; + dirChanged = true; } } From 6bf04c412e209ebc4c2454d2c15d57fb06ab490c Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 7 Aug 2026 17:02:00 +0000 Subject: [PATCH 08/15] fix(core): close round-2 review findings for git-config exec probe (#8645) --- .../src/memory/memory-scoped-agent-config.ts | 12 +- .../permissions/permission-manager.test.ts | 33 +++ .../src/permissions/permission-manager.ts | 50 +++-- packages/core/src/tools/shell.test.ts | 41 ++++ packages/core/src/tools/shell.ts | 9 + .../core/src/utils/git-config-safety.test.ts | 184 +++++++++++++++- packages/core/src/utils/git-config-safety.ts | 112 ++++++++-- packages/core/src/utils/shell-utils.test.ts | 58 +++++ packages/core/src/utils/shell-utils.ts | 53 ++++- .../core/src/utils/shellAstParser.test.ts | 200 ++++++++++++++---- packages/core/src/utils/shellAstParser.ts | 45 +++- .../src/utils/shellReadOnlyChecker.test.ts | 118 +++++++++-- .../core/src/utils/shellReadOnlyChecker.ts | 30 ++- 13 files changed, 837 insertions(+), 108 deletions(-) diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index ccba2d99a78..c7c2142bc7e 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -14,7 +14,10 @@ import type { } from '../permissions/types.js'; import { ToolNames } from '../tools/tool-names.js'; import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; -import { stripShellWrapper } from '../utils/shell-utils.js'; +import { + hasGitConfigOverridingEnv, + stripShellWrapper, +} from '../utils/shell-utils.js'; import { AUTO_MEMORY_PINNED_DIRNAME, getAutoMemoryRoot, @@ -252,7 +255,12 @@ async function evaluateScopedDecision( } // Managed memory agents' shell calls carry no `directory` parameter, // so ctx.cwd is absent in production — fall back to the scoped - // execution root or the git-config probe never runs (#8575). + // execution root or the git-config probe never runs (#8575). A + // git-overriding env prefix survives the wrapper unwrap and still + // applies to the inner script, so it can never be auto-allowed. + if (hasGitConfigOverridingEnv(ctx.command)) { + return 'deny'; + } const isReadOnly = await isShellCommandReadOnlyAST( stripShellWrapper(ctx.command), { cwd: ctx.cwd ?? projectRoot }, diff --git a/packages/core/src/permissions/permission-manager.test.ts b/packages/core/src/permissions/permission-manager.test.ts index bee82373eed..b55fcfc9424 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -3974,6 +3974,39 @@ describe('git config execution probe wiring (#8575)', () => { ).resolves.toBe('allow'); }); + it('keeps per-segment rule composition without directory changes', async () => { + // Without a cd, a rule-matched segment and a read-only 'default' + // segment compose to allow — whole-command resolution would ask here + // because checkout is a write sub-command. + const manager = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash(git checkout *)'] }), + ); + manager.initialize(); + + await expect( + manager.evaluate({ + toolName: 'run_shell_command', + command: 'ls && git checkout -b feature', + cwd: cleanRepo, + }), + ).resolves.toBe('allow'); + }); + + it('resolves cd-containing compounds against the full command even with a rule match', async () => { + const manager = new PermissionManager( + makeConfig({ permissionsAllow: ['Bash(git checkout *)'] }), + ); + manager.initialize(); + + await expect( + manager.evaluate({ + toolName: 'run_shell_command', + command: `cd ${cleanRepo} && git checkout -b feature`, + cwd: cleanRepo, + }), + ).resolves.toBe('ask'); + }); + it('does not let a segment rule override the cd-aware compound default', async () => { // With a rule matching one segment, the other segment's 'default' must // resolve against the FULL command (cd-aware), not the bare segment diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index 454fd98e4d7..ff9af37dccf 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -17,7 +17,10 @@ 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 { normalizeMonitorCommand } from '../utils/shell-utils.js'; +import { + isDirectoryChangeSegment, + normalizeMonitorCommand, +} from '../utils/shell-utils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { findDangerousAllowRules, @@ -435,7 +438,10 @@ export class PermissionManager { * - Otherwise (including command substitution) → 'ask' * * Example: with rules `allow: [git checkout *]` - * - "cd /path && git checkout -b feature" → allow (cd) + allow (rule) → allow + * - "ls && git checkout -b feature" → allow (ls) + allow (rule) → allow + * - "cd /path && git checkout -b feature" → contains a directory + * change, so 'default' segments resolve against the FULL command + * (a write) → ask * - "rm /path && git checkout -b feature" → ask (rm) + allow (rule) → ask * - "evil-cmd && git checkout" (deny: [evil-cmd]) → deny + allow → deny */ @@ -453,16 +459,17 @@ export class PermissionManager { let mostRestrictive: ResolvedDecision = 'allow'; - // A 'default' sub-command resolves against the FULL original command: - // per-segment classification cannot see cd context across the split and - // would probe a stale cwd, letting `cd && git status` auto-run - // whenever any rule matches a sibling segment (#8575). The - // whole-command classifier tracks directory changes. - // Called only for compound commands, so ctx.command is defined. - const defaultDecision = await this.resolveDefaultPermission( - ctx.command!, - this.probeCwd(ctx), - ); + // When the compound contains a directory-changing segment, a 'default' + // sub-command resolves against the FULL original command: per-segment + // classification cannot see cd context across the split and would probe + // a stale cwd, letting `cd && git status` auto-run whenever any + // rule matches a sibling segment (#8575). The whole-command classifier + // tracks directory changes. Without a directory change, per-segment + // resolution keeps rule composition working. Called only for compound + // commands, so ctx.command is defined; computed lazily — rule-heavy + // configs may never reach a 'default' segment. + const hasDirectoryChange = subCommands.some(isDirectoryChangeSegment); + let wholeCommandDecision: 'allow' | 'ask' | undefined; for (const subCmd of subCommands) { const subCtx: PermissionCheckContext = { @@ -473,10 +480,21 @@ export class PermissionManager { // Resolve 'default' to actual permission using AST analysis // (same logic as ShellToolInvocation.getDefaultPermission) - const decision: ResolvedDecision = - rawDecision === 'default' - ? defaultDecision - : (rawDecision as ResolvedDecision); + let decision: ResolvedDecision; + if (rawDecision !== 'default') { + decision = rawDecision as ResolvedDecision; + } else if (hasDirectoryChange) { + wholeCommandDecision ??= await this.resolveDefaultPermission( + ctx.command!, + this.probeCwd(ctx), + ); + decision = wholeCommandDecision; + } else { + decision = await this.resolveDefaultPermission( + subCmd, + this.probeCwd(ctx), + ); + } if (PRIORITY[decision] > PRIORITY[mostRestrictive]) { mostRestrictive = decision; diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 09ce65474d3..5924b83d53a 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -7141,6 +7141,47 @@ describe('ShellTool', () => { expect(details.rootCommand).toContain('rm'); }); + it('keeps sub-commands after a disguised cd in the confirmation scope (#8575)', async () => { + mockGitConfigMayExecutePrograms.mockReturnValue(false); + const invocation = shellTool.build({ + command: 'builtin cd /tmp/repo && git status && rm x', + is_background: false, + }); + + const details = (await invocation.getConfirmationDetails( + new AbortController().signal, + )) as { rootCommand: string }; + + // `builtin cd` genuinely changes the directory in bash, so the git + // segment must not be filtered out of the dialog. + expect(details.rootCommand).toContain('git'); + expect(details.rootCommand).toContain('rm'); + }); + + it('asks when a git-overriding env prefix precedes a shell wrapper (#8575)', async () => { + // GIT_DIR survives the wrapper unwrap and applies to the inner + // script; without the guard the stripped `git status` probes the + // clean execution cwd and auto-executes against the planted repo. + const invocation = shellTool.build({ + command: `GIT_DIR=/planted/.git bash -c 'git status'`, + is_background: false, + }); + expect(await invocation.getDefaultPermission()).toBe('ask'); + + const configInjection = shellTool.build({ + command: `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=evil bash -c 'git status'`, + is_background: false, + }); + expect(await configInjection.getDefaultPermission()).toBe('ask'); + + // Unrelated env prefixes keep their normal classification. + const unrelated = shellTool.build({ + command: `FOO=bar bash -c 'ls -la'`, + is_background: false, + }); + expect(await unrelated.getDefaultPermission()).toBe('allow'); + }); + // Regression coverage for PR #4386 round 6 (cid 3298521039): the // env-prefix wrapper substitution bypass. `getDefaultPermission` // calls `stripShellWrapper(this.params.command)` BEFORE the AST diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 2662c1e68ba..b398bb3922a 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -62,6 +62,7 @@ import { getCommandRoot, getCommandRoots, getShellConfiguration, + hasGitConfigOverridingEnv, hasShellSubstitution, isDirectoryChangeSegment, SHELL_SELF_KILL_REJECTION, @@ -2037,6 +2038,14 @@ export class ShellToolInvocation extends BaseToolInvocation< return 'ask'; } + // A git-overriding env prefix (GIT_DIR=…, GIT_CONFIG_COUNT=…) survives + // the wrapper unwrap below and still applies to the inner script, + // while the probe would only see the stripped command's cwd — the + // compound would auto-execute against attacker-chosen config (#8575). + if (hasGitConfigOverridingEnv(this.params.command)) { + return 'ask'; + } + const command = stripShellWrapper(this.params.command); // AST-based read-only detection diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index d49822dc506..8d00d4e1fb0 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -101,6 +101,8 @@ describe('gitConfigMayExecutePrograms', () => { '[remote "origin"]\n\tproxy = nc -X 5 -x proxy:1080 %h %p\n', 'remote-proxy', ], + ['[remote "origin"]\n\tuploadpack = /tmp/evil\n', 'remote-uploadpack'], + ['[remote "origin"]\n\treceivepack = /tmp/evil\n', 'remote-receivepack'], ['[remote "origin"]\n\turl = ext::sh -c evil%% %S %u\n', 'remote-ext-url'], ] as Array<[string, string]>)('flags subsection key %s', (config, label) => { const repo = makeRepo(label, config); @@ -114,6 +116,15 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(disabled)).toBe(false); }); + it('does not flag boolean core.pager values (no repo-supplied program)', () => { + const off = makeRepo('pager-false', '[core]\n\tpager = false\n'); + expect(gitConfigMayExecutePrograms(off)).toBe(false); + const on = makeRepo('pager-true', '[core]\n\tpager = true\n'); + expect(gitConfigMayExecutePrograms(on)).toBe(false); + const program = makeRepo('pager-prog', '[core]\n\tpager = less -R\n'); + expect(gitConfigMayExecutePrograms(program)).toBe(true); + }); + it('does not flag empty values or non-executing keys', () => { const repo = makeRepo( 'benign', @@ -130,6 +141,19 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(false); }); + it('strips comments inside sections (trailing and whole-line)', () => { + // git strips trailing comments, so the value is the boolean `false` — + // a probe that kept the comment text would fail the boolean exemption + // and spuriously confirm every whitelisted command. + const trailing = makeRepo( + 'inline-comment', + '[pager]\n\tlog = false # disabled\n', + ); + expect(gitConfigMayExecutePrograms(trailing)).toBe(false); + const wholeLine = makeRepo('whole-line-comment', '[pager]\n# log = evil\n'); + expect(gitConfigMayExecutePrograms(wholeLine)).toBe(false); + }); + it('parses inline `[section] key = value` lines', () => { const dirty = makeRepo('inline-dirty', '[diff] external = /tmp/evil\n'); expect(gitConfigMayExecutePrograms(dirty)).toBe(true); @@ -142,6 +166,45 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); + it('flags core.hooksPath overrides (hooks resolve to attacker files)', () => { + const repo = makeRepo('hookspath', '[core]\n\thooksPath = .myhooks\n'); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('flags executable hooks that read-only commands trigger', () => { + for (const hook of ['post-index-change', 'fsmonitor-watchman']) { + const repo = makeRepo(`hook-${hook}`, ''); + const hooksDir = path.join(repo, '.git', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, hook); + fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); + fs.chmodSync(hookPath, 0o755); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + } + }); + + // fs.accessSync(X_OK) is not meaningful on Windows — every file is + // "executable" there — so only assert the negative case elsewhere. + it.skipIf(process.platform === 'win32')( + 'does not flag non-executable or unrelated hooks', + () => { + const repo = makeRepo('hook-inactive', ''); + const hooksDir = path.join(repo, '.git', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'post-index-change'), + '#!/bin/sh\ntouch /tmp/evil\n', + ); + fs.chmodSync(path.join(hooksDir, 'post-index-change'), 0o644); + fs.writeFileSync( + path.join(hooksDir, 'pre-commit.sample'), + '#!/bin/sh\n', + { mode: 0o755 }, + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }, + ); + it('flags include/includeIf entries instead of resolving them', () => { const inc = makeRepo('include', '[include]\n\tpath = ../other-config\n'); expect(gitConfigMayExecutePrograms(inc)).toBe(true); @@ -171,6 +234,35 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); + it('flags ext:: url rewrites with an EMPTY insteadOf (match-all prefix)', () => { + // git treats an empty insteadOf as matching every URL. + const repo = makeRepo( + 'empty-insteadof', + '[url "ext::sh -c evil"]\n\tinsteadOf =\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('flags protocol..allow lifts of the ext:: transport block', () => { + const always = makeRepo( + 'proto-always', + '[protocol "ext"]\n\tallow = always\n', + ); + expect(gitConfigMayExecutePrograms(always)).toBe(true); + const user = makeRepo('proto-user', '[protocol]\n\tallow = user\n'); + expect(gitConfigMayExecutePrograms(user)).toBe(true); + const undecodable = makeRepo( + 'proto-undecodable', + '[protocol "ext"]\n\tallow = "unterminated\n', + ); + expect(gitConfigMayExecutePrograms(undecodable)).toBe(true); + const never = makeRepo( + 'proto-never', + '[protocol "ext"]\n\tallow = never\n', + ); + expect(gitConfigMayExecutePrograms(never)).toBe(false); + }); + it('does not flag boolean pager overrides', () => { const repo = makeRepo( 'pager-bool', @@ -188,6 +280,18 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); + it('joins continued lines before checking values', () => { + // git joins values across a backslash continuation; the dirty evidence + // only exists AFTER the join. + const dirty = makeRepo( + 'cont-ext', + '[remote "origin"]\n\turl = ext\\\n::sh -c evil %S %u\n', + ); + expect(gitConfigMayExecutePrograms(dirty)).toBe(true); + const clean = makeRepo('cont-bool', '[pager]\n\tlog = fal\\\nse\n'); + expect(gitConfigMayExecutePrograms(clean)).toBe(false); + }); + it('reads config.worktree of the main checkout (extensions.worktreeConfig)', () => { const repo = makeRepo( 'wtcfg', @@ -323,21 +427,71 @@ describe('gitConfigMayExecutePrograms', () => { it('probes through a symlinked workspace directory', () => { if (process.platform === 'win32') return; // symlink perms differ const repo = makeRepo('sym-repo', '[diff]\n\texternal = /tmp/evil\n'); + // Point the link at a NESTED path: without the realpathSync in the + // probe the walk would climb the link's own ancestors, never find the + // repo, and fail open. + const nested = path.join(repo, 'src'); + fs.mkdirSync(nested); const link = path.join(root, 'ws-link'); - fs.symlinkSync(repo, link); + fs.symlinkSync(nested, link); expect(gitConfigMayExecutePrograms(link)).toBe(true); }); it('fails closed when the repo search depth is exhausted', () => { - const repo = makeRepo('deep-repo', '[diff]\n\texternal = /tmp/evil\n'); + // A CLEAN config: discovery would return false, so the `true` verdict + // uniquely pins the exhaustion path (a raised/removed depth cap would + // otherwise reach the repo and read the clean config undetected). + // Single-char segments keep 70 levels under Windows MAX_PATH. + const repo = makeRepo('deep-repo', '[core]\n\tbare = false\n'); let deep = repo; for (let i = 0; i < 70; i++) { - deep = path.join(deep, `d${i}`); + deep = path.join(deep, 'd'); } fs.mkdirSync(deep, { recursive: true }); expect(gitConfigMayExecutePrograms(deep)).toBe(true); }); + it('probes the target of a .git/commondir redirect (main checkout)', () => { + // git honors a `.git/commondir` file and reads the pointed-to + // directory's config as the common config — the probe must too. + const evilCommon = makeRepo( + 'common-evil', + '[core]\n\tfsmonitor = /tmp/evil\n', + ); + const repo = makeRepo('redirected', ''); + fs.writeFileSync( + path.join(repo, '.git', 'commondir'), + `${path.join(evilCommon, '.git')}\n`, + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + + // Clean redirect target keeps the repo clean. + const cleanCommon = makeRepo('common-clean', '[core]\n\tbare = false\n'); + fs.writeFileSync( + path.join(repo, '.git', 'commondir'), + `${path.join(cleanCommon, '.git')}\n`, + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it('probes HEAD+commondir git directories git itself accepts', () => { + // A HEAD-plus-commondir pair is a git directory to git even without + // objects/refs (linked-worktree admin dirs — and attacker-shaped + // stand-ins with a config.worktree). + const stand = path.join(root, 'stand-head-commondir'); + fs.mkdirSync(stand, { recursive: true }); + fs.writeFileSync(path.join(stand, 'HEAD'), 'ref: refs/heads/main\n'); + const target = makeRepo( + 'head-commondir-target', + '[diff]\n\texternal = /tmp/evil\n', + ); + fs.writeFileSync( + path.join(stand, 'commondir'), + `${path.join(target, '.git')}\n`, + ); + expect(gitConfigMayExecutePrograms(stand)).toBe(true); + }); + it('reads the config of a git directory the cwd stands in', () => { // Submodule storage layout: `/.git/modules/` is itself a // git directory; git reads ITS config while standing in it, not the @@ -359,6 +513,30 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(moduleGitDir)).toBe(true); }); + it('probes the commondir target of a standing git directory', () => { + // cwd stands in a HEAD+objects+refs dir whose commondir points at a + // NON-ANCESTOR git dir — the walk-up never reaches the target, only + // the commondir read does. + const target = makeRepo( + 'standing-commondir-target', + '[diff]\n\texternal = /tmp/evil\n', + ); + const stand = path.join(root, 'standing-gitdir'); + fs.mkdirSync(path.join(stand, 'objects'), { recursive: true }); + fs.mkdirSync(path.join(stand, 'refs'), { recursive: true }); + fs.writeFileSync(path.join(stand, 'HEAD'), 'ref: refs/heads/main\n'); + fs.writeFileSync(path.join(stand, 'config'), '[core]\n'); + fs.writeFileSync( + path.join(stand, 'commondir'), + `${path.join(target, '.git')}\n`, + ); + expect(gitConfigMayExecutePrograms(stand)).toBe(true); + + // Same git dir without a commondir stays clean. + fs.rmSync(path.join(stand, 'commondir')); + expect(gitConfigMayExecutePrograms(stand)).toBe(false); + }); + it('fails closed on section headers it cannot parse', () => { // `]` inside a quoted subsection is valid to git but opaque to the // minimal parser — must not silently drop the entries below it. diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 5a80ea99e6f..0e711109e6c 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -18,9 +18,12 @@ * - `core.alternateRefsCommand` — `log --alternate-refs` * - `core.pager`, `pager.` — log / show / diff / blame on a TTY * - `core.askpass`, `credential.helper`, `core.sshCommand`, - * `remote..proxy`, `ext::` remote URLs, `core.gitProxy` — - * `remote show` network/transport helpers + * `remote..proxy`, `remote..uploadpack`, `ext::` remote + * URLs, `protocol..allow` lifts, `core.gitProxy` — `remote + * show` network/transport helpers * - `gpg.program` — signature verification helpers + * - `core.hooksPath` — redirects hook resolution; the default hooks + * directory is also probed for hooks that read-only commands fire * * A `.git/config` planted by an attacker (prompt-injection chain with local * file write, shared workspace) could therefore turn an auto-approved @@ -84,6 +87,7 @@ const PROGRAM_VALUED_KEYS = new Set([ 'core.askpass', // credential prompts (e.g. `git remote show `) 'core.fsmonitor', // fsmonitor hook command (`git status`) 'core.gitproxy', // git:// transport proxy (`git remote show git://…`) + 'core.hookspath', // redirects hook resolution to attacker-chosen files 'core.pager', // pager program for log / show / diff output 'core.sshcommand', // ssh override for authenticated remotes 'credential.helper', // credential helpers during network auth @@ -243,6 +247,32 @@ function decodeGitConfigValue(raw: string): string | null { function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { for (const entry of entries) { const value = decodeGitConfigValue(entry.value); + + // `url..insteadOf` rewrites remote URLs before connecting; an + // ext:: rewrite target executes a program. Checked before the + // empty-value skip: git treats an EMPTY insteadOf as a match-all + // rewrite prefix. Git decodes subsection escapes first (`\x` → `x`), + // so over-approximate by dropping backslashes before comparing. + if ( + entry.section === 'url' && + entry.subsection !== null && + entry.subsection.replace(/\\/g, '').startsWith('ext::') + ) { + return true; + } + + // `protocol.allow` / `protocol..allow` lifts the default block + // on program transports (ext::) — any value that is not definitely + // `never` enables the lift, and a command-line ext:: URL passed to a + // whitelisted command then executes a program. + if ( + entry.section === 'protocol' && + entry.key === 'allow' && + value?.toLowerCase() !== 'never' + ) { + return true; + } + if (value === '') continue; // Include targets can live outside `.git` (e.g. files tracked in the @@ -261,9 +291,10 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { const name = `${entry.section}.${entry.key}`; if (!PROGRAM_VALUED_KEYS.has(name)) continue; // core.fsmonitor true/false selects the built-in daemon or disables - // monitoring — neither executes an external program. + // monitoring; core.pager true/false disables paging or falls back to + // $PAGER — neither executes a repo-config-supplied program. if ( - name === 'core.fsmonitor' && + (name === 'core.fsmonitor' || name === 'core.pager') && value !== null && /^(?:true|false)$/i.test(value) ) { @@ -283,7 +314,13 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { if (entry.key === 'program') return true; break; case 'remote': - if (entry.key === 'proxy') return true; + if ( + entry.key === 'proxy' || + entry.key === 'uploadpack' || + entry.key === 'receivepack' + ) { + return true; + } if (entry.key === 'url' && (value === null || /^ext::/.test(value))) { return true; } @@ -299,16 +336,6 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { return true; } break; - case 'url': - // `url..insteadOf` rewrites remote URLs before connecting; - // an ext:: rewrite target executes a program (the transport - // block can be lifted via protocol.ext.allow in the same file). - // Git decodes subsection escapes first (`\x` → `x`), so - // over-approximate by dropping backslashes before comparing. - if (entry.subsection!.replace(/\\/g, '').startsWith('ext::')) { - return true; - } - break; default: break; } @@ -316,6 +343,25 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { return false; } +/** + * Hooks that fire while whitelisted read-only commands run: an index + * refresh runs `post-index-change`, and `git status` consults + * `fsmonitor-watchman` when monitoring is hook-based. + */ +const READ_ONLY_TRIGGERED_HOOKS = ['post-index-change', 'fsmonitor-watchman']; + +function hooksMayExecutePrograms(hooksDir: string): boolean { + for (const hook of READ_ONLY_TRIGGERED_HOOKS) { + try { + fs.accessSync(path.join(hooksDir, hook), fs.constants.X_OK); + return true; + } catch { + // Hook absent or not executable. + } + } + return false; +} + function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { let enabled = false; for (const entry of entries) { @@ -340,11 +386,17 @@ function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { * objects and refs/packed-refs is itself a git directory (is_git_directory * in setup.c) — a bare repository, or a submodule's storage dir like * `/.git/modules/` whose own config git reads while standing - * in it. + * in it. git also accepts a HEAD-plus-commondir pair (linked-worktree + * admin directories — and attacker-shaped stand-ins), so mirror that. */ function isGitDirectory(dir: string): boolean { try { if (!fs.statSync(path.join(dir, 'HEAD')).isFile()) return false; + try { + if (fs.statSync(path.join(dir, 'commondir')).isFile()) return true; + } catch { + // No commondir — fall through to the objects/refs requirement. + } if (!fs.statSync(path.join(dir, 'objects')).isDirectory()) return false; try { if (fs.statSync(path.join(dir, 'refs')).isDirectory()) return true; @@ -400,11 +452,22 @@ function findLocalGitConfigFiles(cwd: string): string[] { if (stat) { if (stat.isDirectory()) { // With extensions.worktreeConfig enabled, git also reads - // `config.worktree` for the MAIN worktree — probe both. - return [ - path.join(gitPath, 'config'), - path.join(gitPath, 'config.worktree'), - ]; + // `config.worktree` for the MAIN worktree — probe both. A + // `commondir` file redirects the common config git reads, so + // probe the pointed-to directory's config as well. + const files = [path.join(gitPath, 'config')]; + try { + const commonDir = fs + .readFileSync(path.join(gitPath, 'commondir'), 'utf8') + .trim(); + if (commonDir) { + files.push(path.join(path.resolve(gitPath, commonDir), 'config')); + } + } catch { + // No commondir — the repo's own config is the common config. + } + files.push(path.join(gitPath, 'config.worktree')); + return files; } if (stat.isFile()) { let pointer: string; @@ -473,7 +536,11 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { try { let readWorktreeConfig = false; + const hooksDirs = new Set(); for (const file of findLocalGitConfigFiles(cwd)) { + if (path.basename(file) === 'config') { + hooksDirs.add(path.join(path.dirname(file), 'hooks')); + } if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; try { if (fs.statSync(file).size > MAX_CONFIG_FILE_BYTES) { @@ -494,6 +561,9 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { if (entriesMayExecutePrograms(entries)) return true; readWorktreeConfig ||= worktreeConfigEnabled(entries); } + for (const hooksDir of hooksDirs) { + if (hooksMayExecutePrograms(hooksDir)) return true; + } return false; } catch { return true; // unexpected probe failure — fail closed diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index ea23f3f01fb..b37ba064fd3 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -15,10 +15,12 @@ import { getCommandRoot, getCommandRoots, getShellConfiguration, + hasGitConfigOverridingEnv, hasNonFinalTopLevelBackgroundOperator, hasUnsafeMonitorBackgroundOperator, isCommandAllowed, isCommandNeedsPermission, + isDirectoryChangeSegment, normalizeMonitorCommand, splitCommands, stripTrailingBackgroundAmp, @@ -1423,3 +1425,59 @@ describe('splitCommands', () => { }); }); }); + +describe('isDirectoryChangeSegment (#8575)', () => { + it('matches bare cd / pushd / popd segments', () => { + expect(isDirectoryChangeSegment('cd /tmp/repo')).toBe(true); + expect(isDirectoryChangeSegment('pushd /tmp/repo')).toBe(true); + expect(isDirectoryChangeSegment('popd')).toBe(true); + expect(isDirectoryChangeSegment('(cd /tmp/repo)')).toBe(true); + }); + + it('matches disguised directory-change forms', () => { + // All of these genuinely change the directory in bash. + expect(isDirectoryChangeSegment('builtin cd /tmp/repo')).toBe(true); + expect(isDirectoryChangeSegment('command cd /tmp/repo')).toBe(true); + expect(isDirectoryChangeSegment('"cd" /tmp/repo')).toBe(true); + expect(isDirectoryChangeSegment("'cd' /tmp/repo")).toBe(true); + expect(isDirectoryChangeSegment('\\cd /tmp/repo')).toBe(true); + expect(isDirectoryChangeSegment('FOO=x cd /tmp/repo')).toBe(true); + }); + + it('does not match non-directory-change segments', () => { + expect(isDirectoryChangeSegment('ls -la')).toBe(false); + expect(isDirectoryChangeSegment('git status')).toBe(false); + expect(isDirectoryChangeSegment('command -v cd')).toBe(false); + expect(isDirectoryChangeSegment('cdr /tmp/repo')).toBe(false); + }); +}); + +describe('hasGitConfigOverridingEnv (#8575)', () => { + it('detects git discovery/config overrides in leading env assignments', () => { + expect(hasGitConfigOverridingEnv('GIT_DIR=/planted/.git git status')).toBe( + true, + ); + expect(hasGitConfigOverridingEnv('GIT_WORK_TREE=/x git status')).toBe(true); + expect(hasGitConfigOverridingEnv('GIT_COMMON_DIR=/x git status')).toBe( + true, + ); + expect( + hasGitConfigOverridingEnv( + 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=evil git status', + ), + ).toBe(true); + expect( + hasGitConfigOverridingEnv("FOO=1 GIT_DIR=/x bash -c 'git status'"), + ).toBe(true); + }); + + it('ignores unrelated env assignments and non-env commands', () => { + expect(hasGitConfigOverridingEnv('FOO=bar git status')).toBe(false); + expect(hasGitConfigOverridingEnv('GIT_TERMINAL_PROMPT=0 git status')).toBe( + false, + ); + expect(hasGitConfigOverridingEnv('git status')).toBe(false); + // The override must be a LEADING env assignment, not an argument. + expect(hasGitConfigOverridingEnv('env GIT_DIR=/x')).toBe(false); + }); +}); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 5d66424b787..11dcd54a5e9 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -369,9 +369,35 @@ export function splitCommands(command: string): string[] { /** True when a split segment changes the working directory. */ const DIRECTORY_CHANGE_SEGMENT = /^\(*\s*(?:cd|pushd|popd)(?:[\s);]|$)/; +const DIRECTORY_CHANGE_COMMANDS = new Set(['cd', 'pushd', 'popd']); +const DIRECTORY_CHANGE_PREFIXES = new Set(['builtin', 'command']); export function isDirectoryChangeSegment(segment: string): boolean { - return DIRECTORY_CHANGE_SEGMENT.test(segment.trim()); + // Parse with shell-quote so disguised forms are recognized too: + // `builtin cd` / `command cd`, env-prefixed cds (`FOO=x cd /dir`), and + // quoted or escaped roots (`"cd"`, `'cd'`, `\cd`) all change the + // directory in bash. Over-detecting only widens the confirmation scope; + // under-detecting would drop the git segments after the cd from it + // (#8575). + try { + const tokens = parse(segment).filter( + (token): token is string => typeof token === 'string', + ); + let index = 0; + while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index]!)) { + index++; + } + if ( + index < tokens.length && + DIRECTORY_CHANGE_PREFIXES.has(tokens[index]!) + ) { + index++; + } + const root = tokens[index]; + return root !== undefined && DIRECTORY_CHANGE_COMMANDS.has(root); + } catch { + return DIRECTORY_CHANGE_SEGMENT.test(segment.trim()); + } } /** @@ -486,6 +512,31 @@ export function getCommandRoots(command: string): string[] { .filter((c): c is string => !!c); } +const GIT_CONFIG_OVERRIDING_ENV = /^GIT_(?:DIR|WORK_TREE|COMMON_DIR|CONFIG)/; + +/** + * True when the command's leading env assignments override git's + * repository discovery (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR) or inject + * config (GIT_CONFIG_COUNT and GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n). + * Such assignments survive + * `stripShellWrapper`'s wrapper unwrap and still apply to the inner + * script, so classifying the stripped command alone would probe the wrong + * repository (#8575). + */ +export function hasGitConfigOverridingEnv(command: string): boolean { + let rest = command; + while (true) { + const token = takeLeadingToken(rest); + if (!token || !isEnvAssignmentToken(token.token)) return false; + if ( + GIT_CONFIG_OVERRIDING_ENV.test(stripSymmetricQuotes(token.token).value) + ) { + return true; + } + rest = token.rest; + } +} + export function stripShellWrapper(command: string): string { const trimmed = command.trim(); let rest = trimmed; diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index d01cb13f1c4..bb09049dd6f 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -1072,6 +1072,36 @@ describe('isShellCommandReadOnlyAST fallback to regex-based checker', () => { expect(await isShellCommandReadOnlyAST('ls -la')).toBe(true); expect(await isShellCommandReadOnlyAST('rm -rf /')).toBe(false); }); + + it('forwards checkOptions to the regex fallback (#8575)', async () => { + // The fallback delegation is load-bearing: dropping checkOptions would + // auto-approve `git status` in a dirty repo on exactly the installs + // (WASM missing after a symlinked install) the fallback exists for. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-fallback-probe-')); + try { + const dirtyRepo = path.join(tmp, 'dirty'); + fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtyRepo, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + const cleanRepo = path.join(tmp, 'clean'); + fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(cleanRepo, '.git', 'config'), + '[core]\n\tbare = false\n', + ); + _setParserFailedForTesting(); + expect( + await isShellCommandReadOnlyAST('git status', { cwd: dirtyRepo }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST('git status', { cwd: cleanRepo }), + ).toBe(true); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); }); // ========================================================================= @@ -1253,21 +1283,26 @@ describe('git config execution probe (#8575)', () => { fs.rmSync(root, { recursive: true, force: true }); }); - it.each(['git diff', 'git status', 'git log -p', 'git show HEAD'])( - 'downgrades %s when repo config executes programs', - async (command) => { - expect(await isShellCommandReadOnlyAST(command, { cwd: dirtyRepo })).toBe( - false, - ); - expect( - await classifyShellCommandSafety(command, { cwd: dirtyRepo }), - ).toBe('unknown'); - // Same command stays read-only in a clean repo. - expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( - true, - ); - }, - ); + it.each([ + 'git diff', + 'git status', + 'git log -p', + 'git show HEAD', + 'git remote show origin', + 'git branch', + 'git branch --list', + ])('downgrades %s when repo config executes programs', async (command) => { + expect(await isShellCommandReadOnlyAST(command, { cwd: dirtyRepo })).toBe( + false, + ); + expect(await classifyShellCommandSafety(command, { cwd: dirtyRepo })).toBe( + 'unknown', + ); + // Same command stays read-only in a clean repo. + expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( + true, + ); + }); it('downgrades compound commands touching a dirty repo cwd', async () => { expect( @@ -1344,14 +1379,19 @@ describe('git config probe cd tracking (#8575)', () => { fs.rmSync(root, { recursive: true, force: true }); }); + // On Windows tmpdir paths contain backslashes, which the cd-target + // resolver (correctly) rejects; normalize to slashes so the tracking + // logic is still exercised there (path.win32.isAbsolute accepts `C:/`). + const p = (dir: string) => dir.split(path.sep).join('/'); + it('probes the post-cd repository for absolute targets', async () => { expect( - await isShellCommandReadOnlyAST(`cd ${dirtyRepo} && git status`, { + await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)} && git status`, { cwd: cleanRepo, }), ).toBe(false); expect( - await isShellCommandReadOnlyAST(`cd ${cleanRepo} && git status`, { + await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} && git status`, { cwd: dirtyRepo, }), ).toBe(true); @@ -1402,7 +1442,7 @@ describe('git config probe cd tracking (#8575)', () => { it('leaves non-git commands after cd untouched', async () => { expect( - await isShellCommandReadOnlyAST(`cd ${dirtyRepo} && ls -la`, { + await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)} && ls -la`, { cwd: cleanRepo, }), ).toBe(true); @@ -1412,7 +1452,7 @@ describe('git config probe cd tracking (#8575)', () => { for (const flag of ['-P', '-L', '-e', '--']) { expect( await isShellCommandReadOnlyAST( - `cd ${flag} ${dirtyRepo} && git status`, + `cd ${flag} ${p(dirtyRepo)} && git status`, { cwd: cleanRepo, }, @@ -1420,7 +1460,7 @@ describe('git config probe cd tracking (#8575)', () => { ).toBe(false); } expect( - await isShellCommandReadOnlyAST(`cd -- ${cleanRepo} && git status`, { + await isShellCommandReadOnlyAST(`cd -- ${p(cleanRepo)} && git status`, { cwd: dirtyRepo, }), ).toBe(true); @@ -1440,12 +1480,12 @@ describe('git config probe cd tracking (#8575)', () => { it('tracks cd across ; and newline separators', async () => { expect( - await isShellCommandReadOnlyAST(`cd ${dirtyRepo}; git status`, { + await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)}; git status`, { cwd: cleanRepo, }), ).toBe(false); expect( - await isShellCommandReadOnlyAST(`cd ${dirtyRepo}\ngit status`, { + await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)}\ngit status`, { cwd: cleanRepo, }), ).toBe(false); @@ -1453,12 +1493,12 @@ describe('git config probe cd tracking (#8575)', () => { it('propagates cd out of brace groups (they run in the current shell)', async () => { expect( - await isShellCommandReadOnlyAST(`{ cd ${dirtyRepo}; }; git status`, { + await isShellCommandReadOnlyAST(`{ cd ${p(dirtyRepo)}; }; git status`, { cwd: cleanRepo, }), ).toBe(false); expect( - await isShellCommandReadOnlyAST(`{ cd ${dirtyRepo}; } && git status`, { + await isShellCommandReadOnlyAST(`{ cd ${p(dirtyRepo)}; } && git status`, { cwd: cleanRepo, }), ).toBe(false); @@ -1467,7 +1507,7 @@ describe('git config probe cd tracking (#8575)', () => { it('tracks cd wrapped in redirections', async () => { expect( await isShellCommandReadOnlyAST( - `cd ${dirtyRepo} { it('tracks cd inside subshell bodies', async () => { expect( - await isShellCommandReadOnlyAST(`(cd ${dirtyRepo}; git status)`, { + await isShellCommandReadOnlyAST(`(cd ${p(dirtyRepo)}; git status)`, { cwd: cleanRepo, }), ).toBe(false); @@ -1483,13 +1523,13 @@ describe('git config probe cd tracking (#8575)', () => { it('does not propagate cd state across || (RHS runs when cd failed)', async () => { expect( - await isShellCommandReadOnlyAST(`cd ${cleanRepo} || git status`, { + await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} || git status`, { cwd: dirtyRepo, }), ).toBe(false); expect( await isShellCommandReadOnlyAST( - `cd ${dirtyRepo} || cd ${cleanRepo} && git status`, + `cd ${p(dirtyRepo)} || cd ${p(cleanRepo)} && git status`, { cwd: cleanRepo }, ), ).toBe(false); @@ -1497,7 +1537,7 @@ describe('git config probe cd tracking (#8575)', () => { it('downgrades git after a multi-argument cd (bash stays put)', async () => { expect( - await isShellCommandReadOnlyAST(`cd ${cleanRepo} extra; git status`, { + await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} extra; git status`, { cwd: dirtyRepo, }), ).toBe(false); @@ -1505,7 +1545,7 @@ describe('git config probe cd tracking (#8575)', () => { it('downgrades git when the cd target does not exist (bash stays put)', async () => { expect( - await isShellCommandReadOnlyAST(`cd ${root}/no-such-dir; git status`, { + await isShellCommandReadOnlyAST(`cd ${p(root)}/no-such-dir; git status`, { cwd: dirtyRepo, }), ).toBe(false); @@ -1513,7 +1553,7 @@ describe('git config probe cd tracking (#8575)', () => { it('treats ANSI-C-quoted and backslash-escaped cd targets as unknown', async () => { expect( - await isShellCommandReadOnlyAST(`cd $'${dirtyRepo}' && git status`, { + await isShellCommandReadOnlyAST(`cd $'${p(dirtyRepo)}' && git status`, { cwd: cleanRepo, }), ).toBe(false); @@ -1526,22 +1566,110 @@ describe('git config probe cd tracking (#8575)', () => { it('treats concatenated quoted/unquoted cd targets as unknown', async () => { expect( - await isShellCommandReadOnlyAST(`cd "${root}/"dirty-repo && git status`, { - cwd: cleanRepo, - }), + await isShellCommandReadOnlyAST( + `cd "${p(root)}/"dirty-repo && git status`, + { + cwd: cleanRepo, + }, + ), ).toBe(false); }); it('never auto-approves commands containing pushd', async () => { expect( - await isShellCommandReadOnlyAST(`pushd ${dirtyRepo} && git status`, { + await isShellCommandReadOnlyAST(`pushd ${p(dirtyRepo)} && git status`, { cwd: cleanRepo, }), ).toBe(false); expect( - await isShellCommandReadOnlyAST(`pushd ${cleanRepo} && git status`, { + await isShellCommandReadOnlyAST(`pushd ${p(cleanRepo)} && git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + }); + + it('requires a clean prior directory for ;/newline-separated cds', async () => { + // The cd may fail at runtime (bash stays in the prior directory), so + // the prior directory must also be clean before trusting the target. + expect( + await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)}; git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + expect( + await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)}\ngit status`, { cwd: dirtyRepo, }), ).toBe(false); }); + + it('does not trust a ||-joined cd as certain (it may be skipped)', async () => { + // `echo hi || cd X` — echo succeeds, bash skips the cd, and git runs + // in the ORIGINAL directory. + expect( + await isShellCommandReadOnlyAST( + `echo hi || cd ${p(cleanRepo)} && git status`, + { cwd: dirtyRepo }, + ), + ).toBe(false); + // Clean prior directory keeps the chain read-only. + expect( + await isShellCommandReadOnlyAST( + `echo hi || cd ${p(cleanRepo)} && git status`, + { cwd: cleanRepo }, + ), + ).toBe(true); + }); + + it('does not propagate cd from backgrounded statements', async () => { + // `&` backgrounds the cd into a subshell; the current shell stays in + // cwd, so the following git command must be probed there. + expect( + await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} & git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + // Two-hop bypass: the relative cd resolves against the ORIGINAL cwd, + // not the backgrounded target. + const work = path.join(root, 'work'); + const dirtySub = path.join(work, 'sub'); + fs.mkdirSync(path.join(dirtySub, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(dirtySub, '.git', 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + expect( + await isShellCommandReadOnlyAST( + `cd ${p(cleanRepo)} & cd sub && git status`, + { cwd: work }, + ), + ).toBe(false); + // Same shape inside a subshell body. + expect( + await isShellCommandReadOnlyAST( + `(cd ${p(cleanRepo)} & cd sub && git status)`, + { cwd: work }, + ), + ).toBe(false); + }); + + it('does not propagate cd through negation', async () => { + // `! cd X && …` continues the chain precisely when the cd FAILED. + expect( + await isShellCommandReadOnlyAST(`! cd ${p(cleanRepo)} && git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + expect( + await classifyShellCommandSafety(`! cd ${p(cleanRepo)} && git status`, { + cwd: dirtyRepo, + }), + ).toBe('unknown'); + // Negation without a cd leaves the context untouched. + expect( + await isShellCommandReadOnlyAST(`! ls && git status`, { + cwd: cleanRepo, + }), + ).toBe(true); + }); }); diff --git a/packages/core/src/utils/shellAstParser.ts b/packages/core/src/utils/shellAstParser.ts index 6575934b53b..40e9b44e453 100644 --- a/packages/core/src/utils/shellAstParser.ts +++ b/packages/core/src/utils/shellAstParser.ts @@ -1122,14 +1122,25 @@ function childrenSafety( * actually reaches (#8575). */ function evaluateSequenceSafety( - statements: SyntaxNode[], + node: SyntaxNode, checkOptions?: ShellReadOnlyCheckOptions, ): ShellCommandSafety { let context = checkOptions; let result: ShellCommandSafety = 'read-only'; - for (const node of statements) { - result = mergeSafety(result, evaluateStatementSafety(node, context)); - context = contextAfterStatement(node, context); + const children = node.children; + for (let index = 0; index < children.length; index++) { + const child = children[index]!; + if (!child.isNamed) continue; + result = mergeSafety(result, evaluateStatementSafety(child, context)); + // A `&` terminator backgrounds the statement: it runs in a subshell, + // so its directory changes never reach the statements that follow + // (#8575). (`&` is a terminator between statements, never inside a + // `list` node.) + const terminator = children[index + 1]; + context = + terminator && !terminator.isNamed && terminator.type === '&' + ? context + : contextAfterStatement(child, context); } return result; } @@ -1146,7 +1157,10 @@ function* iterateListStatements( let operator = leadingOperator; for (const child of node.children) { if (!child.isNamed) { - if (child.type === '&&' || child.type === '||' || child.type === '&') { + // `&` never appears inside a `list` node — it terminates the list + // at program/compound level, where evaluateSequenceSafety handles + // it — so only `&&`/`||` join list members. + if (child.type === '&&' || child.type === '||') { operator = child.type; } continue; @@ -1192,7 +1206,11 @@ function evaluateListSafety( continue; } result = mergeSafety(result, evaluateStatementSafety(statement, context)); - const next = contextAfterStatement(statement, context, true); + // A cd joined by `||` may be skipped entirely (the preceding segment + // succeeded), so the following segments can also run in the prior + // directory — pass `certain=false` so contextAfterCd applies its + // prior-directory check (#8575). + const next = contextAfterStatement(statement, context, operator !== '||'); if (next !== context) { context = next; directoryTracked = true; @@ -1213,13 +1231,20 @@ function contextAfterStatement( context?: ShellReadOnlyCheckOptions, certain = false, ): ShellReadOnlyCheckOptions | undefined { - if (node.type === 'redirected_statement' || node.type === 'negated_command') { - // Redirection/negation still runs the body in the current shell. + if (node.type === 'redirected_statement') { + // Redirection still runs the body in the current shell. const body = node.namedChildren[0]; return body ? contextAfterStatement(body, context, certain) : { ...context, cwd: undefined, unknownDir: true }; } + if (node.type === 'negated_command') { + // `! cd X && …` continues the chain precisely when the cd FAILED — + // the resolved target would point at a directory git never reaches. + return containsCurrentShellCd(node) + ? { ...context, cwd: undefined, unknownDir: true } + : context; + } if (node.type === 'command') { const name = getCommandName(node); if (name === 'cd' || name === 'pushd') { @@ -1347,7 +1372,7 @@ function evaluateStatementSafety( if (node.type === 'command') return evaluateCommandSafety(node, checkOptions); if (node.type === 'list') return evaluateListSafety(node, checkOptions); if (node.type === 'compound_statement' || node.type === 'subshell') - return evaluateSequenceSafety(node.namedChildren, checkOptions); + return evaluateSequenceSafety(node, checkOptions); if (CHILD_STATEMENT.test(node.type)) return childrenSafety(node, 'read-only', checkOptions); if (node.type === 'redirected_statement') @@ -1374,7 +1399,7 @@ async function classifyInternal( try { const root = tree.rootNode; if (root.namedChildCount === 0 || root.hasError) return 'unknown'; - return evaluateSequenceSafety(root.namedChildren, checkOptions); + return evaluateSequenceSafety(root, checkOptions); } finally { tree.delete(); } diff --git a/packages/core/src/utils/shellReadOnlyChecker.test.ts b/packages/core/src/utils/shellReadOnlyChecker.test.ts index b61b8a1c7c8..95774c97c45 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.test.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.test.ts @@ -503,13 +503,17 @@ describe('git config execution probe (#8575)', () => { fs.rmSync(root, { recursive: true, force: true }); }); - it.each(['git diff', 'git status', 'git log'])( - 'downgrades %s when repo config executes programs', - (command) => { - expect(isShellCommandReadOnly(command, { cwd: dirtyRepo })).toBe(false); - expect(isShellCommandReadOnly(command, { cwd: cleanRepo })).toBe(true); - }, - ); + it.each([ + 'git diff', + 'git status', + 'git log', + 'git remote show origin', + 'git branch', + 'git branch --list', + ])('downgrades %s when repo config executes programs', (command) => { + expect(isShellCommandReadOnly(command, { cwd: dirtyRepo })).toBe(false); + expect(isShellCommandReadOnly(command, { cwd: cleanRepo })).toBe(true); + }); it('keeps git --version and bare git read-only under a dirty cwd', () => { expect(isShellCommandReadOnly('git --version', { cwd: dirtyRepo })).toBe( @@ -555,9 +559,14 @@ describe('git config probe cd tracking (#8575)', () => { fs.rmSync(root, { recursive: true, force: true }); }); + // On Windows tmpdir paths contain backslashes, which the cd-target + // resolver (correctly) rejects; normalize to slashes so the tracking + // logic is still exercised there. + const p = (dir: string) => dir.split(path.sep).join('/'); + it('probes the post-cd repository', () => { expect( - isShellCommandReadOnly(`cd ${dirtyRepo} && git status`, { + isShellCommandReadOnly(`cd ${p(dirtyRepo)} && git status`, { cwd: cleanRepo, }), ).toBe(false); @@ -567,7 +576,7 @@ describe('git config probe cd tracking (#8575)', () => { }), ).toBe(false); expect( - isShellCommandReadOnly(`cd ${cleanRepo} && git status`, { + isShellCommandReadOnly(`cd ${p(cleanRepo)} && git status`, { cwd: dirtyRepo, }), ).toBe(true); @@ -590,18 +599,20 @@ describe('git config probe cd tracking (#8575)', () => { it('leaves non-git commands after cd untouched', () => { expect( - isShellCommandReadOnly(`cd ${dirtyRepo} && ls -la`, { cwd: cleanRepo }), + isShellCommandReadOnly(`cd ${p(dirtyRepo)} && ls -la`, { + cwd: cleanRepo, + }), ).toBe(true); }); it('does not take cd flags as the destination directory', () => { expect( - isShellCommandReadOnly(`cd -P ${dirtyRepo} && git status`, { + isShellCommandReadOnly(`cd -P ${p(dirtyRepo)} && git status`, { cwd: cleanRepo, }), ).toBe(false); expect( - isShellCommandReadOnly(`cd -- ${cleanRepo} && git status`, { + isShellCommandReadOnly(`cd -- ${p(cleanRepo)} && git status`, { cwd: dirtyRepo, }), ).toBe(true); @@ -618,12 +629,85 @@ describe('git config probe cd tracking (#8575)', () => { it('does not propagate cd state across non-&& separators', () => { expect( - isShellCommandReadOnly(`cd ${cleanRepo} || git status`, { + isShellCommandReadOnly(`cd ${p(cleanRepo)} || git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly(`cd ${p(dirtyRepo)}; git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + // The guard must fire for ALL five non-&& separators (#8575). + expect( + isShellCommandReadOnly(`cd ${p(cleanRepo)} & git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly(`cd ${p(cleanRepo)} | git status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly(`cd ${p(cleanRepo)}\ngit status`, { + cwd: dirtyRepo, + }), + ).toBe(false); + }); + + it('does not trust a ||-joined cd (it may be skipped entirely)', () => { + expect( + isShellCommandReadOnly(`echo hi || cd ${p(cleanRepo)} && git status`, { cwd: dirtyRepo, }), ).toBe(false); expect( - isShellCommandReadOnly(`cd ${dirtyRepo}; git status`, { + isShellCommandReadOnly(`echo hi || cd ${p(cleanRepo)} && git status`, { + cwd: cleanRepo, + }), + ).toBe(true); + }); + + it('ignores cd in pipeline members (they run in subshells)', () => { + expect( + isShellCommandReadOnly( + `cat /dev/null | cd ${p(cleanRepo)} && git status`, + { cwd: dirtyRepo }, + ), + ).toBe(false); + expect( + isShellCommandReadOnly( + `cat /dev/null | cd ${p(dirtyRepo)} && git status`, + { cwd: cleanRepo }, + ), + ).toBe(true); + expect( + isShellCommandReadOnly( + `cat /dev/null |& cd ${p(cleanRepo)} && git status`, + { cwd: dirtyRepo }, + ), + ).toBe(false); + }); + + it('fails closed for quoted and escaped cd forms', () => { + for (const form of ['"cd"', "'cd'", '\\cd', 'c\\d']) { + expect( + isShellCommandReadOnly(`${form} ${p(dirtyRepo)} && git status`, { + cwd: cleanRepo, + }), + ).toBe(false); + } + }); + + it('fails closed when cd is glued to an input redirection', () => { + expect( + isShellCommandReadOnly(`cd { it('fails closed for a subshell-wrapped cd', () => { expect( - isShellCommandReadOnly(`(cd ${dirtyRepo} && git status)`, { + isShellCommandReadOnly(`(cd ${p(dirtyRepo)} && git status)`, { cwd: cleanRepo, }), ).toBe(false); @@ -639,7 +723,7 @@ describe('git config probe cd tracking (#8575)', () => { it('downgrades git after a multi-argument cd (bash stays put)', () => { expect( - isShellCommandReadOnly(`cd ${cleanRepo} extra; git status`, { + isShellCommandReadOnly(`cd ${p(cleanRepo)} extra; git status`, { cwd: dirtyRepo, }), ).toBe(false); @@ -647,7 +731,7 @@ describe('git config probe cd tracking (#8575)', () => { it('downgrades git when the cd target does not exist (bash stays put)', () => { expect( - isShellCommandReadOnly(`cd ${root}/no-such-dir; git status`, { + isShellCommandReadOnly(`cd ${p(root)}/no-such-dir; git status`, { cwd: dirtyRepo, }), ).toBe(false); diff --git a/packages/core/src/utils/shellReadOnlyChecker.ts b/packages/core/src/utils/shellReadOnlyChecker.ts index c890c4d5f34..0684830c0db 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.ts @@ -370,6 +370,16 @@ function trackDirectoryChange( const wrapped = trimmed.startsWith('('); const bare = wrapped ? trimmed.replace(/^\(+\s*/, '') : trimmed; if (!CD_COMMAND.test(bare)) { + // A disguised cd still changes the directory in bash even though the + // raw text misses the bare-cd regex: quoted or escaped roots (`"cd"`, + // `'cd'`, `\cd`) are unquoted before command lookup, and a glued + // input redirection (`cd 0 ? segments[index - 1]!.separator : null; // A segment after a non-`&&` operator (`;`, `||`, `|`, newline, `&`) // also runs when a preceding cd did not take effect, so the tracked // directory no longer applies once one was involved (#8575). - if (index > 0 && segments[index - 1]!.separator !== '&&' && dirChanged) { + if (incoming !== null && incoming !== '&&' && dirChanged) { diverged = true; } if (diverged) { @@ -459,6 +470,9 @@ export function isShellCommandReadOnly( return false; } if (diverged) continue; + // Every pipeline member runs in a subshell — a cd there never moves + // the directory the following segments execute in (#8575). + if (incoming === '|' || incoming === '|&') continue; const tracked = trackDirectoryChange(segment, currentCwd); if (tracked.unknownDir) { unknownDir = true; @@ -468,7 +482,19 @@ export function isShellCommandReadOnly( tracked.currentCwd !== undefined && tracked.currentCwd !== currentCwd ) { - currentCwd = tracked.currentCwd; + // A cd joined by `||` may be skipped entirely (the preceding + // segment succeeded), in which case the following segments run in + // the prior directory — it must be clean too (#8575). + if ( + incoming === '||' && + currentCwd !== undefined && + gitConfigMayExecutePrograms(currentCwd) + ) { + unknownDir = true; + currentCwd = undefined; + } else { + currentCwd = tracked.currentCwd; + } dirChanged = true; } } From 6bd8c032fed318397716b120464f15dc32af54ee Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 7 Aug 2026 17:35:33 +0000 Subject: [PATCH 09/15] fix(core): flag deprecated dot-form git config sections in exec probe (#8645) --- .../core/src/utils/git-config-safety.test.ts | 40 +++++++++++++++++++ packages/core/src/utils/git-config-safety.ts | 26 +++++++++--- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index 8d00d4e1fb0..ebe26b165cb 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -109,6 +109,46 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); + it.each([ + ['[diff.evil]\n\tcommand = /tmp/evil\n', 'dot-diff-command'], + ['[diff.evil]\n\ttextconv = /tmp/evil\n', 'dot-diff-textconv'], + ['[filter.evil]\n\tclean = /tmp/evil\n', 'dot-filter-clean'], + ['[gpg.ssh]\n\tprogram = /tmp/evil\n', 'dot-gpg-program'], + [ + '[remote.origin]\n\turl = .\n\tuploadpack = /tmp/evil\n', + 'dot-remote-uploadpack', + ], + ['[pager.log]\n\trun = /tmp/evil\n', 'dot-pager'], + ['[DIFF.EVIL]\n\tCOMMAND = /tmp/evil\n', 'dot-case'], + ] as Array<[string, string]>)( + 'flags deprecated dot-form subsection header %s', + (config, label) => { + const repo = makeRepo(label, config); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }, + ); + + it('splits dot-form headers at the first dot only', () => { + // git keeps the remaining dots in the subsection + // (`[diff.evil.suffix]` === `[diff "evil.suffix"]`); a last-dot split + // would leave section `diff.evil` and miss the attack. + const repo = makeRepo( + 'dot-first-dot', + '[diff.evil.suffix]\n\tcommand = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('does not flag benign dot-form subsections', () => { + // Old git versions wrote branch/remote sections in the deprecated dot + // form; they must not start prompting. + const repo = makeRepo( + 'dot-benign', + '[branch.main]\n\tremote = origin\n\tmerge = refs/heads/main\n[remote.origin]\n\turl = https://example.com/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + it('does not flag core.fsmonitor booleans (built-in daemon / disabled)', () => { const enabled = makeRepo('fsm-true', '[core]\n\tfsmonitor = true\n'); expect(gitConfigMayExecutePrograms(enabled)).toBe(false); diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 0e711109e6c..2ef6d4ddb74 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -110,11 +110,12 @@ const SECTION_HEADER = /^([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*$/; /** * Minimal git config parser: enough to identify section/key pairs and raw - * values. Understands `[section]` and `[section "subsection"]` headers - * (including the inline `[section] key = value` form), `key = value` lines, - * continuations, and `#` / `;` comments. Includes are not resolved — - * `include` / `includeIf` entries make the probe fail closed instead, - * because their targets can live outside `.git` (e.g. tracked files). + * values. Understands `[section]`, `[section "subsection"]`, and the + * deprecated `[section.subsection]` headers (including the inline + * `[section] key = value` form), `key = value` lines, continuations, and + * `#` / `;` comments. Includes are not resolved — `include` / `includeIf` + * entries make the probe fail closed instead, because their targets can + * live outside `.git` (e.g. tracked files). */ function parseGitConfig(content: string): ConfigEntry[] { const entries: ConfigEntry[] = []; @@ -180,6 +181,17 @@ function parseGitConfig(content: string): ConfigEntry[] { } section = match[1]!.toLowerCase(); subsection = match[2] ?? null; + if (subsection === null) { + // Deprecated `[section.subsection]` header: git splits at the + // FIRST dot and case-folds the subsection (the quoted form is + // case-sensitive instead). `section` is already lowercased, so + // the slice carries both behaviors. + const dot = section.indexOf('.'); + if (dot > 0) { + subsection = section.slice(dot + 1); + section = section.slice(0, dot); + } + } // Inline form: `[section] key = value` on the same line. const rest = line.slice(close + 1).trim(); if (rest) recordEntry(rest); @@ -336,6 +348,10 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { return true; } break; + case 'pager': + // Dotted `[pager.]` headers only reach this branch via the + // dot-form split above; keep the flat-section catch-all's verdict. + return true; default: break; } From 874ba258ec88e019482ef1074998b4f443558810 Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Fri, 7 Aug 2026 19:03:39 +0000 Subject: [PATCH 10/15] fix(core): probe core.hooksPath targets in git-config exec probe (#8645) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core.hooksPath was listed in PROGRAM_VALUED_KEYS, so any repo with the key set — every husky/lefthook install, and the worktrees Qwen itself creates — downgraded all whitelisted read-only git commands to ask. The key names no program, it only redirects hook lookup, so resolve it the way git does (~ expansion, relative anchored at the worktree root) and probe the target directory for executable read-only-triggered hooks exactly like the default hooks directory. --- .../src/tools/shell-git-config.integ.test.ts | 26 ++++ .../core/src/utils/git-config-safety.test.ts | 115 +++++++++++++++++- packages/core/src/utils/git-config-safety.ts | 71 +++++++++-- 3 files changed, 199 insertions(+), 13 deletions(-) diff --git a/packages/core/src/tools/shell-git-config.integ.test.ts b/packages/core/src/tools/shell-git-config.integ.test.ts index ffb243cabcf..361751fc211 100644 --- a/packages/core/src/tools/shell-git-config.integ.test.ts +++ b/packages/core/src/tools/shell-git-config.integ.test.ts @@ -22,6 +22,7 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { let root: string; let cleanRepo: string; let dirtyRepo: string; + let huskyRepo: string; function makeShellTool(targetDir: string): ShellTool { const config = { @@ -48,6 +49,20 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { path.join(dirtyRepo, '.git', 'config'), '[diff]\n\texternal = /tmp/evil\n[core]\n\tfsmonitor = /tmp/evil\n', ); + + // husky-style setup: core.hooksPath redirects hook resolution, but the + // target dir holds no hooks that read-only commands trigger. + huskyRepo = path.join(root, 'husky'); + fs.mkdirSync(path.join(huskyRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(huskyRepo, '.git', 'config'), + '[core]\n\thooksPath = .husky/_\n', + ); + const huskyHooks = path.join(huskyRepo, '.husky', '_'); + fs.mkdirSync(huskyHooks, { recursive: true }); + fs.writeFileSync(path.join(huskyHooks, 'pre-commit'), '#!/bin/sh\n', { + mode: 0o755, + }); }); afterAll(() => { @@ -76,6 +91,17 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { }, ); + it.each(['git status', 'git diff', 'git log -p'])( + 'allows %s when core.hooksPath holds no read-only-triggered hooks', + async (command) => { + const invocation = makeShellTool(huskyRepo).build({ + command, + is_background: false, + }); + expect(await invocation.getDefaultPermission()).toBe('allow'); + }, + ); + it('allows non-git commands even in a dirty repo', async () => { const invocation = makeShellTool(dirtyRepo).build({ command: 'ls -la', diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index ebe26b165cb..454cf4867b6 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -206,9 +206,116 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); - it('flags core.hooksPath overrides (hooks resolve to attacker files)', () => { - const repo = makeRepo('hookspath', '[core]\n\thooksPath = .myhooks\n'); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); + describe('core.hooksPath overrides', () => { + function writeExecutableHook(hooksDir: string, hook: string): void { + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, hook); + fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); + fs.chmodSync(hookPath, 0o755); + } + + it('flags relative overrides pointing at executable trigger hooks', () => { + const repo = makeRepo( + 'hookspath-dirty', + '[core]\n\thooksPath = .myhooks\n', + ); + writeExecutableHook(path.join(repo, '.myhooks'), 'post-index-change'); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('keeps husky-style overrides read-only when no trigger hooks exist', () => { + // husky / lefthook installs set core.hooksPath in every repo; their + // hook dirs hold commit-time hooks only, so whitelisted read-only + // commands must keep their auto-approval. + const repo = makeRepo('husky', '[core]\n\thooksPath = .husky/_\n'); + const hooksDir = path.join(repo, '.husky', '_'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'pre-commit'), '#!/bin/sh\n', { + mode: 0o755, + }); + fs.writeFileSync(path.join(hooksDir, 'commit-msg'), '#!/bin/sh\n', { + mode: 0o755, + }); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it('resolves relative overrides against the worktree root, not the cwd', () => { + const repo = makeRepo( + 'hookspath-root', + '[core]\n\thooksPath = hooks-dir\n', + ); + writeExecutableHook(path.join(repo, 'hooks-dir'), 'fsmonitor-watchman'); + // Decoy with the same name below the probe's cwd: git never consults + // it, so its emptiness must not hide the root hit. + const nested = path.join(repo, 'sub'); + fs.mkdirSync(path.join(nested, 'hooks-dir'), { recursive: true }); + expect(gitConfigMayExecutePrograms(nested)).toBe(true); + }); + + it('flags absolute overrides pointing at executable trigger hooks', () => { + const external = path.join(root, 'external-hooks'); + writeExecutableHook(external, 'post-index-change'); + const repo = makeRepo( + 'hookspath-abs', + `[core]\n\thooksPath = ${external}\n`, + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('expands a leading ~ to the user home', () => { + writeExecutableHook(path.join(root, 'home-hooks'), 'post-index-change'); + const homedir = vi.spyOn(os, 'homedir').mockReturnValue(root); + try { + const repo = makeRepo( + 'hookspath-tilde', + '[core]\n\thooksPath = ~/home-hooks\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + } finally { + homedir.mockRestore(); + } + }); + + it('does not flag an empty override (git then runs no hooks at all)', () => { + const repo = makeRepo('hookspath-empty', '[core]\n\thooksPath =\n'); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it('fails closed on undecodable override values', () => { + const repo = makeRepo( + 'hookspath-bad', + '[core]\n\thooksPath = "unterminated\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + it('fails closed on ~user overrides it cannot resolve', () => { + const repo = makeRepo( + 'hookspath-user', + '[core]\n\thooksPath = ~other/hooks\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(true); + }); + + // fs.accessSync(X_OK) is not meaningful on Windows — every file is + // "executable" there — so only assert the negative case elsewhere. + it.skipIf(process.platform === 'win32')( + 'does not flag non-executable trigger hooks under an override', + () => { + const repo = makeRepo( + 'hookspath-noexec', + '[core]\n\thooksPath = .myhooks\n', + ); + const hooksDir = path.join(repo, '.myhooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'post-index-change'), + '#!/bin/sh\ntouch /tmp/evil\n', + ); + fs.chmodSync(path.join(hooksDir, 'post-index-change'), 0o644); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }, + ); }); it('flags executable hooks that read-only commands trigger', () => { diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 2ef6d4ddb74..a961d581b83 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -22,8 +22,9 @@ * URLs, `protocol..allow` lifts, `core.gitProxy` — `remote * show` network/transport helpers * - `gpg.program` — signature verification helpers - * - `core.hooksPath` — redirects hook resolution; the default hooks - * directory is also probed for hooks that read-only commands fire + * - `core.hooksPath` — redirects hook resolution; the redirected + * directory is probed for read-only-triggered hooks the same way as + * the default hooks directory * * A `.git/config` planted by an attacker (prompt-injection chain with local * file write, shared workspace) could therefore turn an auto-approved @@ -52,6 +53,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; /** Options accepted by the read-only classifiers. */ @@ -87,7 +89,6 @@ const PROGRAM_VALUED_KEYS = new Set([ 'core.askpass', // credential prompts (e.g. `git remote show `) 'core.fsmonitor', // fsmonitor hook command (`git status`) 'core.gitproxy', // git:// transport proxy (`git remote show git://…`) - 'core.hookspath', // redirects hook resolution to attacker-chosen files 'core.pager', // pager program for log / show / diff output 'core.sshcommand', // ssh override for authenticated remotes 'credential.helper', // credential helpers during network auth @@ -378,6 +379,45 @@ function hooksMayExecutePrograms(hooksDir: string): boolean { return false; } +/** + * Collect the directories `core.hooksPath` entries redirect hook + * resolution to, resolved the way git does: a leading `~` expands to the + * user's home, and relative paths anchor at `root` — the worktree root + * for repositories found through a `.git` entry, the git dir itself when + * the probe stands in one. An empty value disables hooks entirely and + * contributes nothing. Returns `null` when a value cannot be decoded or + * resolved — callers fail closed. + */ +function hooksPathDirectories( + entries: ConfigEntry[], + root: string, +): string[] | null { + const dirs: string[] = []; + for (const entry of entries) { + if ( + entry.section !== 'core' || + entry.subsection !== null || + entry.key !== 'hookspath' + ) { + continue; + } + const value = decodeGitConfigValue(entry.value); + if (value === null) return null; + if (value === '') continue; // git runs no hooks at all + let expanded = value; + if (expanded.startsWith('~')) { + // git expands `~` and `~/...` to the user's home; `~user` lookups + // cannot be reproduced here. + if (expanded.length > 1 && expanded[1] !== '/') return null; + expanded = path.join(os.homedir(), expanded.slice(1)); + } + dirs.push( + path.isAbsolute(expanded) ? expanded : path.resolve(root, expanded), + ); + } + return dirs; +} + function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { let enabled = false; for (const entry of entries) { @@ -435,10 +475,18 @@ function isGitDirectory(dir: string): boolean { * - `cwd` itself (or an ancestor) is a git directory → its `config`, the * commondir `config` when present, and `config.worktree`. * + * Also reports the directory relative `core.hooksPath` values resolve + * against — git anchors them at the worktree root (the directory holding + * the `.git` entry); when the probe stands in a git directory itself, the + * git dir stands in. + * * Throws when the search cannot conclude (unreadable pointer, search depth * exhausted) — the caller converts that into "may execute programs". */ -function findLocalGitConfigFiles(cwd: string): string[] { +function findLocalGitConfigFiles(cwd: string): { + files: string[]; + hooksPathRoot: string; +} { let dir = path.resolve(cwd); try { // git resolves the physical cwd; a symlink between the execution @@ -483,7 +531,7 @@ function findLocalGitConfigFiles(cwd: string): string[] { // No commondir — the repo's own config is the common config. } files.push(path.join(gitPath, 'config.worktree')); - return files; + return { files, hooksPathRoot: dir }; } if (stat.isFile()) { let pointer: string; @@ -512,7 +560,7 @@ function findLocalGitConfigFiles(cwd: string): string[] { // Submodule git dir (no commondir) — the two paths above suffice. } files.push(path.join(gitDir, 'config.worktree')); - return files; + return { files, hooksPathRoot: dir }; } } @@ -529,11 +577,12 @@ function findLocalGitConfigFiles(cwd: string): string[] { // No commondir — the git dir's own config is the common config. } files.push(path.join(dir, 'config.worktree')); - return files; + return { files, hooksPathRoot: dir }; } const parent = path.dirname(dir); - if (parent === dir) return []; // reached the filesystem root — no repo + // Reached the filesystem root — no repo. + if (parent === dir) return { files: [], hooksPathRoot: dir }; dir = parent; } } @@ -553,7 +602,8 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { try { let readWorktreeConfig = false; const hooksDirs = new Set(); - for (const file of findLocalGitConfigFiles(cwd)) { + const { files, hooksPathRoot } = findLocalGitConfigFiles(cwd); + for (const file of files) { if (path.basename(file) === 'config') { hooksDirs.add(path.join(path.dirname(file), 'hooks')); } @@ -575,6 +625,9 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { } const entries = parseGitConfig(content); if (entriesMayExecutePrograms(entries)) return true; + const redirectedHooksDirs = hooksPathDirectories(entries, hooksPathRoot); + if (redirectedHooksDirs === null) return true; // fail closed + for (const dir of redirectedHooksDirs) hooksDirs.add(dir); readWorktreeConfig ||= worktreeConfigEnabled(entries); } for (const hooksDir of hooksDirs) { From c2fc8b3fade7324e6ba5c3c1f0e738c206e641cd Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 8 Aug 2026 02:38:29 +0000 Subject: [PATCH 11/15] fix(core): probe submodule storage configs in git-config exec probe (#8645) --- .../src/tools/shell-git-config.integ.test.ts | 30 ++ .../core/src/utils/git-config-safety.test.ts | 179 +++++++++++ packages/core/src/utils/git-config-safety.ts | 285 +++++++++++++----- 3 files changed, 420 insertions(+), 74 deletions(-) diff --git a/packages/core/src/tools/shell-git-config.integ.test.ts b/packages/core/src/tools/shell-git-config.integ.test.ts index 361751fc211..db238a43686 100644 --- a/packages/core/src/tools/shell-git-config.integ.test.ts +++ b/packages/core/src/tools/shell-git-config.integ.test.ts @@ -23,6 +23,7 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { let cleanRepo: string; let dirtyRepo: string; let huskyRepo: string; + let submoduledRepo: string; function makeShellTool(targetDir: string): ShellTool { const config = { @@ -63,6 +64,24 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { fs.writeFileSync(path.join(huskyHooks, 'pre-commit'), '#!/bin/sh\n', { mode: 0o755, }); + + // Superproject whose own config is clean but a submodule's STORED + // config executes programs — `git status` / `git diff` run child git + // processes inside submodules that read it. + submoduledRepo = path.join(root, 'super'); + fs.mkdirSync(path.join(submoduledRepo, '.git'), { recursive: true }); + fs.writeFileSync( + path.join(submoduledRepo, '.git', 'config'), + '[core]\n\tbare = false\n', + ); + const moduleDir = path.join(submoduledRepo, '.git', 'modules', 'sub'); + fs.mkdirSync(path.join(moduleDir, 'objects'), { recursive: true }); + fs.mkdirSync(path.join(moduleDir, 'refs'), { recursive: true }); + fs.writeFileSync(path.join(moduleDir, 'HEAD'), 'ref: refs/heads/main\n'); + fs.writeFileSync( + path.join(moduleDir, 'config'), + '[core]\n\tfsmonitor = /tmp/evil\n', + ); }); afterAll(() => { @@ -102,6 +121,17 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { }, ); + it.each(['git status', 'git diff'])( + 'asks for %s when a submodule config executes programs', + async (command) => { + const invocation = makeShellTool(submoduledRepo).build({ + command, + is_background: false, + }); + expect(await invocation.getDefaultPermission()).toBe('ask'); + }, + ); + it('allows non-git commands even in a dirty repo', async () => { const invocation = makeShellTool(dirtyRepo).build({ command: 'ls -la', diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index 454cf4867b6..4b0c5f933f0 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -516,6 +516,185 @@ describe('gitConfigMayExecutePrograms', () => { }); }); + describe('submodule storage dirs', () => { + // `git status` / `git diff` in a superproject run child git processes + // inside each submodule; the children read the config stored under + // `.git/modules/`. The probe must therefore downgrade the + // superproject when ANY storage dir can execute programs. + + function makeModuleDir( + superRepo: string, + relPath: string, + config = '', + ): string { + const moduleDir = path.join( + superRepo, + '.git', + 'modules', + ...relPath.split('/'), + ); + fs.mkdirSync(path.join(moduleDir, 'objects'), { recursive: true }); + fs.mkdirSync(path.join(moduleDir, 'refs'), { recursive: true }); + fs.writeFileSync(path.join(moduleDir, 'HEAD'), 'ref: refs/heads/main\n'); + if (config) { + fs.writeFileSync(path.join(moduleDir, 'config'), config); + } + return moduleDir; + } + + it('keeps a superproject clean when submodule configs are clean', () => { + const superRepo = makeRepo('super-clean', '[core]\n\tbare = false\n'); + makeModuleDir(superRepo, 'sub', '[core]\n\tworktree = ../../../sub\n'); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); + }); + + it('flags executing keys planted in a submodule config', () => { + const superRepo = makeRepo('super-fsmon', '[core]\n\tbare = false\n'); + makeModuleDir(superRepo, 'sub', '[core]\n\tfsmonitor = /tmp/evil\n'); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + // The downgrade also applies from a nested cwd in the superproject. + const nested = path.join(superRepo, 'src', 'deep'); + fs.mkdirSync(nested, { recursive: true }); + expect(gitConfigMayExecutePrograms(nested)).toBe(true); + }); + + it('flags nested submodule storage dirs', () => { + const superRepo = makeRepo('super-nested', ''); + makeModuleDir(superRepo, 'a', '[core]\n'); + makeModuleDir( + superRepo, + 'a/modules/b', + '[diff]\n\texternal = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + }); + + it('flags executable trigger hooks in a submodule hooks dir', () => { + const superRepo = makeRepo('super-hook', ''); + const moduleDir = makeModuleDir(superRepo, 'sub', '[core]\n'); + const hooksDir = path.join(moduleDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, 'post-index-change'); + fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); + fs.chmodSync(hookPath, 0o755); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + }); + + // fs.accessSync(X_OK) is not meaningful on Windows — every file is + // "executable" there — so only assert the negative case elsewhere. + it.skipIf(process.platform === 'win32')( + 'does not flag non-executable submodule hooks', + () => { + const superRepo = makeRepo('super-hook-noexec', ''); + const moduleDir = makeModuleDir(superRepo, 'sub', '[core]\n'); + const hooksDir = path.join(moduleDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'post-index-change'), + '#!/bin/sh\n', + ); + fs.chmodSync(path.join(hooksDir, 'post-index-change'), 0o644); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); + }, + ); + + it('resolves submodule hooksPath overrides via core.worktree', () => { + // git anchors a relative hooksPath at the submodule WORKTREE root, + // which the stored config records as core.worktree (relative to the + // storage dir). + const superRepo = makeRepo('super-hookspath', ''); + fs.mkdirSync(path.join(superRepo, 'sub'), { recursive: true }); + makeModuleDir( + superRepo, + 'sub', + '[core]\n\tworktree = ../../../sub\n\thooksPath = ../hooks-dir\n', + ); + const hooksDir = path.join(superRepo, 'hooks-dir'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, 'fsmonitor-watchman'); + fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); + fs.chmodSync(hookPath, 0o755); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + }); + + it('keeps submodule hooksPath overrides read-only without trigger hooks', () => { + const superRepo = makeRepo('super-hookspath-clean', ''); + fs.mkdirSync(path.join(superRepo, 'sub'), { recursive: true }); + makeModuleDir( + superRepo, + 'sub', + '[core]\n\tworktree = ../../../sub\n\thooksPath = ../hooks-dir\n', + ); + const hooksDir = path.join(superRepo, 'hooks-dir'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(path.join(hooksDir, 'pre-commit'), '#!/bin/sh\n', { + mode: 0o755, + }); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); + }); + + it('anchors submodule hooksPath at the LAST core.worktree (git semantics)', () => { + const superRepo = makeRepo('super-worktree-last', ''); + fs.mkdirSync(path.join(superRepo, 'a', 'real'), { recursive: true }); + fs.mkdirSync(path.join(superRepo, 'b', 'decoy'), { recursive: true }); + makeModuleDir( + superRepo, + 'sub', + '[core]\n\tworktree = ../../../b/decoy\n\tworktree = ../../../a/real\n\thooksPath = ../hooks\n', + ); + // Executable trigger hook only under the LAST worktree's resolution. + const hooksDir = path.join(superRepo, 'a', 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, 'post-index-change'); + fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); + fs.chmodSync(hookPath, 0o755); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + }); + + it('fails closed on relative submodule hooksPath without core.worktree', () => { + // Without a recorded worktree root the redirect target cannot be + // resolved — confirm instead of guessing. + const superRepo = makeRepo('super-hookspath-nowt', ''); + makeModuleDir(superRepo, 'sub', '[core]\n\thooksPath = ../hooks-dir\n'); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + }); + + it('probes absolute submodule hooksPath overrides', () => { + const superRepo = makeRepo('super-hookspath-abs', ''); + const external = path.join(root, 'sub-external-hooks'); + fs.mkdirSync(external, { recursive: true }); + const hookPath = path.join(external, 'post-index-change'); + fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); + fs.chmodSync(hookPath, 0o755); + makeModuleDir(superRepo, 'sub', `[core]\n\thooksPath = ${external}\n`); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + }); + + it('ignores modules entries that are not git directories', () => { + // git cannot run a child process in a directory it does not accept + // as a git directory, so its contents never execute. + const superRepo = makeRepo('super-notgit', ''); + const stray = path.join(superRepo, '.git', 'modules', 'stray'); + fs.mkdirSync(stray, { recursive: true }); + fs.writeFileSync( + path.join(stray, 'config'), + '[diff]\n\texternal = /tmp/evil\n', + ); + expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); + }); + + it('fails closed when the submodule budget is exhausted', () => { + // All configs CLEAN: discovery would return false, so the `true` + // verdict uniquely pins the budget path (a raised/removed cap would + // otherwise walk every dir and read clean configs undetected). + const superRepo = makeRepo('super-budget', ''); + for (let i = 0; i <= 256; i++) { + makeModuleDir(superRepo, `sub-${i}`, '[core]\n'); + } + expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); + }); + }); + it('fails closed when the config exists but cannot be read', () => { const repo = path.join(root, 'unreadable'); fs.mkdirSync(path.join(repo, '.git', 'config'), { recursive: true }); diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index a961d581b83..9fbf6b25e49 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -32,9 +32,16 @@ * * Scope: repository-local config only (`.git/config`, `config.worktree` * where git reads it — the main checkout under `extensions.worktreeConfig` - * and linked worktrees — and the common-dir config of linked worktrees). - * Global/system config is the user's own deliberate setup and is not an - * attack surface of cloned repositories — it is intentionally not probed. + * and linked worktrees — and the common-dir config of linked worktrees), + * plus submodule storage dirs (`.git/modules/**`): whitelisted commands + * like `status` and `diff` recurse into submodules, and the child git + * processes read the stored submodule config and hooks. Global/system + * config is the user's own deliberate setup and is not an attack surface + * of cloned repositories — it is intentionally not probed. + * + * The probe gates the default-permission path only: a static allow rule + * matching a git command is granted before default resolution and never + * reaches this check (time-of-grant vs. time-of-use). * * Discovery mirrors git's: each ancestor is checked for a `.git` entry, and * the directory itself is checked as a git directory (bare repositories and @@ -79,6 +86,12 @@ const MAX_REPO_SEARCH_DEPTH = 64; /** Config files larger than this fail closed instead of being read. */ const MAX_CONFIG_FILE_BYTES = 1 << 20; // 1 MiB +/** + * Bound on submodule storage dirs probed under the common dir; exceeding + * it fails closed instead of completing the walk. + */ +const MAX_MODULE_GIT_DIRS = 256; + /** * Flat `section.key` names (lowercased — git config names are * case-insensitive) whose value names a program git may execute while @@ -379,18 +392,31 @@ function hooksMayExecutePrograms(hooksDir: string): boolean { return false; } +/** + * Expand a leading `~` the way git's pathname config values do: `~` and + * `~/...` go to the user's home; `~user` lookups cannot be reproduced + * here and return null (callers fail closed). + */ +function expandLeadingTilde(value: string): string | null { + if (!value.startsWith('~')) return value; + if (value.length > 1 && value[1] !== '/') return null; + return path.join(os.homedir(), value.slice(1)); +} + /** * Collect the directories `core.hooksPath` entries redirect hook * resolution to, resolved the way git does: a leading `~` expands to the * user's home, and relative paths anchor at `root` — the worktree root - * for repositories found through a `.git` entry, the git dir itself when - * the probe stands in one. An empty value disables hooks entirely and + * for repositories found through a `.git` entry, the submodule worktree + * (via `core.worktree`) for submodule storage dirs. A `null` root means + * the worktree is unknown: absolute and `~` paths still resolve, a + * relative path fails closed. An empty value disables hooks entirely and * contributes nothing. Returns `null` when a value cannot be decoded or * resolved — callers fail closed. */ function hooksPathDirectories( entries: ConfigEntry[], - root: string, + root: string | null, ): string[] | null { const dirs: string[] = []; for (const entry of entries) { @@ -404,20 +430,52 @@ function hooksPathDirectories( const value = decodeGitConfigValue(entry.value); if (value === null) return null; if (value === '') continue; // git runs no hooks at all - let expanded = value; - if (expanded.startsWith('~')) { - // git expands `~` and `~/...` to the user's home; `~user` lookups - // cannot be reproduced here. - if (expanded.length > 1 && expanded[1] !== '/') return null; - expanded = path.join(os.homedir(), expanded.slice(1)); + const expanded = expandLeadingTilde(value); + if (expanded === null) return null; + if (path.isAbsolute(expanded)) { + dirs.push(expanded); + continue; } - dirs.push( - path.isAbsolute(expanded) ? expanded : path.resolve(root, expanded), - ); + if (root === null) return null; + dirs.push(path.resolve(root, expanded)); } return dirs; } +/** + * The worktree root of a submodule storage dir, recorded by git as + * `core.worktree` in the stored config when it creates + * `.git/modules/` (relative values resolve against the storage + * dir). Relative `core.hooksPath` entries in that config anchor here. + * Returns null when the entry is absent or undecodable — callers fail + * closed on relative hook paths then. + */ +function coreWorktreeRoot( + entries: ConfigEntry[], + storageDir: string, +): string | null { + let root: string | null = null; + for (const entry of entries) { + if ( + entry.section !== 'core' || + entry.subsection !== null || + entry.key !== 'worktree' + ) { + continue; + } + // Git's last-value-wins semantics: keep scanning past earlier entries. + root = null; + const value = decodeGitConfigValue(entry.value); + if (value === null || value === '') continue; + const expanded = expandLeadingTilde(value); + if (expanded === null) continue; + root = path.isAbsolute(expanded) + ? expanded + : path.resolve(storageDir, expanded); + } + return root; +} + function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { let enabled = false; for (const entry of entries) { @@ -465,6 +523,33 @@ function isGitDirectory(dir: string): boolean { } } +/** + * The config files a git directory reads — its own `config`, the + * commondir `config` when a `commondir` file redirects it, and + * `config.worktree` — plus the effective common dir, which is where + * submodule storage dirs live. + */ +function configFilesInGitDir(gitDir: string): { + files: string[]; + commonDir: string; +} { + const files = [path.join(gitDir, 'config')]; + let commonDir = gitDir; + try { + const pointed = fs + .readFileSync(path.join(gitDir, 'commondir'), 'utf8') + .trim(); + if (pointed) { + commonDir = path.resolve(gitDir, pointed); + files.push(path.join(commonDir, 'config')); + } + } catch { + // No commondir — the git dir's own config is the common config. + } + files.push(path.join(gitDir, 'config.worktree')); + return { files, commonDir }; +} + /** * Locate the repository-local config files for the repo enclosing `cwd`: * @@ -478,7 +563,8 @@ function isGitDirectory(dir: string): boolean { * Also reports the directory relative `core.hooksPath` values resolve * against — git anchors them at the worktree root (the directory holding * the `.git` entry); when the probe stands in a git directory itself, the - * git dir stands in. + * git dir stands in — and the repo's common dir, where submodule storage + * dirs live (null when no repo was found). * * Throws when the search cannot conclude (unreadable pointer, search depth * exhausted) — the caller converts that into "may execute programs". @@ -486,6 +572,7 @@ function isGitDirectory(dir: string): boolean { function findLocalGitConfigFiles(cwd: string): { files: string[]; hooksPathRoot: string; + commonDir: string | null; } { let dir = path.resolve(cwd); try { @@ -519,19 +606,8 @@ function findLocalGitConfigFiles(cwd: string): { // `config.worktree` for the MAIN worktree — probe both. A // `commondir` file redirects the common config git reads, so // probe the pointed-to directory's config as well. - const files = [path.join(gitPath, 'config')]; - try { - const commonDir = fs - .readFileSync(path.join(gitPath, 'commondir'), 'utf8') - .trim(); - if (commonDir) { - files.push(path.join(path.resolve(gitPath, commonDir), 'config')); - } - } catch { - // No commondir — the repo's own config is the common config. - } - files.push(path.join(gitPath, 'config.worktree')); - return { files, hooksPathRoot: dir }; + const { files, commonDir } = configFilesInGitDir(gitPath); + return { files, hooksPathRoot: dir, commonDir }; } if (stat.isFile()) { let pointer: string; @@ -548,45 +624,124 @@ function findLocalGitConfigFiles(cwd: string): { throw new Error(`unparseable git pointer file: ${gitPath}`); } const gitDir = path.resolve(dir, match[1]!); + let commonDir = gitDir; const files = [path.join(gitDir, 'config')]; try { - const commonDir = fs + const pointed = fs .readFileSync(path.join(gitDir, 'commondir'), 'utf8') .trim(); - if (commonDir) { - files[0] = path.join(path.resolve(gitDir, commonDir), 'config'); + if (pointed) { + commonDir = path.resolve(gitDir, pointed); + files[0] = path.join(commonDir, 'config'); } } catch { // Submodule git dir (no commondir) — the two paths above suffice. } files.push(path.join(gitDir, 'config.worktree')); - return { files, hooksPathRoot: dir }; + return { files, hooksPathRoot: dir, commonDir }; } } if (isGitDirectory(dir)) { - const files = [path.join(dir, 'config')]; - try { - const commonDir = fs - .readFileSync(path.join(dir, 'commondir'), 'utf8') - .trim(); - if (commonDir) { - files.push(path.join(path.resolve(dir, commonDir), 'config')); - } - } catch { - // No commondir — the git dir's own config is the common config. - } - files.push(path.join(dir, 'config.worktree')); - return { files, hooksPathRoot: dir }; + const { files, commonDir } = configFilesInGitDir(dir); + return { files, hooksPathRoot: dir, commonDir }; } const parent = path.dirname(dir); // Reached the filesystem root — no repo. - if (parent === dir) return { files: [], hooksPathRoot: dir }; + if (parent === dir) { + return { files: [], hooksPathRoot: dir, commonDir: null }; + } dir = parent; } } +/** + * Collect the submodule storage dirs nested under `commonDir` — git keeps + * them at `/modules/`, nested submodules at + * `modules//modules/...`. Only directories git itself accepts as + * git directories qualify; anything else under `modules/` is never read + * by a child git. Throws past MAX_MODULE_GIT_DIRS — the caller fails + * closed. + */ +function collectModuleGitDirs(commonDir: string): string[] { + const found: string[] = []; + const pending: string[] = [path.join(commonDir, 'modules')]; + while (pending.length > 0) { + const modulesDir = pending.pop()!; + let dirents: fs.Dirent[]; + try { + dirents = fs.readdirSync(modulesDir, { withFileTypes: true }); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') continue; + throw err; // exists but unreadable — fail closed + } + for (const dirent of dirents) { + const candidate = path.join(modulesDir, dirent.name); + let stat: fs.Stats; + try { + stat = fs.statSync(candidate); // follows symlinks + } catch { + continue; // dangling entry — git cannot run in it either + } + if (!stat.isDirectory() || !isGitDirectory(candidate)) continue; + if (found.length >= MAX_MODULE_GIT_DIRS) { + throw new Error('too many submodule storage directories'); + } + found.push(candidate); + pending.push(path.join(candidate, 'modules')); + } + } + return found; +} + +/** + * Probe one config group (a repo's own files, or one submodule storage + * dir's): fail closed on executing keys, oversized or unreadable files, + * and unresolvable hooks-path redirects; collect default and redirected + * hook directories into `hooksDirs`. A `null` hooks-path root means the + * worktree is unknown — submodule groups recover it from `core.worktree` + * where git recorded one. + */ +function probeConfigGroup( + files: string[], + hooksPathRoot: string | null, + hooksDirs: Set, +): boolean { + let readWorktreeConfig = false; + let root = hooksPathRoot; + for (const file of files) { + if (path.basename(file) === 'config') { + hooksDirs.add(path.join(path.dirname(file), 'hooks')); + } + if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; + try { + if (fs.statSync(file).size > MAX_CONFIG_FILE_BYTES) { + return true; // implausibly large config — fail closed + } + } catch { + // stat can race with the read below; fall through. + } + let content: string; + try { + content = fs.readFileSync(file, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') continue; + return true; // exists but unreadable — fail closed + } + const entries = parseGitConfig(content); + if (entriesMayExecutePrograms(entries)) return true; + if (root === null) root = coreWorktreeRoot(entries, path.dirname(file)); + const redirectedHooksDirs = hooksPathDirectories(entries, root); + if (redirectedHooksDirs === null) return true; // fail closed + for (const dir of redirectedHooksDirs) hooksDirs.add(dir); + readWorktreeConfig ||= worktreeConfigEnabled(entries); + } + return false; +} + /** * True when the repository-local git config reachable from `cwd` contains * keys that make git execute a program while running a whitelisted @@ -600,35 +755,17 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { if (!cwd) return false; try { - let readWorktreeConfig = false; const hooksDirs = new Set(); - const { files, hooksPathRoot } = findLocalGitConfigFiles(cwd); - for (const file of files) { - if (path.basename(file) === 'config') { - hooksDirs.add(path.join(path.dirname(file), 'hooks')); - } - if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; - try { - if (fs.statSync(file).size > MAX_CONFIG_FILE_BYTES) { - return true; // implausibly large config — fail closed - } - } catch { - // stat can race with the read below; fall through. - } - let content: string; - try { - content = fs.readFileSync(file, 'utf8'); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') continue; - return true; // exists but unreadable — fail closed + const { files, hooksPathRoot, commonDir } = findLocalGitConfigFiles(cwd); + if (probeConfigGroup(files, hooksPathRoot, hooksDirs)) return true; + if (commonDir !== null) { + // `status`, `diff`, and friends recurse into submodules: the child + // git processes read the stored submodule config and hooks, so a + // clean superproject config does not clear them. + for (const moduleDir of collectModuleGitDirs(commonDir)) { + const { files: moduleFiles } = configFilesInGitDir(moduleDir); + if (probeConfigGroup(moduleFiles, null, hooksDirs)) return true; } - const entries = parseGitConfig(content); - if (entriesMayExecutePrograms(entries)) return true; - const redirectedHooksDirs = hooksPathDirectories(entries, hooksPathRoot); - if (redirectedHooksDirs === null) return true; // fail closed - for (const dir of redirectedHooksDirs) hooksDirs.add(dir); - readWorktreeConfig ||= worktreeConfigEnabled(entries); } for (const hooksDir of hooksDirs) { if (hooksMayExecutePrograms(hooksDir)) return true; From bb6000ca73bbe55af20bd0bdc269e4bcccc13f03 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 8 Aug 2026 11:50:51 +0800 Subject: [PATCH 12/15] fix(core): tighten git config safety checks --- .../core/src/core/coreToolScheduler.test.ts | 18 +- packages/core/src/core/coreToolScheduler.ts | 8 +- .../src/tools/shell-git-config.integ.test.ts | 30 -- .../core/src/utils/git-config-safety.test.ts | 194 +----------- packages/core/src/utils/git-config-safety.ts | 295 +++++------------- 5 files changed, 116 insertions(+), 429 deletions(-) diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 93abb9dea23..55ee87e16ae 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -15365,7 +15365,7 @@ describe('Fire hook functions integration', () => { it('treats a read-only shell command as safe and a mutating one as unsafe', () => { expect( isToolCallConcurrencySafe('shell', Kind.Execute, { - command: 'git status', + command: 'ls', }), ).toBe(true); expect( @@ -15375,6 +15375,20 @@ describe('Fire hook functions integration', () => { ).toBe(false); }); + it('uses the shell directory for git config checks and fails closed without one', () => { + expect( + isToolCallConcurrencySafe('shell', Kind.Execute, { + command: 'git status', + directory: os.tmpdir(), + }), + ).toBe(true); + expect( + isToolCallConcurrencySafe('shell', Kind.Execute, { + command: 'git status', + }), + ).toBe(false); + }); + it('treats a shell call with a non-string command as unsafe (fail-closed)', () => { expect(isToolCallConcurrencySafe('shell', Kind.Execute, {})).toBe( false, @@ -15735,7 +15749,7 @@ describe('Fire hook functions integration', () => { { callId: '1', name: 'run_shell_command', - args: { command: 'git log' }, + args: { command: 'git log', directory: os.tmpdir() }, isClientInitiated: false, prompt_id: 'p1', }, diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 2ccfe2ecd72..9a4440d78ba 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -1267,10 +1267,14 @@ export function isToolCallConcurrencySafe( // one) because partitioning runs synchronously. It is deliberately more // conservative than the AST version used for permission decisions. if (kind === Kind.Execute) { - const command = (args as { command?: string } | undefined)?.command; + const { command, directory } = + (args as { command?: string; directory?: string } | undefined) ?? {}; if (typeof command !== 'string') return false; try { - return isShellCommandReadOnly(command); + return isShellCommandReadOnly( + command, + directory ? { cwd: directory } : { unknownDir: true }, + ); } catch { return false; // fail-closed } diff --git a/packages/core/src/tools/shell-git-config.integ.test.ts b/packages/core/src/tools/shell-git-config.integ.test.ts index db238a43686..361751fc211 100644 --- a/packages/core/src/tools/shell-git-config.integ.test.ts +++ b/packages/core/src/tools/shell-git-config.integ.test.ts @@ -23,7 +23,6 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { let cleanRepo: string; let dirtyRepo: string; let huskyRepo: string; - let submoduledRepo: string; function makeShellTool(targetDir: string): ShellTool { const config = { @@ -64,24 +63,6 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { fs.writeFileSync(path.join(huskyHooks, 'pre-commit'), '#!/bin/sh\n', { mode: 0o755, }); - - // Superproject whose own config is clean but a submodule's STORED - // config executes programs — `git status` / `git diff` run child git - // processes inside submodules that read it. - submoduledRepo = path.join(root, 'super'); - fs.mkdirSync(path.join(submoduledRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(submoduledRepo, '.git', 'config'), - '[core]\n\tbare = false\n', - ); - const moduleDir = path.join(submoduledRepo, '.git', 'modules', 'sub'); - fs.mkdirSync(path.join(moduleDir, 'objects'), { recursive: true }); - fs.mkdirSync(path.join(moduleDir, 'refs'), { recursive: true }); - fs.writeFileSync(path.join(moduleDir, 'HEAD'), 'ref: refs/heads/main\n'); - fs.writeFileSync( - path.join(moduleDir, 'config'), - '[core]\n\tfsmonitor = /tmp/evil\n', - ); }); afterAll(() => { @@ -121,17 +102,6 @@ describe('ShellTool git config probe end-to-end (#8575)', () => { }, ); - it.each(['git status', 'git diff'])( - 'asks for %s when a submodule config executes programs', - async (command) => { - const invocation = makeShellTool(submoduledRepo).build({ - command, - is_background: false, - }); - expect(await invocation.getDefaultPermission()).toBe('ask'); - }, - ); - it('allows non-git commands even in a dirty repo', async () => { const invocation = makeShellTool(dirtyRepo).build({ command: 'ls -la', diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index 4b0c5f933f0..ce7c6c9ab17 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -390,7 +390,15 @@ describe('gitConfigMayExecutePrograms', () => { expect(gitConfigMayExecutePrograms(repo)).toBe(true); }); - it('flags protocol..allow lifts of the ext:: transport block', () => { + it('ignores ext:: url subsections without an insteadOf rewrite', () => { + const repo = makeRepo( + 'url-push-insteadof', + '[url "ext::sh -c evil"]\n\tpushInsteadOf = https://example.com/\n', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it('flags protocol allow lifts only when they can enable ext::', () => { const always = makeRepo( 'proto-always', '[protocol "ext"]\n\tallow = always\n', @@ -408,6 +416,11 @@ describe('gitConfigMayExecutePrograms', () => { '[protocol "ext"]\n\tallow = never\n', ); expect(gitConfigMayExecutePrograms(never)).toBe(false); + const file = makeRepo( + 'proto-file', + '[protocol "file"]\n\tallow = always\n', + ); + expect(gitConfigMayExecutePrograms(file)).toBe(false); }); it('does not flag boolean pager overrides', () => { @@ -516,185 +529,6 @@ describe('gitConfigMayExecutePrograms', () => { }); }); - describe('submodule storage dirs', () => { - // `git status` / `git diff` in a superproject run child git processes - // inside each submodule; the children read the config stored under - // `.git/modules/`. The probe must therefore downgrade the - // superproject when ANY storage dir can execute programs. - - function makeModuleDir( - superRepo: string, - relPath: string, - config = '', - ): string { - const moduleDir = path.join( - superRepo, - '.git', - 'modules', - ...relPath.split('/'), - ); - fs.mkdirSync(path.join(moduleDir, 'objects'), { recursive: true }); - fs.mkdirSync(path.join(moduleDir, 'refs'), { recursive: true }); - fs.writeFileSync(path.join(moduleDir, 'HEAD'), 'ref: refs/heads/main\n'); - if (config) { - fs.writeFileSync(path.join(moduleDir, 'config'), config); - } - return moduleDir; - } - - it('keeps a superproject clean when submodule configs are clean', () => { - const superRepo = makeRepo('super-clean', '[core]\n\tbare = false\n'); - makeModuleDir(superRepo, 'sub', '[core]\n\tworktree = ../../../sub\n'); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); - }); - - it('flags executing keys planted in a submodule config', () => { - const superRepo = makeRepo('super-fsmon', '[core]\n\tbare = false\n'); - makeModuleDir(superRepo, 'sub', '[core]\n\tfsmonitor = /tmp/evil\n'); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - // The downgrade also applies from a nested cwd in the superproject. - const nested = path.join(superRepo, 'src', 'deep'); - fs.mkdirSync(nested, { recursive: true }); - expect(gitConfigMayExecutePrograms(nested)).toBe(true); - }); - - it('flags nested submodule storage dirs', () => { - const superRepo = makeRepo('super-nested', ''); - makeModuleDir(superRepo, 'a', '[core]\n'); - makeModuleDir( - superRepo, - 'a/modules/b', - '[diff]\n\texternal = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - }); - - it('flags executable trigger hooks in a submodule hooks dir', () => { - const superRepo = makeRepo('super-hook', ''); - const moduleDir = makeModuleDir(superRepo, 'sub', '[core]\n'); - const hooksDir = path.join(moduleDir, 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - const hookPath = path.join(hooksDir, 'post-index-change'); - fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); - fs.chmodSync(hookPath, 0o755); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - }); - - // fs.accessSync(X_OK) is not meaningful on Windows — every file is - // "executable" there — so only assert the negative case elsewhere. - it.skipIf(process.platform === 'win32')( - 'does not flag non-executable submodule hooks', - () => { - const superRepo = makeRepo('super-hook-noexec', ''); - const moduleDir = makeModuleDir(superRepo, 'sub', '[core]\n'); - const hooksDir = path.join(moduleDir, 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync( - path.join(hooksDir, 'post-index-change'), - '#!/bin/sh\n', - ); - fs.chmodSync(path.join(hooksDir, 'post-index-change'), 0o644); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); - }, - ); - - it('resolves submodule hooksPath overrides via core.worktree', () => { - // git anchors a relative hooksPath at the submodule WORKTREE root, - // which the stored config records as core.worktree (relative to the - // storage dir). - const superRepo = makeRepo('super-hookspath', ''); - fs.mkdirSync(path.join(superRepo, 'sub'), { recursive: true }); - makeModuleDir( - superRepo, - 'sub', - '[core]\n\tworktree = ../../../sub\n\thooksPath = ../hooks-dir\n', - ); - const hooksDir = path.join(superRepo, 'hooks-dir'); - fs.mkdirSync(hooksDir, { recursive: true }); - const hookPath = path.join(hooksDir, 'fsmonitor-watchman'); - fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); - fs.chmodSync(hookPath, 0o755); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - }); - - it('keeps submodule hooksPath overrides read-only without trigger hooks', () => { - const superRepo = makeRepo('super-hookspath-clean', ''); - fs.mkdirSync(path.join(superRepo, 'sub'), { recursive: true }); - makeModuleDir( - superRepo, - 'sub', - '[core]\n\tworktree = ../../../sub\n\thooksPath = ../hooks-dir\n', - ); - const hooksDir = path.join(superRepo, 'hooks-dir'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'pre-commit'), '#!/bin/sh\n', { - mode: 0o755, - }); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); - }); - - it('anchors submodule hooksPath at the LAST core.worktree (git semantics)', () => { - const superRepo = makeRepo('super-worktree-last', ''); - fs.mkdirSync(path.join(superRepo, 'a', 'real'), { recursive: true }); - fs.mkdirSync(path.join(superRepo, 'b', 'decoy'), { recursive: true }); - makeModuleDir( - superRepo, - 'sub', - '[core]\n\tworktree = ../../../b/decoy\n\tworktree = ../../../a/real\n\thooksPath = ../hooks\n', - ); - // Executable trigger hook only under the LAST worktree's resolution. - const hooksDir = path.join(superRepo, 'a', 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - const hookPath = path.join(hooksDir, 'post-index-change'); - fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); - fs.chmodSync(hookPath, 0o755); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - }); - - it('fails closed on relative submodule hooksPath without core.worktree', () => { - // Without a recorded worktree root the redirect target cannot be - // resolved — confirm instead of guessing. - const superRepo = makeRepo('super-hookspath-nowt', ''); - makeModuleDir(superRepo, 'sub', '[core]\n\thooksPath = ../hooks-dir\n'); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - }); - - it('probes absolute submodule hooksPath overrides', () => { - const superRepo = makeRepo('super-hookspath-abs', ''); - const external = path.join(root, 'sub-external-hooks'); - fs.mkdirSync(external, { recursive: true }); - const hookPath = path.join(external, 'post-index-change'); - fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); - fs.chmodSync(hookPath, 0o755); - makeModuleDir(superRepo, 'sub', `[core]\n\thooksPath = ${external}\n`); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - }); - - it('ignores modules entries that are not git directories', () => { - // git cannot run a child process in a directory it does not accept - // as a git directory, so its contents never execute. - const superRepo = makeRepo('super-notgit', ''); - const stray = path.join(superRepo, '.git', 'modules', 'stray'); - fs.mkdirSync(stray, { recursive: true }); - fs.writeFileSync( - path.join(stray, 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(superRepo)).toBe(false); - }); - - it('fails closed when the submodule budget is exhausted', () => { - // All configs CLEAN: discovery would return false, so the `true` - // verdict uniquely pins the budget path (a raised/removed cap would - // otherwise walk every dir and read clean configs undetected). - const superRepo = makeRepo('super-budget', ''); - for (let i = 0; i <= 256; i++) { - makeModuleDir(superRepo, `sub-${i}`, '[core]\n'); - } - expect(gitConfigMayExecutePrograms(superRepo)).toBe(true); - }); - }); - it('fails closed when the config exists but cannot be read', () => { const repo = path.join(root, 'unreadable'); fs.mkdirSync(path.join(repo, '.git', 'config'), { recursive: true }); diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 9fbf6b25e49..77c193751d0 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -32,16 +32,9 @@ * * Scope: repository-local config only (`.git/config`, `config.worktree` * where git reads it — the main checkout under `extensions.worktreeConfig` - * and linked worktrees — and the common-dir config of linked worktrees), - * plus submodule storage dirs (`.git/modules/**`): whitelisted commands - * like `status` and `diff` recurse into submodules, and the child git - * processes read the stored submodule config and hooks. Global/system - * config is the user's own deliberate setup and is not an attack surface - * of cloned repositories — it is intentionally not probed. - * - * The probe gates the default-permission path only: a static allow rule - * matching a git command is granted before default resolution and never - * reaches this check (time-of-grant vs. time-of-use). + * and linked worktrees — and the common-dir config of linked worktrees). + * Global/system config is the user's own deliberate setup and is not an + * attack surface of cloned repositories — it is intentionally not probed. * * Discovery mirrors git's: each ancestor is checked for a `.git` entry, and * the directory itself is checked as a git directory (bare repositories and @@ -86,12 +79,6 @@ const MAX_REPO_SEARCH_DEPTH = 64; /** Config files larger than this fail closed instead of being read. */ const MAX_CONFIG_FILE_BYTES = 1 << 20; // 1 MiB -/** - * Bound on submodule storage dirs probed under the common dir; exceeding - * it fails closed instead of completing the walk. - */ -const MAX_MODULE_GIT_DIRS = 256; - /** * Flat `section.key` names (lowercased — git config names are * case-insensitive) whose value names a program git may execute while @@ -282,18 +269,20 @@ function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { if ( entry.section === 'url' && entry.subsection !== null && + entry.key === 'insteadof' && entry.subsection.replace(/\\/g, '').startsWith('ext::') ) { return true; } - // `protocol.allow` / `protocol..allow` lifts the default block - // on program transports (ext::) — any value that is not definitely - // `never` enables the lift, and a command-line ext:: URL passed to a - // whitelisted command then executes a program. + // `protocol.allow` / `protocol.ext.allow` lifts the default block on + // the program transport — any value that is not definitely `never` + // enables it, and a command-line ext:: URL passed to a whitelisted + // command then executes a program. if ( entry.section === 'protocol' && entry.key === 'allow' && + (entry.subsection === null || entry.subsection === 'ext') && value?.toLowerCase() !== 'never' ) { return true; @@ -392,31 +381,18 @@ function hooksMayExecutePrograms(hooksDir: string): boolean { return false; } -/** - * Expand a leading `~` the way git's pathname config values do: `~` and - * `~/...` go to the user's home; `~user` lookups cannot be reproduced - * here and return null (callers fail closed). - */ -function expandLeadingTilde(value: string): string | null { - if (!value.startsWith('~')) return value; - if (value.length > 1 && value[1] !== '/') return null; - return path.join(os.homedir(), value.slice(1)); -} - /** * Collect the directories `core.hooksPath` entries redirect hook * resolution to, resolved the way git does: a leading `~` expands to the * user's home, and relative paths anchor at `root` — the worktree root - * for repositories found through a `.git` entry, the submodule worktree - * (via `core.worktree`) for submodule storage dirs. A `null` root means - * the worktree is unknown: absolute and `~` paths still resolve, a - * relative path fails closed. An empty value disables hooks entirely and + * for repositories found through a `.git` entry, the git dir itself when + * the probe stands in one. An empty value disables hooks entirely and * contributes nothing. Returns `null` when a value cannot be decoded or * resolved — callers fail closed. */ function hooksPathDirectories( entries: ConfigEntry[], - root: string | null, + root: string, ): string[] | null { const dirs: string[] = []; for (const entry of entries) { @@ -430,52 +406,20 @@ function hooksPathDirectories( const value = decodeGitConfigValue(entry.value); if (value === null) return null; if (value === '') continue; // git runs no hooks at all - const expanded = expandLeadingTilde(value); - if (expanded === null) return null; - if (path.isAbsolute(expanded)) { - dirs.push(expanded); - continue; + let expanded = value; + if (expanded.startsWith('~')) { + // git expands `~` and `~/...` to the user's home; `~user` lookups + // cannot be reproduced here. + if (expanded.length > 1 && expanded[1] !== '/') return null; + expanded = path.join(os.homedir(), expanded.slice(1)); } - if (root === null) return null; - dirs.push(path.resolve(root, expanded)); + dirs.push( + path.isAbsolute(expanded) ? expanded : path.resolve(root, expanded), + ); } return dirs; } -/** - * The worktree root of a submodule storage dir, recorded by git as - * `core.worktree` in the stored config when it creates - * `.git/modules/` (relative values resolve against the storage - * dir). Relative `core.hooksPath` entries in that config anchor here. - * Returns null when the entry is absent or undecodable — callers fail - * closed on relative hook paths then. - */ -function coreWorktreeRoot( - entries: ConfigEntry[], - storageDir: string, -): string | null { - let root: string | null = null; - for (const entry of entries) { - if ( - entry.section !== 'core' || - entry.subsection !== null || - entry.key !== 'worktree' - ) { - continue; - } - // Git's last-value-wins semantics: keep scanning past earlier entries. - root = null; - const value = decodeGitConfigValue(entry.value); - if (value === null || value === '') continue; - const expanded = expandLeadingTilde(value); - if (expanded === null) continue; - root = path.isAbsolute(expanded) - ? expanded - : path.resolve(storageDir, expanded); - } - return root; -} - function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { let enabled = false; for (const entry of entries) { @@ -523,33 +467,6 @@ function isGitDirectory(dir: string): boolean { } } -/** - * The config files a git directory reads — its own `config`, the - * commondir `config` when a `commondir` file redirects it, and - * `config.worktree` — plus the effective common dir, which is where - * submodule storage dirs live. - */ -function configFilesInGitDir(gitDir: string): { - files: string[]; - commonDir: string; -} { - const files = [path.join(gitDir, 'config')]; - let commonDir = gitDir; - try { - const pointed = fs - .readFileSync(path.join(gitDir, 'commondir'), 'utf8') - .trim(); - if (pointed) { - commonDir = path.resolve(gitDir, pointed); - files.push(path.join(commonDir, 'config')); - } - } catch { - // No commondir — the git dir's own config is the common config. - } - files.push(path.join(gitDir, 'config.worktree')); - return { files, commonDir }; -} - /** * Locate the repository-local config files for the repo enclosing `cwd`: * @@ -563,8 +480,7 @@ function configFilesInGitDir(gitDir: string): { * Also reports the directory relative `core.hooksPath` values resolve * against — git anchors them at the worktree root (the directory holding * the `.git` entry); when the probe stands in a git directory itself, the - * git dir stands in — and the repo's common dir, where submodule storage - * dirs live (null when no repo was found). + * git dir stands in. * * Throws when the search cannot conclude (unreadable pointer, search depth * exhausted) — the caller converts that into "may execute programs". @@ -572,7 +488,6 @@ function configFilesInGitDir(gitDir: string): { function findLocalGitConfigFiles(cwd: string): { files: string[]; hooksPathRoot: string; - commonDir: string | null; } { let dir = path.resolve(cwd); try { @@ -606,8 +521,19 @@ function findLocalGitConfigFiles(cwd: string): { // `config.worktree` for the MAIN worktree — probe both. A // `commondir` file redirects the common config git reads, so // probe the pointed-to directory's config as well. - const { files, commonDir } = configFilesInGitDir(gitPath); - return { files, hooksPathRoot: dir, commonDir }; + const files = [path.join(gitPath, 'config')]; + try { + const commonDir = fs + .readFileSync(path.join(gitPath, 'commondir'), 'utf8') + .trim(); + if (commonDir) { + files.push(path.join(path.resolve(gitPath, commonDir), 'config')); + } + } catch { + // No commondir — the repo's own config is the common config. + } + files.push(path.join(gitPath, 'config.worktree')); + return { files, hooksPathRoot: dir }; } if (stat.isFile()) { let pointer: string; @@ -624,122 +550,43 @@ function findLocalGitConfigFiles(cwd: string): { throw new Error(`unparseable git pointer file: ${gitPath}`); } const gitDir = path.resolve(dir, match[1]!); - let commonDir = gitDir; const files = [path.join(gitDir, 'config')]; try { - const pointed = fs + const commonDir = fs .readFileSync(path.join(gitDir, 'commondir'), 'utf8') .trim(); - if (pointed) { - commonDir = path.resolve(gitDir, pointed); - files[0] = path.join(commonDir, 'config'); + if (commonDir) { + files[0] = path.join(path.resolve(gitDir, commonDir), 'config'); } } catch { // Submodule git dir (no commondir) — the two paths above suffice. } files.push(path.join(gitDir, 'config.worktree')); - return { files, hooksPathRoot: dir, commonDir }; + return { files, hooksPathRoot: dir }; } } if (isGitDirectory(dir)) { - const { files, commonDir } = configFilesInGitDir(dir); - return { files, hooksPathRoot: dir, commonDir }; - } - - const parent = path.dirname(dir); - // Reached the filesystem root — no repo. - if (parent === dir) { - return { files: [], hooksPathRoot: dir, commonDir: null }; - } - dir = parent; - } -} - -/** - * Collect the submodule storage dirs nested under `commonDir` — git keeps - * them at `/modules/`, nested submodules at - * `modules//modules/...`. Only directories git itself accepts as - * git directories qualify; anything else under `modules/` is never read - * by a child git. Throws past MAX_MODULE_GIT_DIRS — the caller fails - * closed. - */ -function collectModuleGitDirs(commonDir: string): string[] { - const found: string[] = []; - const pending: string[] = [path.join(commonDir, 'modules')]; - while (pending.length > 0) { - const modulesDir = pending.pop()!; - let dirents: fs.Dirent[]; - try { - dirents = fs.readdirSync(modulesDir, { withFileTypes: true }); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') continue; - throw err; // exists but unreadable — fail closed - } - for (const dirent of dirents) { - const candidate = path.join(modulesDir, dirent.name); - let stat: fs.Stats; + const files = [path.join(dir, 'config')]; try { - stat = fs.statSync(candidate); // follows symlinks + const commonDir = fs + .readFileSync(path.join(dir, 'commondir'), 'utf8') + .trim(); + if (commonDir) { + files.push(path.join(path.resolve(dir, commonDir), 'config')); + } } catch { - continue; // dangling entry — git cannot run in it either + // No commondir — the git dir's own config is the common config. } - if (!stat.isDirectory() || !isGitDirectory(candidate)) continue; - if (found.length >= MAX_MODULE_GIT_DIRS) { - throw new Error('too many submodule storage directories'); - } - found.push(candidate); - pending.push(path.join(candidate, 'modules')); + files.push(path.join(dir, 'config.worktree')); + return { files, hooksPathRoot: dir }; } - } - return found; -} -/** - * Probe one config group (a repo's own files, or one submodule storage - * dir's): fail closed on executing keys, oversized or unreadable files, - * and unresolvable hooks-path redirects; collect default and redirected - * hook directories into `hooksDirs`. A `null` hooks-path root means the - * worktree is unknown — submodule groups recover it from `core.worktree` - * where git recorded one. - */ -function probeConfigGroup( - files: string[], - hooksPathRoot: string | null, - hooksDirs: Set, -): boolean { - let readWorktreeConfig = false; - let root = hooksPathRoot; - for (const file of files) { - if (path.basename(file) === 'config') { - hooksDirs.add(path.join(path.dirname(file), 'hooks')); - } - if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; - try { - if (fs.statSync(file).size > MAX_CONFIG_FILE_BYTES) { - return true; // implausibly large config — fail closed - } - } catch { - // stat can race with the read below; fall through. - } - let content: string; - try { - content = fs.readFileSync(file, 'utf8'); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') continue; - return true; // exists but unreadable — fail closed - } - const entries = parseGitConfig(content); - if (entriesMayExecutePrograms(entries)) return true; - if (root === null) root = coreWorktreeRoot(entries, path.dirname(file)); - const redirectedHooksDirs = hooksPathDirectories(entries, root); - if (redirectedHooksDirs === null) return true; // fail closed - for (const dir of redirectedHooksDirs) hooksDirs.add(dir); - readWorktreeConfig ||= worktreeConfigEnabled(entries); + const parent = path.dirname(dir); + // Reached the filesystem root — no repo. + if (parent === dir) return { files: [], hooksPathRoot: dir }; + dir = parent; } - return false; } /** @@ -755,17 +602,35 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { if (!cwd) return false; try { + let readWorktreeConfig = false; const hooksDirs = new Set(); - const { files, hooksPathRoot, commonDir } = findLocalGitConfigFiles(cwd); - if (probeConfigGroup(files, hooksPathRoot, hooksDirs)) return true; - if (commonDir !== null) { - // `status`, `diff`, and friends recurse into submodules: the child - // git processes read the stored submodule config and hooks, so a - // clean superproject config does not clear them. - for (const moduleDir of collectModuleGitDirs(commonDir)) { - const { files: moduleFiles } = configFilesInGitDir(moduleDir); - if (probeConfigGroup(moduleFiles, null, hooksDirs)) return true; + const { files, hooksPathRoot } = findLocalGitConfigFiles(cwd); + for (const file of files) { + if (path.basename(file) === 'config') { + hooksDirs.add(path.join(path.dirname(file), 'hooks')); + } + if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; + try { + if (fs.statSync(file).size > MAX_CONFIG_FILE_BYTES) { + return true; // implausibly large config — fail closed + } + } catch { + // stat can race with the read below; fall through. + } + let content: string; + try { + content = fs.readFileSync(file, 'utf8'); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') continue; + return true; // exists but unreadable — fail closed } + const entries = parseGitConfig(content); + if (entriesMayExecutePrograms(entries)) return true; + const redirectedHooksDirs = hooksPathDirectories(entries, hooksPathRoot); + if (redirectedHooksDirs === null) return true; // fail closed + for (const dir of redirectedHooksDirs) hooksDirs.add(dir); + readWorktreeConfig ||= worktreeConfigEnabled(entries); } for (const hooksDir of hooksDirs) { if (hooksMayExecutePrograms(hooksDir)) return true; From c04dd2993293f8089c850167ee62ea7d7be1696f Mon Sep 17 00:00:00 2001 From: qwen-code-dev-bot Date: Sat, 8 Aug 2026 04:45:53 +0000 Subject: [PATCH 13/15] fix(core): address verification findings for git-config exec probe (#8645) --- .../core/src/utils/git-config-safety.test.ts | 21 ++++++++++ packages/core/src/utils/git-config-safety.ts | 16 ++++++- .../core/src/utils/shellAstParser.test.ts | 26 ++++++++++++ .../src/utils/shellReadOnlyChecker.test.ts | 42 +++++++++++++++++++ .../core/src/utils/shellReadOnlyChecker.ts | 17 +++++++- 5 files changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts index ce7c6c9ab17..7de4f754939 100644 --- a/packages/core/src/utils/git-config-safety.test.ts +++ b/packages/core/src/utils/git-config-safety.test.ts @@ -279,6 +279,27 @@ describe('gitConfigMayExecutePrograms', () => { it('does not flag an empty override (git then runs no hooks at all)', () => { const repo = makeRepo('hookspath-empty', '[core]\n\thooksPath =\n'); expect(gitConfigMayExecutePrograms(repo)).toBe(false); + // An executable default hook changes nothing: an empty core.hooksPath + // disables hooks entirely, so git never consults `.git/hooks`. + writeExecutableHook( + path.join(repo, '.git', 'hooks'), + 'post-index-change', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); + }); + + it('ignores the default hooks directory under a non-empty override', () => { + // core.hooksPath redirects hook lookup — the default hooks directory + // is never consulted, whatever it contains. + const repo = makeRepo( + 'hookspath-defaults', + '[core]\n\thooksPath = .myhooks\n', + ); + writeExecutableHook( + path.join(repo, '.git', 'hooks'), + 'post-index-change', + ); + expect(gitConfigMayExecutePrograms(repo)).toBe(false); }); it('fails closed on undecodable override values', () => { diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts index 77c193751d0..767c205b297 100644 --- a/packages/core/src/utils/git-config-safety.ts +++ b/packages/core/src/utils/git-config-safety.ts @@ -49,7 +49,8 @@ * moves the probe to the repository the following git segments actually run * in, and an unresolvable target (expansions, `~`, flag-only forms, a * `||`-diverged chain) downgrades later git segments the same way a dirty - * config does. + * config does — including when the classifier was called without a cwd, + * since the effective repository is then unknown. */ import fs from 'node:fs'; @@ -627,6 +628,19 @@ export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { } const entries = parseGitConfig(content); if (entriesMayExecutePrograms(entries)) return true; + // A core.hooksPath entry redirects hook lookup away from this + // config's default hooks directory — or, when empty, disables hooks + // entirely, so git runs no hooks at all. + if ( + entries.some( + (e) => + e.section === 'core' && + e.subsection === null && + e.key === 'hookspath', + ) + ) { + hooksDirs.delete(path.join(path.dirname(file), 'hooks')); + } const redirectedHooksDirs = hooksPathDirectories(entries, hooksPathRoot); if (redirectedHooksDirs === null) return true; // fail closed for (const dir of redirectedHooksDirs) hooksDirs.add(dir); diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index bb09049dd6f..45759269d96 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -1418,6 +1418,19 @@ describe('git config probe cd tracking (#8575)', () => { ).toBe('read-only'); }); + it('resolves fully quoted cd targets', async () => { + expect( + await isShellCommandReadOnlyAST("cd 'sub' && git status", { + cwd: cleanRepo, + }), + ).toBe(true); + expect( + await isShellCommandReadOnlyAST('cd "sub" && git status', { + cwd: cleanRepo, + }), + ).toBe(true); + }); + it('downgrades git after an unresolvable cd', async () => { for (const command of [ 'cd $TARGET && git status', @@ -1653,6 +1666,19 @@ describe('git config probe cd tracking (#8575)', () => { ).toBe(false); }); + it('downgrades git after an unresolvable cd even without a cwd', async () => { + // cd tracking is not gated on a supplied cwd: a target that cannot be + // resolved or probed leaves the effective repository unknown (#8575). + expect(await isShellCommandReadOnlyAST('cd $TARGET && git status')).toBe( + false, + ); + expect( + await isShellCommandReadOnlyAST('(cd /nonexistent && git status)'), + ).toBe(false); + // Plain git commands without a cwd keep their pre-#8575 behavior. + expect(await isShellCommandReadOnlyAST('git status')).toBe(true); + }); + it('does not propagate cd through negation', async () => { // `! cd X && …` continues the chain precisely when the cd FAILED. expect( diff --git a/packages/core/src/utils/shellReadOnlyChecker.test.ts b/packages/core/src/utils/shellReadOnlyChecker.test.ts index 95774c97c45..885cd2312da 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.test.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.test.ts @@ -588,6 +588,37 @@ describe('git config probe cd tracking (#8575)', () => { ).toBe(true); }); + it('resolves fully quoted cd targets (parity with the AST path)', () => { + expect( + isShellCommandReadOnly("cd 'sub' && git status", { cwd: cleanRepo }), + ).toBe(true); + expect( + isShellCommandReadOnly('cd "sub" && git status', { cwd: cleanRepo }), + ).toBe(true); + // Expansions and escapes inside double quotes stay unresolvable. + expect( + isShellCommandReadOnly('cd "$TARGET" && git status', { + cwd: cleanRepo, + }), + ).toBe(false); + expect( + isShellCommandReadOnly('cd "su\\b" && git status', { + cwd: cleanRepo, + }), + ).toBe(false); + }); + + it('keeps the original cwd read-only after a backgrounded cd', () => { + // `cd sub &` runs in a background subshell; the following git command + // executes in the ORIGINAL cwd (parity with the AST path). + expect( + isShellCommandReadOnly('cd sub & git status', { cwd: cleanRepo }), + ).toBe(true); + expect( + isShellCommandReadOnly('cd sub & git status', { cwd: dirtyRepo }), + ).toBe(false); + }); + it('downgrades git after an unresolvable cd', () => { expect( isShellCommandReadOnly('cd $TARGET && git status', { cwd: cleanRepo }), @@ -736,4 +767,15 @@ describe('git config probe cd tracking (#8575)', () => { }), ).toBe(false); }); + + it('downgrades git after an unresolvable cd even without a cwd', () => { + // cd tracking is not gated on a supplied cwd: a target that cannot be + // resolved or probed leaves the effective repository unknown (#8575). + expect(isShellCommandReadOnly('cd $TARGET && git status')).toBe(false); + expect(isShellCommandReadOnly('(cd /nonexistent && git status)')).toBe( + false, + ); + // Plain git commands without a cwd keep their pre-#8575 behavior. + expect(isShellCommandReadOnly('git status')).toBe(true); + }); }); diff --git a/packages/core/src/utils/shellReadOnlyChecker.ts b/packages/core/src/utils/shellReadOnlyChecker.ts index 0684830c0db..99ad7882106 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.ts @@ -398,8 +398,18 @@ function trackDirectoryChange( // No operand cds to $HOME; more than one is rejected by bash (`cd: too // many arguments`) or rewrites $PWD (`cd old new`) — neither resolvable. if (operands.length !== 1) return unknown; - const target = operands[0]!; - if (target.startsWith('~') || /[$`'"\\*?[\]{}()<>|;&]/.test(target)) { + let target = operands[0]!; + // Mirror the AST classifier: a fully quoted target resolves to its + // literal content — single quotes are fully literal, and double quotes + // are literal unless they hold an expansion or escape (#8575). + if (/^'[^']*'$/.test(target)) { + target = target.slice(1, -1); + if (target.startsWith('~')) return unknown; + } else if (/^"[^"]*"$/.test(target)) { + const inner = target.slice(1, -1); + if (/[\\"$`]/.test(inner) || inner.startsWith('~')) return unknown; + target = inner; + } else if (target.startsWith('~') || /[$`'"\\*?[\]{}()<>|;&]/.test(target)) { return unknown; } const resolved = path.isAbsolute(target) @@ -473,6 +483,9 @@ export function isShellCommandReadOnly( // Every pipeline member runs in a subshell — a cd there never moves // the directory the following segments execute in (#8575). if (incoming === '|' || incoming === '|&') continue; + // A `&` backgrounds the segment the same way: a cd there runs in a + // subshell and leaves the tracked directory alone (#8575). + if (segments[index]!.separator === '&') continue; const tracked = trackDirectoryChange(segment, currentCwd); if (tracked.unknownDir) { unknownDir = true; From 313ef681e5671850358ae7717b3653ad21efd839 Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 8 Aug 2026 15:07:45 +0800 Subject: [PATCH 14/15] refactor(core): reset git config probe to issue scope --- .../core/src/core/coreToolScheduler.test.ts | 18 +- packages/core/src/core/coreToolScheduler.ts | 8 +- .../src/core/plan-mode-shell-policy.test.ts | 53 +- .../core/src/core/plan-mode-shell-policy.ts | 6 +- .../core/src/followup/speculation.test.ts | 74 -- packages/core/src/followup/speculation.ts | 1 - .../src/followup/speculationToolGate.test.ts | 67 -- .../core/src/followup/speculationToolGate.ts | 14 +- .../memory/memory-scoped-agent-config.test.ts | 70 -- .../src/memory/memory-scoped-agent-config.ts | 14 +- .../permissions/permission-manager.test.ts | 141 +--- .../src/permissions/permission-manager.ts | 63 +- packages/core/src/tools/monitor.test.ts | 57 -- packages/core/src/tools/monitor.ts | 56 +- .../src/tools/shell-git-config.integ.test.ts | 121 --- packages/core/src/tools/shell.test.ts | 106 --- packages/core/src/tools/shell.ts | 56 +- .../core/src/utils/git-config-safety.test.ts | 730 ------------------ packages/core/src/utils/git-config-safety.ts | 656 ---------------- packages/core/src/utils/shell-utils.test.ts | 58 -- packages/core/src/utils/shell-utils.ts | 110 +-- .../core/src/utils/shellAstParser.test.ts | 486 +----------- packages/core/src/utils/shellAstParser.ts | 344 +-------- .../src/utils/shellReadOnlyChecker.test.ts | 316 +------- .../core/src/utils/shellReadOnlyChecker.ts | 194 +---- 25 files changed, 115 insertions(+), 3704 deletions(-) delete mode 100644 packages/core/src/tools/shell-git-config.integ.test.ts delete mode 100644 packages/core/src/utils/git-config-safety.test.ts delete mode 100644 packages/core/src/utils/git-config-safety.ts diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 55ee87e16ae..93abb9dea23 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -15363,28 +15363,14 @@ describe('Fire hook functions integration', () => { }); it('treats a read-only shell command as safe and a mutating one as unsafe', () => { - expect( - isToolCallConcurrencySafe('shell', Kind.Execute, { - command: 'ls', - }), - ).toBe(true); - expect( - isToolCallConcurrencySafe('shell', Kind.Execute, { - command: 'rm -rf build', - }), - ).toBe(false); - }); - - it('uses the shell directory for git config checks and fails closed without one', () => { expect( isToolCallConcurrencySafe('shell', Kind.Execute, { command: 'git status', - directory: os.tmpdir(), }), ).toBe(true); expect( isToolCallConcurrencySafe('shell', Kind.Execute, { - command: 'git status', + command: 'rm -rf build', }), ).toBe(false); }); @@ -15749,7 +15735,7 @@ describe('Fire hook functions integration', () => { { callId: '1', name: 'run_shell_command', - args: { command: 'git log', directory: os.tmpdir() }, + args: { command: 'git log' }, isClientInitiated: false, prompt_id: 'p1', }, diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 9a4440d78ba..2ccfe2ecd72 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -1267,14 +1267,10 @@ export function isToolCallConcurrencySafe( // one) because partitioning runs synchronously. It is deliberately more // conservative than the AST version used for permission decisions. if (kind === Kind.Execute) { - const { command, directory } = - (args as { command?: string; directory?: string } | undefined) ?? {}; + const command = (args as { command?: string } | undefined)?.command; if (typeof command !== 'string') return false; try { - return isShellCommandReadOnly( - command, - directory ? { cwd: directory } : { unknownDir: true }, - ); + return isShellCommandReadOnly(command); } catch { return false; // fail-closed } diff --git a/packages/core/src/core/plan-mode-shell-policy.test.ts b/packages/core/src/core/plan-mode-shell-policy.test.ts index 9fe9a8d3cd9..7b922423881 100644 --- a/packages/core/src/core/plan-mode-shell-policy.test.ts +++ b/packages/core/src/core/plan-mode-shell-policy.test.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; import type { Config } from '../config/config.js'; import { ApprovalMode } from '../config/config.js'; import type { PermissionManager } from '../permissions/permission-manager.js'; @@ -460,51 +457,3 @@ describe('plan-mode shell policy', () => { }); }); }); - -describe('git config probe cwd threading (#8575)', () => { - let root: string; - let cleanRepo: string; - let dirtyRepo: string; - - beforeAll(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'plan-mode-git-config-')); - - cleanRepo = path.join(root, 'clean'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync(path.join(cleanRepo, '.git', 'config'), '[core]\n'); - - dirtyRepo = path.join(root, 'dirty'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - }); - - afterAll(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it('classifies dirty-repo git commands as unknown', async () => { - await expect( - evaluate('git status', { - config: createConfig({ targetDir: () => dirtyRepo }), - }), - ).resolves.toMatchObject({ classification: 'unknown' }); - - await expect( - evaluate('git status', { - config: createConfig({ targetDir: () => cleanRepo }), - }), - ).resolves.toMatchObject({ classification: 'read-only' }); - }); - - it('honors the directory invocation param over the target dir', async () => { - await expect( - evaluate('git status', { - config: createConfig({ targetDir: () => cleanRepo }), - invocationParams: { command: 'git status', directory: dirtyRepo }, - }), - ).resolves.toMatchObject({ classification: 'unknown' }); - }); -}); diff --git a/packages/core/src/core/plan-mode-shell-policy.ts b/packages/core/src/core/plan-mode-shell-policy.ts index 60306f62981..318b91d5cfa 100644 --- a/packages/core/src/core/plan-mode-shell-policy.ts +++ b/packages/core/src/core/plan-mode-shell-policy.ts @@ -164,11 +164,7 @@ export async function evaluatePlanModeShellPolicy(input: { let classification: ShellCommandSafety; try { classification = await raceWithAbort( - () => - classifyShellCommandSafety( - safetyCommand, - permissionContext.cwd ? { cwd: permissionContext.cwd } : undefined, - ), + () => classifyShellCommandSafety(safetyCommand), input.signal, ); } catch (error) { diff --git a/packages/core/src/followup/speculation.test.ts b/packages/core/src/followup/speculation.test.ts index 527c72929c3..9209ed2a3a3 100644 --- a/packages/core/src/followup/speculation.test.ts +++ b/packages/core/src/followup/speculation.test.ts @@ -5,9 +5,6 @@ */ import { afterEach, describe, it, expect, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { abortSpeculation, ensureToolResultPairing, @@ -63,7 +60,6 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), - getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), @@ -111,71 +107,6 @@ describe('startSpeculation', () => { await abortSpeculation(state); }); - it('stops at a boundary for shell calls when the target repo config executes programs (#8575)', async () => { - const dirtyRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'spec-dirty-')); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - try { - const execute = vi.fn(); - const toolRegistry = { - ensureTool: vi.fn().mockResolvedValue({ - build: vi.fn().mockReturnValue({ - params: { command: 'git status' }, - execute, - }), - }), - }; - const config = { - getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), - getCwd: vi.fn().mockReturnValue(process.cwd()), - getTargetDir: vi.fn().mockReturnValue(dirtyRepo), - getFastModel: vi.fn().mockReturnValue(undefined), - getToolRegistry: vi.fn().mockReturnValue(toolRegistry), - getToolInvocationGuard: vi.fn().mockReturnValue(undefined), - } as unknown as Config; - - forkedAgentMocks.runForkedAgent.mockResolvedValue({ - jsonResult: { suggestion: '' }, - }); - forkedAgentMocks.sendMessageStream.mockImplementation(async function* () { - if (forkedAgentMocks.sendMessageStream.mock.calls.length === 1) { - yield { - type: 'chunk', - value: { - candidates: [ - { - content: { - parts: [ - { - functionCall: { - id: 'call-shell-git', - name: 'run_shell_command', - args: { command: 'git status' }, - }, - }, - ], - }, - }, - ], - }, - }; - } - }); - - const state = await startSpeculation(config, 'check the repo'); - await vi.waitFor(() => expect(state.status).toBe('boundary')); - - expect(execute).not.toHaveBeenCalled(); - - await abortSpeculation(state); - } finally { - fs.rmSync(dirtyRepo, { recursive: true, force: true }); - } - }); - it('proceeds to execution when the host guard allows a speculative invocation', async () => { const execute = vi.fn().mockResolvedValue({ llmContent: 'file contents', @@ -193,7 +124,6 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), - getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolInvocationGuard: vi.fn().mockReturnValue(guard), @@ -254,7 +184,6 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), - getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -316,7 +245,6 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), - getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; @@ -380,7 +308,6 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), - getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), getToolOutputBatchBudget: vi.fn().mockReturnValue(10_000), @@ -444,7 +371,6 @@ describe('startSpeculation', () => { const config = { getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getCwd: vi.fn().mockReturnValue(process.cwd()), - getTargetDir: vi.fn().mockReturnValue(process.cwd()), getFastModel: vi.fn().mockReturnValue(undefined), getToolRegistry: vi.fn().mockReturnValue(toolRegistry), } as unknown as Config; diff --git a/packages/core/src/followup/speculation.ts b/packages/core/src/followup/speculation.ts index 9ae43ee8e85..323de822093 100644 --- a/packages/core/src/followup/speculation.ts +++ b/packages/core/src/followup/speculation.ts @@ -299,7 +299,6 @@ async function runSpeculativeLoop( args, state.overlayFs!, approvalMode, - config.getTargetDir(), ); if (gate.action === 'boundary') { diff --git a/packages/core/src/followup/speculationToolGate.test.ts b/packages/core/src/followup/speculationToolGate.test.ts index d9090651cc3..52e7814a76a 100644 --- a/packages/core/src/followup/speculationToolGate.test.ts +++ b/packages/core/src/followup/speculationToolGate.test.ts @@ -149,73 +149,6 @@ describe('speculationToolGate', () => { ); expect(result.action).toBe('boundary'); }); - - // Issue #8575: speculation bypasses the permission flow, so the gate - // itself must downgrade read-only git commands whose repo-local config - // executes programs. - describe('git config execution probe (#8575)', () => { - let cleanRepo: string; - let dirtyRepo: string; - - beforeEach(async () => { - cleanRepo = join(testDir, 'clean-repo'); - await mkdir(join(cleanRepo, '.git'), { recursive: true }); - await writeFile( - join(cleanRepo, '.git', 'config'), - '[core]\n\tbare = false\n', - ); - - dirtyRepo = join(testDir, 'dirty-repo'); - await mkdir(join(dirtyRepo, '.git'), { recursive: true }); - await writeFile( - join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - }); - - it('hits boundary for read-only git when cwd config executes programs', async () => { - const result = await evaluateToolCall( - ToolNames.SHELL, - { command: 'git diff', directory: '' }, - overlayFs, - ApprovalMode.DEFAULT, - dirtyRepo, - ); - expect(result.action).toBe('boundary'); - }); - - it('allows read-only git when cwd config is clean', async () => { - const result = await evaluateToolCall( - ToolNames.SHELL, - { command: 'git diff' }, - overlayFs, - ApprovalMode.DEFAULT, - cleanRepo, - ); - expect(result.action).toBe('allow'); - }); - - it('honors the directory arg over the ambient cwd', async () => { - const result = await evaluateToolCall( - ToolNames.SHELL, - { command: 'git status', directory: dirtyRepo }, - overlayFs, - ApprovalMode.DEFAULT, - cleanRepo, - ); - expect(result.action).toBe('boundary'); - }); - - it('keeps backward compatibility without cwd', async () => { - const result = await evaluateToolCall( - ToolNames.SHELL, - { command: 'git diff' }, - overlayFs, - ApprovalMode.DEFAULT, - ); - expect(result.action).toBe('allow'); - }); - }); }); describe('BOUNDARY_TOOLS', () => { diff --git a/packages/core/src/followup/speculationToolGate.ts b/packages/core/src/followup/speculationToolGate.ts index 959108c5ff1..e06e39e984f 100644 --- a/packages/core/src/followup/speculationToolGate.ts +++ b/packages/core/src/followup/speculationToolGate.ts @@ -61,10 +61,6 @@ 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 - Execution directory for shell commands; lets the classifier - * downgrade git commands whose repo-local config executes programs - * (#8575). Speculation bypasses the permission flow, so this gate is the - * only place that check can happen for speculated shell calls. * @returns Gate result: allow, redirect, or boundary */ export async function evaluateToolCall( @@ -72,7 +68,6 @@ 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)) { @@ -100,16 +95,9 @@ 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, - directory ? { cwd: directory } : undefined, - )) === 'read-only' + (await classifyShellCommandSafety(command)) === 'read-only' ) { return { action: 'allow' }; } diff --git a/packages/core/src/memory/memory-scoped-agent-config.test.ts b/packages/core/src/memory/memory-scoped-agent-config.test.ts index 04f7f61290b..a1978aab330 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.test.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.test.ts @@ -477,76 +477,6 @@ describe('createMemoryScopedAgentConfig', () => { ).resolves.toBe('deny'); }); - it('threads cwd into the shell read-only classifier (#8575)', async () => { - const cleanRepo = path.join(tempDir, 'clean-repo'); - await fs.mkdir(path.join(cleanRepo, '.git'), { recursive: true }); - await fs.writeFile(path.join(cleanRepo, '.git', 'config'), '[core]\n'); - const dirtyRepo = path.join(tempDir, 'dirty-repo'); - await fs.mkdir(path.join(dirtyRepo, '.git'), { recursive: true }); - await fs.writeFile( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - - const enabled = permissionManager( - createMemoryScopedAgentConfig({} as Config, projectRoot, { - allowShell: true, - }), - ); - await expect( - enabled.evaluate({ - toolName: ToolNames.SHELL, - command: 'git status', - cwd: dirtyRepo, - }), - ).resolves.toBe('deny'); - await expect( - enabled.evaluate({ - toolName: ToolNames.SHELL, - command: 'git status', - cwd: cleanRepo, - }), - ).resolves.toBe('allow'); - }); - - it('probes the scoped execution root when no cwd is provided (#8575)', async () => { - // Production shape: managed memory agents' shell calls carry no - // `directory` parameter, so ctx.cwd is absent and the probe must fall - // back to the scoped execution root. - const dirtyRoot = path.join(tempDir, 'dirty-root'); - await fs.mkdir(path.join(dirtyRoot, '.git'), { recursive: true }); - await fs.writeFile( - path.join(dirtyRoot, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - const dirty = permissionManager( - createMemoryScopedAgentConfig({} as Config, dirtyRoot, { - allowShell: true, - }), - ); - await expect( - dirty.evaluate({ - toolName: ToolNames.SHELL, - command: 'git status', - }), - ).resolves.toBe('deny'); - - const cleanRoot = path.join(tempDir, 'clean-root'); - await fs.mkdir(path.join(cleanRoot, '.git'), { recursive: true }); - await fs.writeFile(path.join(cleanRoot, '.git', 'config'), '[core]\n'); - const clean = permissionManager( - createMemoryScopedAgentConfig({} as Config, cleanRoot, { - allowShell: true, - }), - ); - await expect( - clean.evaluate({ - toolName: ToolNames.SHELL, - command: 'git status', - }), - ).resolves.toBe('allow'); - }); - it('lets base deny rules override scoped allows', async () => { const basePm: Pick< PermissionManager, diff --git a/packages/core/src/memory/memory-scoped-agent-config.ts b/packages/core/src/memory/memory-scoped-agent-config.ts index c7c2142bc7e..ca3784c3d4c 100644 --- a/packages/core/src/memory/memory-scoped-agent-config.ts +++ b/packages/core/src/memory/memory-scoped-agent-config.ts @@ -14,10 +14,7 @@ import type { } from '../permissions/types.js'; import { ToolNames } from '../tools/tool-names.js'; import { isShellCommandReadOnlyAST } from '../utils/shellAstParser.js'; -import { - hasGitConfigOverridingEnv, - stripShellWrapper, -} from '../utils/shell-utils.js'; +import { stripShellWrapper } from '../utils/shell-utils.js'; import { AUTO_MEMORY_PINNED_DIRNAME, getAutoMemoryRoot, @@ -253,17 +250,8 @@ async function evaluateScopedDecision( if (!opts.allowShell || !ctx.command) { return 'deny'; } - // Managed memory agents' shell calls carry no `directory` parameter, - // so ctx.cwd is absent in production — fall back to the scoped - // execution root or the git-config probe never runs (#8575). A - // git-overriding env prefix survives the wrapper unwrap and still - // applies to the inner script, so it can never be auto-allowed. - if (hasGitConfigOverridingEnv(ctx.command)) { - return 'deny'; - } const isReadOnly = await isShellCommandReadOnlyAST( stripShellWrapper(ctx.command), - { cwd: 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 b55fcfc9424..dead3984ec8 100644 --- a/packages/core/src/permissions/permission-manager.test.ts +++ b/packages/core/src/permissions/permission-manager.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -3887,142 +3887,3 @@ describe('matchesRule — param matcher type guards', () => { ).toBe(true); }); }); - -describe('git config execution probe wiring (#8575)', () => { - let root: string; - let cleanRepo: string; - let dirtyRepo: string; - - beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'pm-git-config-')); - cleanRepo = path.join(root, 'clean'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync(path.join(cleanRepo, '.git', 'config'), '[core]\n'); - dirtyRepo = path.join(root, 'dirty'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - }); - - afterEach(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it('resolves the default shell permission against ctx.cwd', async () => { - const manager = new PermissionManager(makeConfig()); - manager.initialize(); - - // No rules match, so 'default' resolves through the classifier, which - // must see the execution cwd: dirty repo config → ask, clean → allow. - await expect( - manager.evaluate({ - toolName: 'run_shell_command', - command: 'git status', - cwd: dirtyRepo, - }), - ).resolves.toBe('ask'); - - await expect( - manager.evaluate({ - toolName: 'run_shell_command', - command: 'git status', - cwd: cleanRepo, - }), - ).resolves.toBe('allow'); - }); - - it('falls back to config.getCwd() when ctx.cwd is absent', async () => { - const dirtyManager = new PermissionManager(makeConfig({ cwd: dirtyRepo })); - dirtyManager.initialize(); - await expect( - dirtyManager.evaluate({ - toolName: 'run_shell_command', - command: 'git status', - }), - ).resolves.toBe('ask'); - - const cleanManager = new PermissionManager(makeConfig({ cwd: cleanRepo })); - cleanManager.initialize(); - await expect( - cleanManager.evaluate({ - toolName: 'run_shell_command', - command: 'git status', - }), - ).resolves.toBe('allow'); - }); - - it('resolves compound defaults against the full command with cwd', async () => { - const manager = new PermissionManager(makeConfig()); - manager.initialize(); - - await expect( - manager.evaluate({ - toolName: 'run_shell_command', - command: 'git status && git diff', - cwd: dirtyRepo, - }), - ).resolves.toBe('ask'); - - await expect( - manager.evaluate({ - toolName: 'run_shell_command', - command: 'git status && git diff', - cwd: cleanRepo, - }), - ).resolves.toBe('allow'); - }); - - it('keeps per-segment rule composition without directory changes', async () => { - // Without a cd, a rule-matched segment and a read-only 'default' - // segment compose to allow — whole-command resolution would ask here - // because checkout is a write sub-command. - const manager = new PermissionManager( - makeConfig({ permissionsAllow: ['Bash(git checkout *)'] }), - ); - manager.initialize(); - - await expect( - manager.evaluate({ - toolName: 'run_shell_command', - command: 'ls && git checkout -b feature', - cwd: cleanRepo, - }), - ).resolves.toBe('allow'); - }); - - it('resolves cd-containing compounds against the full command even with a rule match', async () => { - const manager = new PermissionManager( - makeConfig({ permissionsAllow: ['Bash(git checkout *)'] }), - ); - manager.initialize(); - - await expect( - manager.evaluate({ - toolName: 'run_shell_command', - command: `cd ${cleanRepo} && git checkout -b feature`, - cwd: cleanRepo, - }), - ).resolves.toBe('ask'); - }); - - it('does not let a segment rule override the cd-aware compound default', async () => { - // With a rule matching one segment, the other segment's 'default' must - // resolve against the FULL command (cd-aware), not the bare segment - // probed at the original cwd — otherwise `cd && git status` - // auto-executes (#8575). - const manager = new PermissionManager( - makeConfig({ permissionsAllow: ['Bash(cd *)'] }), - ); - manager.initialize(); - - await expect( - manager.evaluate({ - toolName: 'run_shell_command', - command: `cd ${dirtyRepo} && git status`, - cwd: cleanRepo, - }), - ).resolves.toBe('ask'); - }); -}); diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index ff9af37dccf..df8851895e2 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -17,10 +17,7 @@ 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 { - isDirectoryChangeSegment, - normalizeMonitorCommand, -} from '../utils/shell-utils.js'; +import { normalizeMonitorCommand } from '../utils/shell-utils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { findDangerousAllowRules, @@ -236,10 +233,7 @@ export class PermissionManager { SHELL_TOOL_NAMES.has(toolName) && command !== undefined ) { - bashDecision = await this.resolveDefaultPermission( - command, - this.probeCwd(ctx), - ); + bashDecision = await this.resolveDefaultPermission(command); } } } else { @@ -438,10 +432,7 @@ export class PermissionManager { * - Otherwise (including command substitution) → 'ask' * * Example: with rules `allow: [git checkout *]` - * - "ls && git checkout -b feature" → allow (ls) + allow (rule) → allow - * - "cd /path && git checkout -b feature" → contains a directory - * change, so 'default' segments resolve against the FULL command - * (a write) → ask + * - "cd /path && git checkout -b feature" → allow (cd) + allow (rule) → allow * - "rm /path && git checkout -b feature" → ask (rm) + allow (rule) → ask * - "evil-cmd && git checkout" (deny: [evil-cmd]) → deny + allow → deny */ @@ -459,18 +450,6 @@ export class PermissionManager { let mostRestrictive: ResolvedDecision = 'allow'; - // When the compound contains a directory-changing segment, a 'default' - // sub-command resolves against the FULL original command: per-segment - // classification cannot see cd context across the split and would probe - // a stale cwd, letting `cd && git status` auto-run whenever any - // rule matches a sibling segment (#8575). The whole-command classifier - // tracks directory changes. Without a directory change, per-segment - // resolution keeps rule composition working. Called only for compound - // commands, so ctx.command is defined; computed lazily — rule-heavy - // configs may never reach a 'default' segment. - const hasDirectoryChange = subCommands.some(isDirectoryChangeSegment); - let wholeCommandDecision: 'allow' | 'ask' | undefined; - for (const subCmd of subCommands) { const subCtx: PermissionCheckContext = { ...ctx, @@ -480,21 +459,10 @@ export class PermissionManager { // Resolve 'default' to actual permission using AST analysis // (same logic as ShellToolInvocation.getDefaultPermission) - let decision: ResolvedDecision; - if (rawDecision !== 'default') { - decision = rawDecision as ResolvedDecision; - } else if (hasDirectoryChange) { - wholeCommandDecision ??= await this.resolveDefaultPermission( - ctx.command!, - this.probeCwd(ctx), - ); - decision = wholeCommandDecision; - } else { - decision = await this.resolveDefaultPermission( - subCmd, - this.probeCwd(ctx), - ); - } + const decision: ResolvedDecision = + rawDecision === 'default' + ? await this.resolveDefaultPermission(subCmd) + : (rawDecision as ResolvedDecision); if (PRIORITY[decision] > PRIORITY[mostRestrictive]) { mostRestrictive = decision; @@ -523,19 +491,13 @@ export class PermissionManager { * "relevant" rules for the surrounding compound command. * * @param command - The shell command to analyze. - * @param cwd - Execution directory; lets the classifier downgrade git - * commands whose repository-local config executes programs (#8575). * @returns 'allow' for read-only, 'ask' otherwise. */ private async resolveDefaultPermission( command: string, - cwd?: string, ): Promise<'allow' | 'ask'> { try { - const isReadOnly = await isShellCommandReadOnlyAST( - command, - cwd ? { cwd } : undefined, - ); + const isReadOnly = await isShellCommandReadOnlyAST(command); if (isReadOnly) { return 'allow'; } @@ -551,15 +513,6 @@ export class PermissionManager { return 'ask'; } - /** - * Best-effort execution directory for the git-config probe (#8575). - * Returns `undefined` when unknown — the probe is skipped and the - * classifier keeps its text-only verdict. - */ - private probeCwd(ctx: PermissionCheckContext): string | undefined { - return ctx.cwd ?? this.config.getCwd?.(); - } - private normalizePermissionContext( ctx: PermissionCheckContext, ): PermissionCheckContext { diff --git a/packages/core/src/tools/monitor.test.ts b/packages/core/src/tools/monitor.test.ts index 76d1fb6226e..e442690383c 100644 --- a/packages/core/src/tools/monitor.test.ts +++ b/packages/core/src/tools/monitor.test.ts @@ -518,63 +518,6 @@ describe('MonitorTool', () => { await expect(invocation.getDefaultPermission()).resolves.toBe('allow'); }); - it('passes the execution cwd to the read-only classifier (#8575)', async () => { - mockIsShellCommandReadOnlyAST.mockResolvedValueOnce(true); - const invocation = createInvocation({ - command: 'git status', - }); - - await expect(invocation.getDefaultPermission()).resolves.toBe('allow'); - expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledWith( - expect.any(String), - { cwd: '/test/dir' }, - ); - }); - - it('lets the directory parameter win over the target dir (#8575)', async () => { - mockIsShellCommandReadOnlyAST.mockResolvedValueOnce(true); - const invocation = createInvocation({ - command: 'git status', - directory: '/other/dir', - }); - - await expect(invocation.getDefaultPermission()).resolves.toBe('allow'); - expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledWith( - expect.any(String), - { cwd: '/other/dir' }, - ); - }); - - it('passes the execution cwd to the confirmation-scope classifier (#8575)', async () => { - mockIsShellCommandReadOnlyAST.mockResolvedValue(false); - const invocation = createInvocation({ - command: 'git status && rm x', - }); - - await invocation.getConfirmationDetails(new AbortController().signal); - - expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledWith( - expect.any(String), - { cwd: '/test/dir' }, - ); - }); - - it('keeps sub-commands after a cd in the monitor confirmation scope (#8575)', async () => { - mockIsShellCommandReadOnlyAST.mockResolvedValueOnce(true); // the cd - const invocation = createInvocation({ - command: 'cd /tmp/repo && git status', - }); - - const details = (await invocation.getConfirmationDetails( - new AbortController().signal, - )) as { rootCommand: string }; - - expect(details.rootCommand).toContain('git'); - // Only the cd segment is classified; the segment after the cd is - // kept in scope instead of being re-probed against the stale cwd. - expect(mockIsShellCommandReadOnlyAST).toHaveBeenCalledTimes(1); - }); - it('surfaces a command-substitution warning via getConfirmationDetails (issue #4093)', async () => { const invocation = createInvocation({ command: 'echo $(cat secret.txt)', diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index 9a5ee407734..ab2c925c467 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -39,7 +39,6 @@ import { getCommandRoot, getShellConfiguration, hasUnsafeMonitorBackgroundOperator, - isDirectoryChangeSegment, normalizeMonitorCommand as normalizeMonitorShellCommand, splitCommands, } from '../utils/shell-utils.js'; @@ -189,8 +188,7 @@ class MonitorToolInvocation extends BaseToolInvocation< // Bash(...) — see comment in getConfirmationDetails); only the // substitution-deny half is removed. try { - const cwd = this.params.directory || this.config.getTargetDir(); - const isReadOnly = await isShellCommandReadOnlyAST(command, { cwd }); + const isReadOnly = await isShellCommandReadOnlyAST(command); if (isReadOnly) { return 'allow'; } @@ -205,43 +203,31 @@ class MonitorToolInvocation extends BaseToolInvocation< _abortSignal: AbortSignal, ): Promise { const normalized = normalizeMonitorShellCommand(this.params.command); - const cwd = this.params.directory || this.config.getTargetDir(); const subCommands = splitCommands(normalized.safetyCommand); const confirmableSubCommands: string[] = []; - // After a directory-changing segment the per-sub-command probe would - // classify against the pre-cd cwd and silently drop the git sub-command - // that triggered this confirmation, so everything after it stays in - // scope (#8575). - let sawDirectoryChange = false; for (const sub of subCommands) { - const changesDirectory = isDirectoryChangeSegment(sub); - const filterable = !sawDirectoryChange; - if (changesDirectory) sawDirectoryChange = true; - - if (filterable) { - // Only filter out read-only commands via AST analysis. - // We intentionally do NOT consult pm.isCommandAllowed() here because - // that evaluates under 'run_shell_command' context, which would let - // existing Bash(...) allow rules shrink the monitor confirmation scope. - // Monitor is a long-running background process with a different risk - // profile than one-shot shell execution and should maintain its own - // permission boundary. - let isReadOnly = false; - try { - isReadOnly = await isShellCommandReadOnlyAST(sub, { cwd }); - } catch (e) { - // Conservative fallback: if AST analysis fails, keep the sub-command - // in the confirmation scope instead of accidentally dropping it. - debugLogger.warn( - 'AST read-only check failed for monitor sub-command, falling back to ask:', - e, - ); - } + // Only filter out read-only commands via AST analysis. + // We intentionally do NOT consult pm.isCommandAllowed() here because + // that evaluates under 'run_shell_command' context, which would let + // existing Bash(...) allow rules shrink the monitor confirmation scope. + // Monitor is a long-running background process with a different risk + // profile than one-shot shell execution and should maintain its own + // permission boundary. + let isReadOnly = false; + try { + isReadOnly = await isShellCommandReadOnlyAST(sub); + } catch (e) { + // Conservative fallback: if AST analysis fails, keep the sub-command + // in the confirmation scope instead of accidentally dropping it. + debugLogger.warn( + 'AST read-only check failed for monitor sub-command, falling back to ask:', + e, + ); + } - if (isReadOnly) { - continue; - } + if (isReadOnly) { + continue; } confirmableSubCommands.push(sub); diff --git a/packages/core/src/tools/shell-git-config.integ.test.ts b/packages/core/src/tools/shell-git-config.integ.test.ts deleted file mode 100644 index 361751fc211..00000000000 --- a/packages/core/src/tools/shell-git-config.integ.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * End-to-end coverage for issue #8575 with the REAL probe and classifier - * (no fs mocking): ShellToolInvocation.getDefaultPermission must ask for - * whitelisted read-only git commands when the repo-local `.git/config` - * contains program-executing keys, and keep allowing them in clean repos. - */ - -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { ShellTool } from './shell.js'; -import type { Config } from '../config/config.js'; - -describe('ShellTool git config probe end-to-end (#8575)', () => { - let root: string; - let cleanRepo: string; - let dirtyRepo: string; - let huskyRepo: string; - - function makeShellTool(targetDir: string): ShellTool { - const config = { - getTargetDir: () => targetDir, - storage: { getUserSkillsDirs: () => [] }, - getWorkspaceContext: () => ({ isPathWithinWorkspace: () => true }), - } as unknown as Config; - return new ShellTool(config); - } - - beforeAll(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-git-config-integ-')); - - cleanRepo = path.join(root, 'clean'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(cleanRepo, '.git', 'config'), - '[core]\n\tbare = false\n', - ); - - dirtyRepo = path.join(root, 'dirty'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n[core]\n\tfsmonitor = /tmp/evil\n', - ); - - // husky-style setup: core.hooksPath redirects hook resolution, but the - // target dir holds no hooks that read-only commands trigger. - huskyRepo = path.join(root, 'husky'); - fs.mkdirSync(path.join(huskyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(huskyRepo, '.git', 'config'), - '[core]\n\thooksPath = .husky/_\n', - ); - const huskyHooks = path.join(huskyRepo, '.husky', '_'); - fs.mkdirSync(huskyHooks, { recursive: true }); - fs.writeFileSync(path.join(huskyHooks, 'pre-commit'), '#!/bin/sh\n', { - mode: 0o755, - }); - }); - - afterAll(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it.each(['git status', 'git diff', 'git log -p'])( - 'asks for %s when repo config executes programs', - async (command) => { - const invocation = makeShellTool(dirtyRepo).build({ - command, - is_background: false, - }); - expect(await invocation.getDefaultPermission()).toBe('ask'); - }, - ); - - it.each(['git status', 'git diff', 'git log -p'])( - 'allows %s when repo config is clean', - async (command) => { - const invocation = makeShellTool(cleanRepo).build({ - command, - is_background: false, - }); - expect(await invocation.getDefaultPermission()).toBe('allow'); - }, - ); - - it.each(['git status', 'git diff', 'git log -p'])( - 'allows %s when core.hooksPath holds no read-only-triggered hooks', - async (command) => { - const invocation = makeShellTool(huskyRepo).build({ - command, - is_background: false, - }); - expect(await invocation.getDefaultPermission()).toBe('allow'); - }, - ); - - it('allows non-git commands even in a dirty repo', async () => { - const invocation = makeShellTool(dirtyRepo).build({ - command: 'ls -la', - is_background: false, - }); - expect(await invocation.getDefaultPermission()).toBe('allow'); - }); - - it('honors the directory parameter over the target dir', async () => { - const invocation = makeShellTool(cleanRepo).build({ - command: 'git status', - directory: dirtyRepo, - is_background: false, - }); - expect(await invocation.getDefaultPermission()).toBe('ask'); - }); -}); diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index 5924b83d53a..10750530f34 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -37,10 +37,6 @@ vi.mock('../utils/debugLogger.js', () => ({ vi.mock('fs'); vi.mock('os'); vi.mock('crypto'); -const mockGitConfigMayExecutePrograms = vi.hoisted(() => vi.fn(() => false)); -vi.mock('../utils/git-config-safety.js', () => ({ - gitConfigMayExecutePrograms: mockGitConfigMayExecutePrograms, -})); import { isCommandAllowed } from '../utils/shell-utils.js'; import { @@ -7080,108 +7076,6 @@ describe('ShellTool', () => { expect(permission).toBe('allow'); }); - // Regression coverage for issue #8575: whitelisted read-only git - // sub-commands execute programs configured in the repository-local - // `.git/config` (diff.external, core.fsmonitor, pagers, credential/ssh - // helpers). When such keys are present the command must be confirmed - // instead of auto-approved. - it('asks for read-only git commands when repo config executes programs (#8575)', async () => { - mockGitConfigMayExecutePrograms.mockReturnValue(true); - const invocation = shellTool.build({ - command: 'git status', - is_background: false, - }); - - expect(await invocation.getDefaultPermission()).toBe('ask'); - expect(mockGitConfigMayExecutePrograms).toHaveBeenCalledWith('/test/dir'); - }); - - it('still allows read-only git commands when repo config is clean', async () => { - mockGitConfigMayExecutePrograms.mockReturnValue(false); - const invocation = shellTool.build({ - command: 'git status', - is_background: false, - }); - - expect(await invocation.getDefaultPermission()).toBe('allow'); - }); - - it('keeps probed git sub-commands in the confirmation scope (#8575)', async () => { - // The confirmation-scope filter must pass the cwd to the classifier: - // without it the probe never runs and the git sub-command that - // triggered the confirmation is silently filtered out of the dialog. - mockGitConfigMayExecutePrograms.mockReturnValue(true); - const invocation = shellTool.build({ - command: 'git status && rm x', - is_background: false, - }); - - const details = (await invocation.getConfirmationDetails( - new AbortController().signal, - )) as { rootCommand: string }; - - expect(details.rootCommand).toContain('git'); - mockGitConfigMayExecutePrograms.mockReturnValue(false); - }); - - it('keeps sub-commands after a cd in the confirmation scope (#8575)', async () => { - mockGitConfigMayExecutePrograms.mockReturnValue(false); - const invocation = shellTool.build({ - command: 'cd /tmp/repo && git status && rm x', - is_background: false, - }); - - const details = (await invocation.getConfirmationDetails( - new AbortController().signal, - )) as { rootCommand: string }; - - // The git segment runs after the cd, so classifying it against the - // pre-cd cwd is unsound — it must stay in the confirmation scope. - expect(details.rootCommand).toContain('git'); - expect(details.rootCommand).toContain('rm'); - }); - - it('keeps sub-commands after a disguised cd in the confirmation scope (#8575)', async () => { - mockGitConfigMayExecutePrograms.mockReturnValue(false); - const invocation = shellTool.build({ - command: 'builtin cd /tmp/repo && git status && rm x', - is_background: false, - }); - - const details = (await invocation.getConfirmationDetails( - new AbortController().signal, - )) as { rootCommand: string }; - - // `builtin cd` genuinely changes the directory in bash, so the git - // segment must not be filtered out of the dialog. - expect(details.rootCommand).toContain('git'); - expect(details.rootCommand).toContain('rm'); - }); - - it('asks when a git-overriding env prefix precedes a shell wrapper (#8575)', async () => { - // GIT_DIR survives the wrapper unwrap and applies to the inner - // script; without the guard the stripped `git status` probes the - // clean execution cwd and auto-executes against the planted repo. - const invocation = shellTool.build({ - command: `GIT_DIR=/planted/.git bash -c 'git status'`, - is_background: false, - }); - expect(await invocation.getDefaultPermission()).toBe('ask'); - - const configInjection = shellTool.build({ - command: `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=evil bash -c 'git status'`, - is_background: false, - }); - expect(await configInjection.getDefaultPermission()).toBe('ask'); - - // Unrelated env prefixes keep their normal classification. - const unrelated = shellTool.build({ - command: `FOO=bar bash -c 'ls -la'`, - is_background: false, - }); - expect(await unrelated.getDefaultPermission()).toBe('allow'); - }); - // Regression coverage for PR #4386 round 6 (cid 3298521039): the // env-prefix wrapper substitution bypass. `getDefaultPermission` // calls `stripShellWrapper(this.params.command)` BEFORE the AST diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index b398bb3922a..c7dece30138 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -62,9 +62,7 @@ import { getCommandRoot, getCommandRoots, getShellConfiguration, - hasGitConfigOverridingEnv, hasShellSubstitution, - isDirectoryChangeSegment, SHELL_SELF_KILL_REJECTION, type ShellConfiguration, type ShellType, @@ -2038,20 +2036,11 @@ export class ShellToolInvocation extends BaseToolInvocation< return 'ask'; } - // A git-overriding env prefix (GIT_DIR=…, GIT_CONFIG_COUNT=…) survives - // the wrapper unwrap below and still applies to the inner script, - // while the probe would only see the stripped command's cwd — the - // compound would auto-execute against attacker-chosen config (#8575). - if (hasGitConfigOverridingEnv(this.params.command)) { - return 'ask'; - } - const command = stripShellWrapper(this.params.command); // AST-based read-only detection try { - const cwd = this.params.directory || this.config.getTargetDir(); - const isReadOnly = await isShellCommandReadOnlyAST(command, { cwd }); + const isReadOnly = await isShellCommandReadOnlyAST(command); if (isReadOnly) { return 'allow'; } @@ -2119,39 +2108,28 @@ export class ShellToolInvocation extends BaseToolInvocation< } } - // Split compound command and filter out already-allowed (read-only) - // sub-commands. After a directory-changing segment the per-sub-command - // probe would classify against the pre-cd cwd and silently drop the git - // sub-command that triggered this confirmation, so everything after it - // stays in scope (#8575). + // Split compound command and filter out already-allowed (read-only) sub-commands const subCommands = splitCommands(command); const confirmableSubCommands: string[] = []; - let sawDirectoryChange = false; for (const sub of subCommands) { - const changesDirectory = isDirectoryChangeSegment(sub); - const filterable = !sawDirectoryChange; - if (changesDirectory) sawDirectoryChange = true; - - if (filterable) { - let isReadOnly = false; - try { - isReadOnly = await isShellCommandReadOnlyAST(sub, { cwd }); - } catch { - // conservative: treat unknown commands as requiring confirmation - } + let isReadOnly = false; + try { + isReadOnly = await isShellCommandReadOnlyAST(sub); + } catch { + // conservative: treat unknown commands as requiring confirmation + } - if (isReadOnly) { - continue; - } + if (isReadOnly) { + continue; + } - if (pm) { - try { - if ((await pm.isCommandAllowed(sub, cwd)) === 'allow') { - continue; - } - } catch (e) { - debugLogger.warn('PermissionManager command check failed:', e); + if (pm) { + try { + if ((await pm.isCommandAllowed(sub, cwd)) === 'allow') { + continue; } + } catch (e) { + debugLogger.warn('PermissionManager command check failed:', e); } } diff --git a/packages/core/src/utils/git-config-safety.test.ts b/packages/core/src/utils/git-config-safety.test.ts deleted file mode 100644 index 7de4f754939..00000000000 --- a/packages/core/src/utils/git-config-safety.test.ts +++ /dev/null @@ -1,730 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { gitConfigMayExecutePrograms } from './git-config-safety.js'; - -describe('gitConfigMayExecutePrograms', () => { - let root: string; - - beforeEach(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-config-safety-')); - }); - - afterEach(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - function makeRepo(name: string, config = ''): string { - const repo = path.join(root, name); - fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); - if (config) { - fs.writeFileSync(path.join(repo, '.git', 'config'), config); - } - return repo; - } - - it('returns false outside a git repository', () => { - expect(gitConfigMayExecutePrograms(root)).toBe(false); - }); - - it('returns false for an undefined cwd', () => { - expect(gitConfigMayExecutePrograms(undefined)).toBe(false); - }); - - it('returns false for a clean repo config (including nested cwd)', () => { - const repo = makeRepo( - 'clean', - '[core]\n\trepositoryformatversion = 0\n\tbare = false\n[remote "origin"]\n\turl = https://example.com/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - - const nested = path.join(repo, 'src', 'deep'); - fs.mkdirSync(nested, { recursive: true }); - expect(gitConfigMayExecutePrograms(nested)).toBe(false); - }); - - it('returns false when .git/config does not exist', () => { - const repo = makeRepo('no-config'); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it.each([ - ['[diff]\n\texternal = /tmp/evil\n', 'diff.external'], - ['[core]\n\tpager = less -R\n', 'core.pager'], - ['[core]\n\tfsmonitor = /tmp/evil\n', 'core.fsmonitor'], - ['[core]\n\taskpass = /tmp/evil\n', 'core.askpass'], - ['[core]\n\tsshCommand = /tmp/evil\n', 'core.sshCommand'], - ['[credential]\n\thelper = !/tmp/evil\n', 'credential.helper'], - ['[gpg]\n\tprogram = /tmp/evil\n', 'gpg.program'], - [ - '[core]\n\talternateRefsCommand = /tmp/evil\n', - 'core.alternateRefsCommand', - ], - ] as Array<[string, string]>)( - 'flags program-valued key %s', - (config, label) => { - const repo = makeRepo(label.replace(/\W+/g, '-'), config); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }, - ); - - it('is case-insensitive for section and key names', () => { - const repo = makeRepo('case', '[DIFF]\n\tEXTERNAL = /tmp/evil\n'); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('handles quoted values', () => { - const repo = makeRepo( - 'quoted', - '[core]\n\tpager = "delta --paging=never"\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it.each([ - ['[pager]\n\tlog = delta\n', 'pager-cmd-override'], - ['[diff "drv"]\n\ttextconv = /tmp/evil\n', 'diff-driver-textconv'], - ['[diff "drv"]\n\tcommand = /tmp/evil\n', 'diff-driver-command'], - [ - '[credential "https://example.com"]\n\thelper = store\n', - 'credential-url-helper', - ], - ['[gpg "ssh"]\n\tprogram = /tmp/evil\n', 'gpg-format-program'], - [ - '[remote "origin"]\n\tproxy = nc -X 5 -x proxy:1080 %h %p\n', - 'remote-proxy', - ], - ['[remote "origin"]\n\tuploadpack = /tmp/evil\n', 'remote-uploadpack'], - ['[remote "origin"]\n\treceivepack = /tmp/evil\n', 'remote-receivepack'], - ['[remote "origin"]\n\turl = ext::sh -c evil%% %S %u\n', 'remote-ext-url'], - ] as Array<[string, string]>)('flags subsection key %s', (config, label) => { - const repo = makeRepo(label, config); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it.each([ - ['[diff.evil]\n\tcommand = /tmp/evil\n', 'dot-diff-command'], - ['[diff.evil]\n\ttextconv = /tmp/evil\n', 'dot-diff-textconv'], - ['[filter.evil]\n\tclean = /tmp/evil\n', 'dot-filter-clean'], - ['[gpg.ssh]\n\tprogram = /tmp/evil\n', 'dot-gpg-program'], - [ - '[remote.origin]\n\turl = .\n\tuploadpack = /tmp/evil\n', - 'dot-remote-uploadpack', - ], - ['[pager.log]\n\trun = /tmp/evil\n', 'dot-pager'], - ['[DIFF.EVIL]\n\tCOMMAND = /tmp/evil\n', 'dot-case'], - ] as Array<[string, string]>)( - 'flags deprecated dot-form subsection header %s', - (config, label) => { - const repo = makeRepo(label, config); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }, - ); - - it('splits dot-form headers at the first dot only', () => { - // git keeps the remaining dots in the subsection - // (`[diff.evil.suffix]` === `[diff "evil.suffix"]`); a last-dot split - // would leave section `diff.evil` and miss the attack. - const repo = makeRepo( - 'dot-first-dot', - '[diff.evil.suffix]\n\tcommand = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('does not flag benign dot-form subsections', () => { - // Old git versions wrote branch/remote sections in the deprecated dot - // form; they must not start prompting. - const repo = makeRepo( - 'dot-benign', - '[branch.main]\n\tremote = origin\n\tmerge = refs/heads/main\n[remote.origin]\n\turl = https://example.com/repo.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('does not flag core.fsmonitor booleans (built-in daemon / disabled)', () => { - const enabled = makeRepo('fsm-true', '[core]\n\tfsmonitor = true\n'); - expect(gitConfigMayExecutePrograms(enabled)).toBe(false); - const disabled = makeRepo('fsm-false', '[core]\n\tfsmonitor = false\n'); - expect(gitConfigMayExecutePrograms(disabled)).toBe(false); - }); - - it('does not flag boolean core.pager values (no repo-supplied program)', () => { - const off = makeRepo('pager-false', '[core]\n\tpager = false\n'); - expect(gitConfigMayExecutePrograms(off)).toBe(false); - const on = makeRepo('pager-true', '[core]\n\tpager = true\n'); - expect(gitConfigMayExecutePrograms(on)).toBe(false); - const program = makeRepo('pager-prog', '[core]\n\tpager = less -R\n'); - expect(gitConfigMayExecutePrograms(program)).toBe(true); - }); - - it('does not flag empty values or non-executing keys', () => { - const repo = makeRepo( - 'benign', - '[diff]\n\texternal =\n[pager]\n\tlog =\n[core]\n\teditor = vim\n[init]\n\tdefaultBranch = main\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('ignores comments', () => { - const repo = makeRepo( - 'comments', - '# diff.external = /tmp/evil\n; pager.log = evil\n[core]\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('strips comments inside sections (trailing and whole-line)', () => { - // git strips trailing comments, so the value is the boolean `false` — - // a probe that kept the comment text would fail the boolean exemption - // and spuriously confirm every whitelisted command. - const trailing = makeRepo( - 'inline-comment', - '[pager]\n\tlog = false # disabled\n', - ); - expect(gitConfigMayExecutePrograms(trailing)).toBe(false); - const wholeLine = makeRepo('whole-line-comment', '[pager]\n# log = evil\n'); - expect(gitConfigMayExecutePrograms(wholeLine)).toBe(false); - }); - - it('parses inline `[section] key = value` lines', () => { - const dirty = makeRepo('inline-dirty', '[diff] external = /tmp/evil\n'); - expect(gitConfigMayExecutePrograms(dirty)).toBe(true); - const clean = makeRepo('inline-clean', '[core] bare = false\n'); - expect(gitConfigMayExecutePrograms(clean)).toBe(false); - }); - - it('flags core.gitProxy (git:// transport helper)', () => { - const repo = makeRepo('gitproxy', '[core]\n\tgitProxy = /tmp/evil\n'); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - describe('core.hooksPath overrides', () => { - function writeExecutableHook(hooksDir: string, hook: string): void { - fs.mkdirSync(hooksDir, { recursive: true }); - const hookPath = path.join(hooksDir, hook); - fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); - fs.chmodSync(hookPath, 0o755); - } - - it('flags relative overrides pointing at executable trigger hooks', () => { - const repo = makeRepo( - 'hookspath-dirty', - '[core]\n\thooksPath = .myhooks\n', - ); - writeExecutableHook(path.join(repo, '.myhooks'), 'post-index-change'); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('keeps husky-style overrides read-only when no trigger hooks exist', () => { - // husky / lefthook installs set core.hooksPath in every repo; their - // hook dirs hold commit-time hooks only, so whitelisted read-only - // commands must keep their auto-approval. - const repo = makeRepo('husky', '[core]\n\thooksPath = .husky/_\n'); - const hooksDir = path.join(repo, '.husky', '_'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(path.join(hooksDir, 'pre-commit'), '#!/bin/sh\n', { - mode: 0o755, - }); - fs.writeFileSync(path.join(hooksDir, 'commit-msg'), '#!/bin/sh\n', { - mode: 0o755, - }); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('resolves relative overrides against the worktree root, not the cwd', () => { - const repo = makeRepo( - 'hookspath-root', - '[core]\n\thooksPath = hooks-dir\n', - ); - writeExecutableHook(path.join(repo, 'hooks-dir'), 'fsmonitor-watchman'); - // Decoy with the same name below the probe's cwd: git never consults - // it, so its emptiness must not hide the root hit. - const nested = path.join(repo, 'sub'); - fs.mkdirSync(path.join(nested, 'hooks-dir'), { recursive: true }); - expect(gitConfigMayExecutePrograms(nested)).toBe(true); - }); - - it('flags absolute overrides pointing at executable trigger hooks', () => { - const external = path.join(root, 'external-hooks'); - writeExecutableHook(external, 'post-index-change'); - const repo = makeRepo( - 'hookspath-abs', - `[core]\n\thooksPath = ${external}\n`, - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('expands a leading ~ to the user home', () => { - writeExecutableHook(path.join(root, 'home-hooks'), 'post-index-change'); - const homedir = vi.spyOn(os, 'homedir').mockReturnValue(root); - try { - const repo = makeRepo( - 'hookspath-tilde', - '[core]\n\thooksPath = ~/home-hooks\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - } finally { - homedir.mockRestore(); - } - }); - - it('does not flag an empty override (git then runs no hooks at all)', () => { - const repo = makeRepo('hookspath-empty', '[core]\n\thooksPath =\n'); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - // An executable default hook changes nothing: an empty core.hooksPath - // disables hooks entirely, so git never consults `.git/hooks`. - writeExecutableHook( - path.join(repo, '.git', 'hooks'), - 'post-index-change', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('ignores the default hooks directory under a non-empty override', () => { - // core.hooksPath redirects hook lookup — the default hooks directory - // is never consulted, whatever it contains. - const repo = makeRepo( - 'hookspath-defaults', - '[core]\n\thooksPath = .myhooks\n', - ); - writeExecutableHook( - path.join(repo, '.git', 'hooks'), - 'post-index-change', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('fails closed on undecodable override values', () => { - const repo = makeRepo( - 'hookspath-bad', - '[core]\n\thooksPath = "unterminated\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('fails closed on ~user overrides it cannot resolve', () => { - const repo = makeRepo( - 'hookspath-user', - '[core]\n\thooksPath = ~other/hooks\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - // fs.accessSync(X_OK) is not meaningful on Windows — every file is - // "executable" there — so only assert the negative case elsewhere. - it.skipIf(process.platform === 'win32')( - 'does not flag non-executable trigger hooks under an override', - () => { - const repo = makeRepo( - 'hookspath-noexec', - '[core]\n\thooksPath = .myhooks\n', - ); - const hooksDir = path.join(repo, '.myhooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync( - path.join(hooksDir, 'post-index-change'), - '#!/bin/sh\ntouch /tmp/evil\n', - ); - fs.chmodSync(path.join(hooksDir, 'post-index-change'), 0o644); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }, - ); - }); - - it('flags executable hooks that read-only commands trigger', () => { - for (const hook of ['post-index-change', 'fsmonitor-watchman']) { - const repo = makeRepo(`hook-${hook}`, ''); - const hooksDir = path.join(repo, '.git', 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - const hookPath = path.join(hooksDir, hook); - fs.writeFileSync(hookPath, '#!/bin/sh\ntouch /tmp/evil\n'); - fs.chmodSync(hookPath, 0o755); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - } - }); - - // fs.accessSync(X_OK) is not meaningful on Windows — every file is - // "executable" there — so only assert the negative case elsewhere. - it.skipIf(process.platform === 'win32')( - 'does not flag non-executable or unrelated hooks', - () => { - const repo = makeRepo('hook-inactive', ''); - const hooksDir = path.join(repo, '.git', 'hooks'); - fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync( - path.join(hooksDir, 'post-index-change'), - '#!/bin/sh\ntouch /tmp/evil\n', - ); - fs.chmodSync(path.join(hooksDir, 'post-index-change'), 0o644); - fs.writeFileSync( - path.join(hooksDir, 'pre-commit.sample'), - '#!/bin/sh\n', - { mode: 0o755 }, - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }, - ); - - it('flags include/includeIf entries instead of resolving them', () => { - const inc = makeRepo('include', '[include]\n\tpath = ../other-config\n'); - expect(gitConfigMayExecutePrograms(inc)).toBe(true); - const incIf = makeRepo( - 'include-if', - '[includeIf "gitdir:~/src/"]\n\tpath = /tmp/other-config\n', - ); - expect(gitConfigMayExecutePrograms(incIf)).toBe(true); - }); - - it.each(['clean', 'smudge', 'process'])( - 'flags filter %s programs (git diff triggers them)', - (key) => { - const repo = makeRepo( - `filter-${key}`, - `[filter "evil"]\n\t${key} = /tmp/evil\n`, - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }, - ); - - it('flags ext:: url..insteadOf rewrite targets', () => { - const repo = makeRepo( - 'url-insteadof', - '[url "ext::sh -c evil"]\n\tinsteadOf = https://example.com/\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('flags ext:: url rewrites with an EMPTY insteadOf (match-all prefix)', () => { - // git treats an empty insteadOf as matching every URL. - const repo = makeRepo( - 'empty-insteadof', - '[url "ext::sh -c evil"]\n\tinsteadOf =\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('ignores ext:: url subsections without an insteadOf rewrite', () => { - const repo = makeRepo( - 'url-push-insteadof', - '[url "ext::sh -c evil"]\n\tpushInsteadOf = https://example.com/\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('flags protocol allow lifts only when they can enable ext::', () => { - const always = makeRepo( - 'proto-always', - '[protocol "ext"]\n\tallow = always\n', - ); - expect(gitConfigMayExecutePrograms(always)).toBe(true); - const user = makeRepo('proto-user', '[protocol]\n\tallow = user\n'); - expect(gitConfigMayExecutePrograms(user)).toBe(true); - const undecodable = makeRepo( - 'proto-undecodable', - '[protocol "ext"]\n\tallow = "unterminated\n', - ); - expect(gitConfigMayExecutePrograms(undecodable)).toBe(true); - const never = makeRepo( - 'proto-never', - '[protocol "ext"]\n\tallow = never\n', - ); - expect(gitConfigMayExecutePrograms(never)).toBe(false); - const file = makeRepo( - 'proto-file', - '[protocol "file"]\n\tallow = always\n', - ); - expect(gitConfigMayExecutePrograms(file)).toBe(false); - }); - - it('does not flag boolean pager overrides', () => { - const repo = makeRepo( - 'pager-bool', - '[pager]\n\tlog = false\n\tdiff = true\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('fails closed on implausibly large config files', () => { - const repo = makeRepo('huge', ''); - fs.writeFileSync( - path.join(repo, '.git', 'config'), - `[core]\n\tbare = false\n# ${'x'.repeat(1 << 20)}\n`, - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('joins continued lines before checking values', () => { - // git joins values across a backslash continuation; the dirty evidence - // only exists AFTER the join. - const dirty = makeRepo( - 'cont-ext', - '[remote "origin"]\n\turl = ext\\\n::sh -c evil %S %u\n', - ); - expect(gitConfigMayExecutePrograms(dirty)).toBe(true); - const clean = makeRepo('cont-bool', '[pager]\n\tlog = fal\\\nse\n'); - expect(gitConfigMayExecutePrograms(clean)).toBe(false); - }); - - it('reads config.worktree of the main checkout (extensions.worktreeConfig)', () => { - const repo = makeRepo( - 'wtcfg', - '[extensions]\n\tworktreeConfig = false # ignored\\\n\tworktreeConfig = 0x\\\n1k # enabled\n', - ); - fs.writeFileSync( - path.join(repo, '.git', 'config.worktree'), - '[diff]\n\texternal = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('ignores config.worktree unless worktreeConfig is enabled', () => { - const repo = makeRepo( - 'disabled-worktree-config', - '[extensions]\n\tworktreeConfig = 0x0k\n', - ); - fs.writeFileSync( - path.join(repo, '.git', 'config.worktree'), - '[diff]\n\texternal = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - describe('linked worktrees and submodules', () => { - it('reads config.worktree and the common config via the .git file', () => { - const main = makeRepo( - 'main-repo', - '[core]\n\tbare = false\n[extensions]\n\tworktreeConfig = true\n', - ); - const commonGitDir = path.join(main, '.git'); - - // Linked worktree: /.git is a file pointing into - //
/.git/worktrees/. - const wtGitDir = path.join(commonGitDir, 'worktrees', 'wt'); - fs.mkdirSync(wtGitDir, { recursive: true }); - fs.writeFileSync(path.join(wtGitDir, 'commondir'), '../..\n'); - const wt = path.join(root, 'wt-clean'); - fs.mkdirSync(wt, { recursive: true }); - fs.writeFileSync(path.join(wt, '.git'), `gitdir: ${wtGitDir}\n`); - expect(gitConfigMayExecutePrograms(wt)).toBe(false); - - // Planted key in the per-worktree config. - fs.writeFileSync( - path.join(wtGitDir, 'config.worktree'), - '[core]\n\tpager = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(wt)).toBe(true); - - // Planted key in the common config instead. - fs.rmSync(path.join(wtGitDir, 'config.worktree')); - fs.writeFileSync( - path.join(commonGitDir, 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(wt)).toBe(true); - }); - - it('reads the gitdir target config for submodule-style .git files', () => { - const store = path.join(root, 'store', 'modules', 'sub'); - fs.mkdirSync(store, { recursive: true }); - fs.writeFileSync( - path.join(store, 'config'), - '[core]\n\tfsmonitor = /tmp/evil\n', - ); - const sub = path.join(root, 'sub-checkout'); - fs.mkdirSync(sub, { recursive: true }); - // Real git writes RELATIVE pointers for submodules; resolution is - // against the pointer's containing directory. - fs.writeFileSync( - path.join(sub, '.git'), - `gitdir: ${path.relative(sub, store)}\n`, - ); - expect(gitConfigMayExecutePrograms(sub)).toBe(true); - }); - }); - - it('fails closed when the config exists but cannot be read', () => { - const repo = path.join(root, 'unreadable'); - fs.mkdirSync(path.join(repo, '.git', 'config'), { recursive: true }); - // `.git/config` is a directory → readFileSync throws EISDIR. - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - // chmod(0o000) does not block reads on Windows (only the owner-write - // bit is honored) or for root (DAC bypass), so the simulation only - // means EACCES elsewhere. - it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( - 'fails closed when the .git pointer file cannot be read', - () => { - const repo = path.join(root, 'bad-pointer'); - fs.mkdirSync(path.join(repo, '.git'), { recursive: true }); - fs.rmdirSync(path.join(repo, '.git')); - fs.mkdirSync(path.join(repo, '.git.d'), { recursive: true }); - fs.writeFileSync(path.join(repo, '.git'), 'gitdir: .git.d\n'); - fs.chmodSync(path.join(repo, '.git'), 0o000); - try { - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - } finally { - fs.chmodSync(path.join(repo, '.git'), 0o644); - } - }, - ); - - it('fails closed on an unparseable .git pointer file', () => { - const repo = path.join(root, 'garbage-pointer'); - fs.mkdirSync(repo, { recursive: true }); - fs.writeFileSync(path.join(repo, '.git'), 'not a gitdir pointer\n'); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('flags partially quoted ext:: url values (git concatenates segments)', () => { - const trailing = makeRepo( - 'ext-partial-quote', - '[remote "origin"]\n\turl = "ext::/tmp/evil.sh" x\n', - ); - expect(gitConfigMayExecutePrograms(trailing)).toBe(true); - const split = makeRepo( - 'ext-split-quotes', - '[remote "origin"]\n\turl = "ext""::/tmp/evil.sh"\n', - ); - expect(gitConfigMayExecutePrograms(split)).toBe(true); - }); - - it('flags url. subsections whose ext:: prefix is escape-encoded', () => { - const repo = makeRepo( - 'url-subsection-escape', - '[url "e\\xt::/tmp/evil.sh "]\n\tinsteadOf = https://example.com/\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); - - it('probes through a symlinked workspace directory', () => { - if (process.platform === 'win32') return; // symlink perms differ - const repo = makeRepo('sym-repo', '[diff]\n\texternal = /tmp/evil\n'); - // Point the link at a NESTED path: without the realpathSync in the - // probe the walk would climb the link's own ancestors, never find the - // repo, and fail open. - const nested = path.join(repo, 'src'); - fs.mkdirSync(nested); - const link = path.join(root, 'ws-link'); - fs.symlinkSync(nested, link); - expect(gitConfigMayExecutePrograms(link)).toBe(true); - }); - - it('fails closed when the repo search depth is exhausted', () => { - // A CLEAN config: discovery would return false, so the `true` verdict - // uniquely pins the exhaustion path (a raised/removed depth cap would - // otherwise reach the repo and read the clean config undetected). - // Single-char segments keep 70 levels under Windows MAX_PATH. - const repo = makeRepo('deep-repo', '[core]\n\tbare = false\n'); - let deep = repo; - for (let i = 0; i < 70; i++) { - deep = path.join(deep, 'd'); - } - fs.mkdirSync(deep, { recursive: true }); - expect(gitConfigMayExecutePrograms(deep)).toBe(true); - }); - - it('probes the target of a .git/commondir redirect (main checkout)', () => { - // git honors a `.git/commondir` file and reads the pointed-to - // directory's config as the common config — the probe must too. - const evilCommon = makeRepo( - 'common-evil', - '[core]\n\tfsmonitor = /tmp/evil\n', - ); - const repo = makeRepo('redirected', ''); - fs.writeFileSync( - path.join(repo, '.git', 'commondir'), - `${path.join(evilCommon, '.git')}\n`, - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - - // Clean redirect target keeps the repo clean. - const cleanCommon = makeRepo('common-clean', '[core]\n\tbare = false\n'); - fs.writeFileSync( - path.join(repo, '.git', 'commondir'), - `${path.join(cleanCommon, '.git')}\n`, - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(false); - }); - - it('probes HEAD+commondir git directories git itself accepts', () => { - // A HEAD-plus-commondir pair is a git directory to git even without - // objects/refs (linked-worktree admin dirs — and attacker-shaped - // stand-ins with a config.worktree). - const stand = path.join(root, 'stand-head-commondir'); - fs.mkdirSync(stand, { recursive: true }); - fs.writeFileSync(path.join(stand, 'HEAD'), 'ref: refs/heads/main\n'); - const target = makeRepo( - 'head-commondir-target', - '[diff]\n\texternal = /tmp/evil\n', - ); - fs.writeFileSync( - path.join(stand, 'commondir'), - `${path.join(target, '.git')}\n`, - ); - expect(gitConfigMayExecutePrograms(stand)).toBe(true); - }); - - it('reads the config of a git directory the cwd stands in', () => { - // Submodule storage layout: `/.git/modules/` is itself a - // git directory; git reads ITS config while standing in it, not the - // superproject's. - const moduleGitDir = path.join(root, 'super', '.git', 'modules', 'sub'); - fs.mkdirSync(path.join(moduleGitDir, 'objects'), { recursive: true }); - fs.mkdirSync(path.join(moduleGitDir, 'refs'), { recursive: true }); - fs.writeFileSync(path.join(moduleGitDir, 'HEAD'), 'ref: refs/heads/main\n'); - fs.writeFileSync(path.join(moduleGitDir, 'config'), '[core]\n'); - // Clean module config, clean superproject config. - const superConfig = path.join(root, 'super', '.git', 'config'); - fs.writeFileSync(superConfig, '[core]\n'); - expect(gitConfigMayExecutePrograms(moduleGitDir)).toBe(false); - - fs.writeFileSync( - path.join(moduleGitDir, 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(moduleGitDir)).toBe(true); - }); - - it('probes the commondir target of a standing git directory', () => { - // cwd stands in a HEAD+objects+refs dir whose commondir points at a - // NON-ANCESTOR git dir — the walk-up never reaches the target, only - // the commondir read does. - const target = makeRepo( - 'standing-commondir-target', - '[diff]\n\texternal = /tmp/evil\n', - ); - const stand = path.join(root, 'standing-gitdir'); - fs.mkdirSync(path.join(stand, 'objects'), { recursive: true }); - fs.mkdirSync(path.join(stand, 'refs'), { recursive: true }); - fs.writeFileSync(path.join(stand, 'HEAD'), 'ref: refs/heads/main\n'); - fs.writeFileSync(path.join(stand, 'config'), '[core]\n'); - fs.writeFileSync( - path.join(stand, 'commondir'), - `${path.join(target, '.git')}\n`, - ); - expect(gitConfigMayExecutePrograms(stand)).toBe(true); - - // Same git dir without a commondir stays clean. - fs.rmSync(path.join(stand, 'commondir')); - expect(gitConfigMayExecutePrograms(stand)).toBe(false); - }); - - it('fails closed on section headers it cannot parse', () => { - // `]` inside a quoted subsection is valid to git but opaque to the - // minimal parser — must not silently drop the entries below it. - const repo = makeRepo( - 'bracket-subsection', - '[diff "a]b"]\n\ttextconv = /tmp/evil\n', - ); - expect(gitConfigMayExecutePrograms(repo)).toBe(true); - }); -}); diff --git a/packages/core/src/utils/git-config-safety.ts b/packages/core/src/utils/git-config-safety.ts deleted file mode 100644 index 767c205b297..00000000000 --- a/packages/core/src/utils/git-config-safety.ts +++ /dev/null @@ -1,656 +0,0 @@ -/** - * @license - * Copyright 2026 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -/** - * Repository-local git config execution probe. - * - * The shell read-only classifier auto-approves whitelisted git sub-commands - * (`status`, `diff`, `log`, ...) based purely on command text. But git can - * execute programs that are *configured in the repository's local config* - * while running those otherwise read-only commands: - * - * - `diff.external`, `diff..textconv`, `diff..command` — - * diff / log / show - * - `core.fsmonitor` — status - * - `core.alternateRefsCommand` — `log --alternate-refs` - * - `core.pager`, `pager.` — log / show / diff / blame on a TTY - * - `core.askpass`, `credential.helper`, `core.sshCommand`, - * `remote..proxy`, `remote..uploadpack`, `ext::` remote - * URLs, `protocol..allow` lifts, `core.gitProxy` — `remote - * show` network/transport helpers - * - `gpg.program` — signature verification helpers - * - `core.hooksPath` — redirects hook resolution; the redirected - * directory is probed for read-only-triggered hooks the same way as - * the default hooks directory - * - * A `.git/config` planted by an attacker (prompt-injection chain with local - * file write, shared workspace) could therefore turn an auto-approved - * "read-only" command into arbitrary code execution. See issue #8575. - * - * Scope: repository-local config only (`.git/config`, `config.worktree` - * where git reads it — the main checkout under `extensions.worktreeConfig` - * and linked worktrees — and the common-dir config of linked worktrees). - * Global/system config is the user's own deliberate setup and is not an - * attack surface of cloned repositories — it is intentionally not probed. - * - * Discovery mirrors git's: each ancestor is checked for a `.git` entry, and - * the directory itself is checked as a git directory (bare repositories and - * submodule storage dirs like `/.git/modules/`, whose own - * config git reads when it stands in one). - * - * The probe is synchronous (bounded stat walk + small file reads) so it can - * be shared by the AST classifier and the synchronous regex fallback - * without changing either API's async shape. - * - * Directory changes are tracked by both classifiers: a `cd`/`pushd` segment - * moves the probe to the repository the following git segments actually run - * in, and an unresolvable target (expansions, `~`, flag-only forms, a - * `||`-diverged chain) downgrades later git segments the same way a dirty - * config does — including when the classifier was called without a cwd, - * since the effective repository is then unknown. - */ - -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -/** Options accepted by the read-only classifiers. */ -export interface ShellReadOnlyCheckOptions { - /** - * Directory the command will execute in. When provided, git commands that - * would otherwise classify as read-only are downgraded (require - * confirmation) if the repository-local config reachable from this - * directory contains keys that make git execute a program. - */ - cwd?: string; - /** - * A directory-changing segment (`cd`/`pushd`) precedes this command and - * its target could not be resolved statically. Downgrades git commands - * the same way a dirty config does — the effective repo is unknown. - */ - unknownDir?: boolean; -} - -/** Bound on the upward search for the enclosing `.git`. */ -const MAX_REPO_SEARCH_DEPTH = 64; - -/** Config files larger than this fail closed instead of being read. */ -const MAX_CONFIG_FILE_BYTES = 1 << 20; // 1 MiB - -/** - * Flat `section.key` names (lowercased — git config names are - * case-insensitive) whose value names a program git may execute while - * running a whitelisted read-only sub-command. - */ -const PROGRAM_VALUED_KEYS = new Set([ - 'core.alternaterefscommand', // alternate-refs lister (`git log --alternate-refs`) - 'core.askpass', // credential prompts (e.g. `git remote show `) - 'core.fsmonitor', // fsmonitor hook command (`git status`) - 'core.gitproxy', // git:// transport proxy (`git remote show git://…`) - 'core.pager', // pager program for log / show / diff output - 'core.sshcommand', // ssh override for authenticated remotes - 'credential.helper', // credential helpers during network auth - 'diff.external', // external diff program - 'gpg.program', // signature verification helper -]); - -interface ConfigEntry { - /** Lowercased section name. */ - section: string; - /** Case-sensitive subsection name, if any. */ - subsection: string | null; - /** Lowercased key name. */ - key: string; - /** Raw value text (quotes left intact). */ - value: string; -} - -const SECTION_HEADER = /^([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*$/; - -/** - * Minimal git config parser: enough to identify section/key pairs and raw - * values. Understands `[section]`, `[section "subsection"]`, and the - * deprecated `[section.subsection]` headers (including the inline - * `[section] key = value` form), `key = value` lines, continuations, and - * `#` / `;` comments. Includes are not resolved — `include` / `includeIf` - * entries make the probe fail closed instead, because their targets can - * live outside `.git` (e.g. tracked files). - */ -function parseGitConfig(content: string): ConfigEntry[] { - const entries: ConfigEntry[] = []; - let section = ''; - let subsection: string | null = null; - - const recordEntry = (text: string): void => { - const eq = text.indexOf('='); - const key = (eq < 0 ? text : text.slice(0, eq)).trim().toLowerCase(); - const value = eq < 0 ? '' : text.slice(eq + 1).trim(); - if (!key) return; - entries.push({ section, subsection, key, value }); - }; - const lines: string[] = []; - let continued = ''; - - for (const rawLine of content.split(/\r?\n/)) { - const line = continued + rawLine; - let quoted = false; - let escaped = false; - let comment = line.length; - for (let i = 0; i < line.length; i++) { - const char = line[i]!; - if (escaped) { - escaped = false; - } else if (char === '\\') { - escaped = true; - } else if (char === '"') { - quoted = !quoted; - } else if (!quoted && (char === '#' || char === ';')) { - comment = i; - break; - } - } - const logicalContent = line.slice(0, comment); - let trailingBackslashes = 0; - while ( - logicalContent[logicalContent.length - 1 - trailingBackslashes] === '\\' - ) { - trailingBackslashes++; - } - if (trailingBackslashes % 2 === 1) { - continued = logicalContent.slice(0, -1); - } else { - lines.push(logicalContent); - continued = ''; - } - } - if (continued) lines.push(continued); - - for (const rawLine of lines) { - const line = rawLine.trim(); - if (!line || line.startsWith('#') || line.startsWith(';')) continue; - - if (line.startsWith('[')) { - const close = line.indexOf(']'); - if (close < 0) continue; // unclosed header — git aborts config load - const match = line.slice(1, close).trim().match(SECTION_HEADER); - if (!match) { - // Valid to git but opaque here (e.g. `]` inside a quoted - // subsection) — fail closed. - throw new Error('unrecognized git config section header'); - } - section = match[1]!.toLowerCase(); - subsection = match[2] ?? null; - if (subsection === null) { - // Deprecated `[section.subsection]` header: git splits at the - // FIRST dot and case-folds the subsection (the quoted form is - // case-sensitive instead). `section` is already lowercased, so - // the slice carries both behaviors. - const dot = section.indexOf('.'); - if (dot > 0) { - subsection = section.slice(dot + 1); - section = section.slice(0, dot); - } - } - // Inline form: `[section] key = value` on the same line. - const rest = line.slice(close + 1).trim(); - if (rest) recordEntry(rest); - continue; - } - - if (!section) continue; - recordEntry(line); - } - - return entries; -} - -/** - * Decode a raw config value the way git does: quoted and bare segments - * concatenate (`url = "ext::"sh` is `ext::sh` to git), and the `\b \n \t - * \" \\` escapes apply inside quoted segments. Returns `null` when the - * value cannot be decoded (unbalanced quote, other escape) — callers fail - * closed on it. - */ -function decodeGitConfigValue(raw: string): string | null { - const value = raw.trim(); - let decoded = ''; - let quoted = false; - for (let i = 0; i < value.length; i++) { - const char = value[i]!; - if (!quoted) { - if (char === '"') { - quoted = true; - } else { - decoded += char; - } - continue; - } - if (char === '"') { - quoted = false; - } else if (char === '\\') { - switch (value[++i]) { - case 'b': - decoded += '\b'; - break; - case 'n': - decoded += '\n'; - break; - case 't': - decoded += '\t'; - break; - case '"': - decoded += '"'; - break; - case '\\': - decoded += '\\'; - break; - default: - return null; - } - } else { - decoded += char; - } - } - return quoted ? null : decoded; -} - -/** True when any entry names a program git would execute. */ -function entriesMayExecutePrograms(entries: ConfigEntry[]): boolean { - for (const entry of entries) { - const value = decodeGitConfigValue(entry.value); - - // `url..insteadOf` rewrites remote URLs before connecting; an - // ext:: rewrite target executes a program. Checked before the - // empty-value skip: git treats an EMPTY insteadOf as a match-all - // rewrite prefix. Git decodes subsection escapes first (`\x` → `x`), - // so over-approximate by dropping backslashes before comparing. - if ( - entry.section === 'url' && - entry.subsection !== null && - entry.key === 'insteadof' && - entry.subsection.replace(/\\/g, '').startsWith('ext::') - ) { - return true; - } - - // `protocol.allow` / `protocol.ext.allow` lifts the default block on - // the program transport — any value that is not definitely `never` - // enables it, and a command-line ext:: URL passed to a whitelisted - // command then executes a program. - if ( - entry.section === 'protocol' && - entry.key === 'allow' && - (entry.subsection === null || entry.subsection === 'ext') && - value?.toLowerCase() !== 'never' - ) { - return true; - } - - if (value === '') continue; - - // Include targets can live outside `.git` (e.g. files tracked in the - // working tree), so flag them instead of resolving them. - if (entry.section === 'include' || entry.section === 'includeif') { - return true; - } - - if (entry.subsection === null) { - // `[pager] = ` overrides live in the flat section. - if (entry.section === 'pager') { - // true/false enable or disable paging without naming a program. - if (value !== null && /^(?:true|false)$/i.test(value)) continue; - return true; - } - const name = `${entry.section}.${entry.key}`; - if (!PROGRAM_VALUED_KEYS.has(name)) continue; - // core.fsmonitor true/false selects the built-in daemon or disables - // monitoring; core.pager true/false disables paging or falls back to - // $PAGER — neither executes a repo-config-supplied program. - if ( - (name === 'core.fsmonitor' || name === 'core.pager') && - value !== null && - /^(?:true|false)$/i.test(value) - ) { - continue; - } - return true; - } - - switch (entry.section) { - case 'diff': - if (entry.key === 'textconv' || entry.key === 'command') return true; - break; - case 'credential': - if (entry.key === 'helper') return true; - break; - case 'gpg': - if (entry.key === 'program') return true; - break; - case 'remote': - if ( - entry.key === 'proxy' || - entry.key === 'uploadpack' || - entry.key === 'receivepack' - ) { - return true; - } - if (entry.key === 'url' && (value === null || /^ext::/.test(value))) { - return true; - } - break; - case 'filter': - // `git diff` cleans worktree content through the configured filter - // when comparing against the index — no flag needed. - if ( - entry.key === 'clean' || - entry.key === 'smudge' || - entry.key === 'process' - ) { - return true; - } - break; - case 'pager': - // Dotted `[pager.]` headers only reach this branch via the - // dot-form split above; keep the flat-section catch-all's verdict. - return true; - default: - break; - } - } - return false; -} - -/** - * Hooks that fire while whitelisted read-only commands run: an index - * refresh runs `post-index-change`, and `git status` consults - * `fsmonitor-watchman` when monitoring is hook-based. - */ -const READ_ONLY_TRIGGERED_HOOKS = ['post-index-change', 'fsmonitor-watchman']; - -function hooksMayExecutePrograms(hooksDir: string): boolean { - for (const hook of READ_ONLY_TRIGGERED_HOOKS) { - try { - fs.accessSync(path.join(hooksDir, hook), fs.constants.X_OK); - return true; - } catch { - // Hook absent or not executable. - } - } - return false; -} - -/** - * Collect the directories `core.hooksPath` entries redirect hook - * resolution to, resolved the way git does: a leading `~` expands to the - * user's home, and relative paths anchor at `root` — the worktree root - * for repositories found through a `.git` entry, the git dir itself when - * the probe stands in one. An empty value disables hooks entirely and - * contributes nothing. Returns `null` when a value cannot be decoded or - * resolved — callers fail closed. - */ -function hooksPathDirectories( - entries: ConfigEntry[], - root: string, -): string[] | null { - const dirs: string[] = []; - for (const entry of entries) { - if ( - entry.section !== 'core' || - entry.subsection !== null || - entry.key !== 'hookspath' - ) { - continue; - } - const value = decodeGitConfigValue(entry.value); - if (value === null) return null; - if (value === '') continue; // git runs no hooks at all - let expanded = value; - if (expanded.startsWith('~')) { - // git expands `~` and `~/...` to the user's home; `~user` lookups - // cannot be reproduced here. - if (expanded.length > 1 && expanded[1] !== '/') return null; - expanded = path.join(os.homedir(), expanded.slice(1)); - } - dirs.push( - path.isAbsolute(expanded) ? expanded : path.resolve(root, expanded), - ); - } - return dirs; -} - -function worktreeConfigEnabled(entries: ConfigEntry[]): boolean { - let enabled = false; - for (const entry of entries) { - if ( - entry.section === 'extensions' && - entry.subsection === null && - entry.key === 'worktreeconfig' - ) { - const value = decodeGitConfigValue(entry.value); - // Git also accepts hexadecimal integers and k/m/g suffixes. Treat - // anything except its definite false forms as enabled (fail closed). - enabled = - value === null || - !/^(?:false|no|off|[+-]?(?:0+|0x0+)[kmg]?)$/i.test(value); - } - } - return enabled; -} - -/** - * git's primary discovery rule: a directory that holds a HEAD file plus - * objects and refs/packed-refs is itself a git directory (is_git_directory - * in setup.c) — a bare repository, or a submodule's storage dir like - * `/.git/modules/` whose own config git reads while standing - * in it. git also accepts a HEAD-plus-commondir pair (linked-worktree - * admin directories — and attacker-shaped stand-ins), so mirror that. - */ -function isGitDirectory(dir: string): boolean { - try { - if (!fs.statSync(path.join(dir, 'HEAD')).isFile()) return false; - try { - if (fs.statSync(path.join(dir, 'commondir')).isFile()) return true; - } catch { - // No commondir — fall through to the objects/refs requirement. - } - if (!fs.statSync(path.join(dir, 'objects')).isDirectory()) return false; - try { - if (fs.statSync(path.join(dir, 'refs')).isDirectory()) return true; - } catch { - // No refs dir — packed-refs alone also qualifies. - } - return fs.statSync(path.join(dir, 'packed-refs')).isFile(); - } catch { - return false; - } -} - -/** - * Locate the repository-local config files for the repo enclosing `cwd`: - * - * - `.git` directory → `.git/config` - * - `.git` file (`gitdir: `, linked worktree or submodule) → - * `/config`, `/config.worktree`, and the common dir's - * `config` when a `commondir` file marks a linked worktree. - * - `cwd` itself (or an ancestor) is a git directory → its `config`, the - * commondir `config` when present, and `config.worktree`. - * - * Also reports the directory relative `core.hooksPath` values resolve - * against — git anchors them at the worktree root (the directory holding - * the `.git` entry); when the probe stands in a git directory itself, the - * git dir stands in. - * - * Throws when the search cannot conclude (unreadable pointer, search depth - * exhausted) — the caller converts that into "may execute programs". - */ -function findLocalGitConfigFiles(cwd: string): { - files: string[]; - hooksPathRoot: string; -} { - let dir = path.resolve(cwd); - try { - // git resolves the physical cwd; a symlink between the execution - // directory and the repo root must not send the walk up the link's - // ancestors instead of the target's. - dir = fs.realpathSync(dir); - } catch { - // Absent/unresolvable path — keep the logical form. - } - - for (let depth = 0; ; depth++) { - if (depth >= MAX_REPO_SEARCH_DEPTH) { - // git's discovery has no depth cap: exhausting the budget before - // reaching the filesystem root is "repository unknown", not "no - // repository" — fail closed. - throw new Error('repository search depth exhausted'); - } - - const gitPath = path.join(dir, '.git'); - let stat: fs.Stats | undefined; - try { - stat = fs.statSync(gitPath); - } catch { - // No `.git` here; check the directory itself, then walk up. - } - - if (stat) { - if (stat.isDirectory()) { - // With extensions.worktreeConfig enabled, git also reads - // `config.worktree` for the MAIN worktree — probe both. A - // `commondir` file redirects the common config git reads, so - // probe the pointed-to directory's config as well. - const files = [path.join(gitPath, 'config')]; - try { - const commonDir = fs - .readFileSync(path.join(gitPath, 'commondir'), 'utf8') - .trim(); - if (commonDir) { - files.push(path.join(path.resolve(gitPath, commonDir), 'config')); - } - } catch { - // No commondir — the repo's own config is the common config. - } - files.push(path.join(gitPath, 'config.worktree')); - return { files, hooksPathRoot: dir }; - } - if (stat.isFile()) { - let pointer: string; - try { - pointer = fs.readFileSync(gitPath, 'utf8'); - } catch { - // `.git` exists but cannot be read — fail closed (the outer - // catch converts this into "may execute programs"). - throw new Error(`unreadable git pointer file: ${gitPath}`); - } - const match = pointer.match(/^\s*gitdir:\s*(.+?)\s*$/m); - if (!match) { - // Unparseable pointer — fail closed like the unreadable case. - throw new Error(`unparseable git pointer file: ${gitPath}`); - } - const gitDir = path.resolve(dir, match[1]!); - const files = [path.join(gitDir, 'config')]; - try { - const commonDir = fs - .readFileSync(path.join(gitDir, 'commondir'), 'utf8') - .trim(); - if (commonDir) { - files[0] = path.join(path.resolve(gitDir, commonDir), 'config'); - } - } catch { - // Submodule git dir (no commondir) — the two paths above suffice. - } - files.push(path.join(gitDir, 'config.worktree')); - return { files, hooksPathRoot: dir }; - } - } - - if (isGitDirectory(dir)) { - const files = [path.join(dir, 'config')]; - try { - const commonDir = fs - .readFileSync(path.join(dir, 'commondir'), 'utf8') - .trim(); - if (commonDir) { - files.push(path.join(path.resolve(dir, commonDir), 'config')); - } - } catch { - // No commondir — the git dir's own config is the common config. - } - files.push(path.join(dir, 'config.worktree')); - return { files, hooksPathRoot: dir }; - } - - const parent = path.dirname(dir); - // Reached the filesystem root — no repo. - if (parent === dir) return { files: [], hooksPathRoot: dir }; - dir = parent; - } -} - -/** - * True when the repository-local git config reachable from `cwd` contains - * keys that make git execute a program while running a whitelisted - * read-only sub-command. - * - * Fail-closed: a config file that exists but cannot be read (or any - * unexpected probe error) reports `true` so the command is confirmed - * instead of auto-approved. - */ -export function gitConfigMayExecutePrograms(cwd: string | undefined): boolean { - if (!cwd) return false; - - try { - let readWorktreeConfig = false; - const hooksDirs = new Set(); - const { files, hooksPathRoot } = findLocalGitConfigFiles(cwd); - for (const file of files) { - if (path.basename(file) === 'config') { - hooksDirs.add(path.join(path.dirname(file), 'hooks')); - } - if (file.endsWith('config.worktree') && !readWorktreeConfig) continue; - try { - if (fs.statSync(file).size > MAX_CONFIG_FILE_BYTES) { - return true; // implausibly large config — fail closed - } - } catch { - // stat can race with the read below; fall through. - } - let content: string; - try { - content = fs.readFileSync(file, 'utf8'); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if (code === 'ENOENT' || code === 'ENOTDIR') continue; - return true; // exists but unreadable — fail closed - } - const entries = parseGitConfig(content); - if (entriesMayExecutePrograms(entries)) return true; - // A core.hooksPath entry redirects hook lookup away from this - // config's default hooks directory — or, when empty, disables hooks - // entirely, so git runs no hooks at all. - if ( - entries.some( - (e) => - e.section === 'core' && - e.subsection === null && - e.key === 'hookspath', - ) - ) { - hooksDirs.delete(path.join(path.dirname(file), 'hooks')); - } - const redirectedHooksDirs = hooksPathDirectories(entries, hooksPathRoot); - if (redirectedHooksDirs === null) return true; // fail closed - for (const dir of redirectedHooksDirs) hooksDirs.add(dir); - readWorktreeConfig ||= worktreeConfigEnabled(entries); - } - for (const hooksDir of hooksDirs) { - if (hooksMayExecutePrograms(hooksDir)) return true; - } - return false; - } catch { - return true; // unexpected probe failure — fail closed - } -} diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index b37ba064fd3..ea23f3f01fb 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -15,12 +15,10 @@ import { getCommandRoot, getCommandRoots, getShellConfiguration, - hasGitConfigOverridingEnv, hasNonFinalTopLevelBackgroundOperator, hasUnsafeMonitorBackgroundOperator, isCommandAllowed, isCommandNeedsPermission, - isDirectoryChangeSegment, normalizeMonitorCommand, splitCommands, stripTrailingBackgroundAmp, @@ -1425,59 +1423,3 @@ describe('splitCommands', () => { }); }); }); - -describe('isDirectoryChangeSegment (#8575)', () => { - it('matches bare cd / pushd / popd segments', () => { - expect(isDirectoryChangeSegment('cd /tmp/repo')).toBe(true); - expect(isDirectoryChangeSegment('pushd /tmp/repo')).toBe(true); - expect(isDirectoryChangeSegment('popd')).toBe(true); - expect(isDirectoryChangeSegment('(cd /tmp/repo)')).toBe(true); - }); - - it('matches disguised directory-change forms', () => { - // All of these genuinely change the directory in bash. - expect(isDirectoryChangeSegment('builtin cd /tmp/repo')).toBe(true); - expect(isDirectoryChangeSegment('command cd /tmp/repo')).toBe(true); - expect(isDirectoryChangeSegment('"cd" /tmp/repo')).toBe(true); - expect(isDirectoryChangeSegment("'cd' /tmp/repo")).toBe(true); - expect(isDirectoryChangeSegment('\\cd /tmp/repo')).toBe(true); - expect(isDirectoryChangeSegment('FOO=x cd /tmp/repo')).toBe(true); - }); - - it('does not match non-directory-change segments', () => { - expect(isDirectoryChangeSegment('ls -la')).toBe(false); - expect(isDirectoryChangeSegment('git status')).toBe(false); - expect(isDirectoryChangeSegment('command -v cd')).toBe(false); - expect(isDirectoryChangeSegment('cdr /tmp/repo')).toBe(false); - }); -}); - -describe('hasGitConfigOverridingEnv (#8575)', () => { - it('detects git discovery/config overrides in leading env assignments', () => { - expect(hasGitConfigOverridingEnv('GIT_DIR=/planted/.git git status')).toBe( - true, - ); - expect(hasGitConfigOverridingEnv('GIT_WORK_TREE=/x git status')).toBe(true); - expect(hasGitConfigOverridingEnv('GIT_COMMON_DIR=/x git status')).toBe( - true, - ); - expect( - hasGitConfigOverridingEnv( - 'GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=evil git status', - ), - ).toBe(true); - expect( - hasGitConfigOverridingEnv("FOO=1 GIT_DIR=/x bash -c 'git status'"), - ).toBe(true); - }); - - it('ignores unrelated env assignments and non-env commands', () => { - expect(hasGitConfigOverridingEnv('FOO=bar git status')).toBe(false); - expect(hasGitConfigOverridingEnv('GIT_TERMINAL_PROMPT=0 git status')).toBe( - false, - ); - expect(hasGitConfigOverridingEnv('git status')).toBe(false); - // The override must be a LEADING env assignment, not an argument. - expect(hasGitConfigOverridingEnv('env GIT_DIR=/x')).toBe(false); - }); -}); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 11dcd54a5e9..8487f178253 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -197,26 +197,14 @@ export function escapeShellArg(arg: string, shell: ShellType): string { } } -/** A command segment plus the control operator that terminated it. */ -export interface CommandSegment { - command: string; - /** Terminating operator (`&&`, `||`, `;`, `|`, `&`, newline) — `null` for the last segment. */ - separator: string | null; -} - /** - * Splits a shell command into segments, respecting quotes, and records the - * control operator terminating each segment. @see splitCommands for the - * separator-free variant. + * Splits a shell command into a list of individual commands, respecting quotes. + * This is used to separate chained commands (e.g., using &&, ||, ;). + * @param command The shell command string to parse + * @returns An array of individual command strings */ -export function splitCommandsWithSeparators(command: string): CommandSegment[] { - const commands: CommandSegment[] = []; - const push = (segment: string, separator: string | null): void => { - const trimmed = segment.trim(); - if (trimmed) { - commands.push({ command: trimmed, separator }); - } - }; +export function splitCommands(command: string): string[] { + const commands: string[] = []; let currentCommand = ''; let inSingleQuotes = false; let inDoubleQuotes = false; @@ -312,18 +300,18 @@ export function splitCommandsWithSeparators(command: string): CommandSegment[] { (char === '&' && nextChar === '&') || (char === '|' && (nextChar === '|' || nextChar === '&')) ) { - push(currentCommand, char + nextChar); + commands.push(currentCommand.trim()); currentCommand = ''; i++; // Skip the next character } else if (char === ';') { - push(currentCommand, ';'); + commands.push(currentCommand.trim()); currentCommand = ''; } else if (char === '&') { const prevChar = previousNonWhitespaceChar(i); if (prevChar === '>' || prevChar === '<') { currentCommand += char; } else { - push(currentCommand, '&'); + commands.push(currentCommand.trim()); currentCommand = ''; } } else if (char === '|') { @@ -331,17 +319,17 @@ export function splitCommandsWithSeparators(command: string): CommandSegment[] { if (prevChar === '>') { currentCommand += char; } else { - push(currentCommand, '|'); + commands.push(currentCommand.trim()); currentCommand = ''; } } else if (char === '\r' && nextChar === '\n') { // Windows-style \r\n newline - treat as command separator - push(currentCommand, '\n'); + commands.push(currentCommand.trim()); currentCommand = ''; i++; // Skip the \n } else if (char === '\n') { // Unix-style \n newline - treat as command separator - push(currentCommand, '\n'); + commands.push(currentCommand.trim()); currentCommand = ''; } else { currentCommand += char; @@ -352,52 +340,11 @@ export function splitCommandsWithSeparators(command: string): CommandSegment[] { i++; } - push(currentCommand, null); - - return commands; -} - -/** - * Splits a shell command into a list of individual commands, respecting quotes. - * This is used to separate chained commands (e.g., using &&, ||, ;). - * @param command The shell command string to parse - * @returns An array of individual command strings - */ -export function splitCommands(command: string): string[] { - return splitCommandsWithSeparators(command).map((entry) => entry.command); -} - -/** True when a split segment changes the working directory. */ -const DIRECTORY_CHANGE_SEGMENT = /^\(*\s*(?:cd|pushd|popd)(?:[\s);]|$)/; -const DIRECTORY_CHANGE_COMMANDS = new Set(['cd', 'pushd', 'popd']); -const DIRECTORY_CHANGE_PREFIXES = new Set(['builtin', 'command']); - -export function isDirectoryChangeSegment(segment: string): boolean { - // Parse with shell-quote so disguised forms are recognized too: - // `builtin cd` / `command cd`, env-prefixed cds (`FOO=x cd /dir`), and - // quoted or escaped roots (`"cd"`, `'cd'`, `\cd`) all change the - // directory in bash. Over-detecting only widens the confirmation scope; - // under-detecting would drop the git segments after the cd from it - // (#8575). - try { - const tokens = parse(segment).filter( - (token): token is string => typeof token === 'string', - ); - let index = 0; - while (index < tokens.length && ENV_ASSIGNMENT_REGEX.test(tokens[index]!)) { - index++; - } - if ( - index < tokens.length && - DIRECTORY_CHANGE_PREFIXES.has(tokens[index]!) - ) { - index++; - } - const root = tokens[index]; - return root !== undefined && DIRECTORY_CHANGE_COMMANDS.has(root); - } catch { - return DIRECTORY_CHANGE_SEGMENT.test(segment.trim()); + if (currentCommand.trim()) { + commands.push(currentCommand.trim()); } + + return commands.filter(Boolean); // Filter out any empty strings } /** @@ -512,31 +459,6 @@ export function getCommandRoots(command: string): string[] { .filter((c): c is string => !!c); } -const GIT_CONFIG_OVERRIDING_ENV = /^GIT_(?:DIR|WORK_TREE|COMMON_DIR|CONFIG)/; - -/** - * True when the command's leading env assignments override git's - * repository discovery (GIT_DIR, GIT_WORK_TREE, GIT_COMMON_DIR) or inject - * config (GIT_CONFIG_COUNT and GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n). - * Such assignments survive - * `stripShellWrapper`'s wrapper unwrap and still apply to the inner - * script, so classifying the stripped command alone would probe the wrong - * repository (#8575). - */ -export function hasGitConfigOverridingEnv(command: string): boolean { - let rest = command; - while (true) { - const token = takeLeadingToken(rest); - if (!token || !isEnvAssignmentToken(token.token)) return false; - if ( - GIT_CONFIG_OVERRIDING_ENV.test(stripSymmetricQuotes(token.token).value) - ) { - return true; - } - rest = token.rest; - } -} - export function stripShellWrapper(command: string): string { const trimmed = command.trim(); let rest = trimmed; diff --git a/packages/core/src/utils/shellAstParser.test.ts b/packages/core/src/utils/shellAstParser.test.ts index 45759269d96..6b2c1278951 100644 --- a/packages/core/src/utils/shellAstParser.test.ts +++ b/packages/core/src/utils/shellAstParser.test.ts @@ -5,9 +5,6 @@ */ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { classifyShellCommandSafety, initParser, @@ -816,7 +813,7 @@ describe('classifyShellCommandSafety', () => { } const startedAt = performance.now(); await expect( - Promise.all(commands.map((c) => classifyShellCommandSafety(c))), + Promise.all(commands.map(classifyShellCommandSafety)), ).resolves.toEqual(['unknown', 'unknown']); expect(performance.now() - startedAt).toBeLessThan(1000); }); @@ -837,7 +834,7 @@ describe('classifyShellCommandSafety', () => { ]; const startedAt = performance.now(); await expect( - Promise.all(commands.map((c) => classifyShellCommandSafety(c))), + Promise.all(commands.map(classifyShellCommandSafety)), ).resolves.toEqual([ 'unknown', 'read-only', @@ -1072,36 +1069,6 @@ describe('isShellCommandReadOnlyAST fallback to regex-based checker', () => { expect(await isShellCommandReadOnlyAST('ls -la')).toBe(true); expect(await isShellCommandReadOnlyAST('rm -rf /')).toBe(false); }); - - it('forwards checkOptions to the regex fallback (#8575)', async () => { - // The fallback delegation is load-bearing: dropping checkOptions would - // auto-approve `git status` in a dirty repo on exactly the installs - // (WASM missing after a symlinked install) the fallback exists for. - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ast-fallback-probe-')); - try { - const dirtyRepo = path.join(tmp, 'dirty'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - const cleanRepo = path.join(tmp, 'clean'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(cleanRepo, '.git', 'config'), - '[core]\n\tbare = false\n', - ); - _setParserFailedForTesting(); - expect( - await isShellCommandReadOnlyAST('git status', { cwd: dirtyRepo }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST('git status', { cwd: cleanRepo }), - ).toBe(true); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } - }); }); // ========================================================================= @@ -1250,452 +1217,3 @@ describe('consistency: isShellCommandReadOnly (regex) vs isShellCommandReadOnlyA }); }); }); - -// ========================================================================= -// Git config execution probe (issue #8575) — read-only git sub-commands -// must be downgraded when the repo-local config executes programs. -// ========================================================================= - -describe('git config execution probe (#8575)', () => { - let root: string; - let cleanRepo: string; - let dirtyRepo: string; - - beforeAll(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-ast-git-config-')); - - cleanRepo = path.join(root, 'clean'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(cleanRepo, '.git', 'config'), - '[core]\n\tbare = false\n[remote "origin"]\n\turl = https://example.com/repo.git\n', - ); - - dirtyRepo = path.join(root, 'dirty'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - }); - - afterAll(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it.each([ - 'git diff', - 'git status', - 'git log -p', - 'git show HEAD', - 'git remote show origin', - 'git branch', - 'git branch --list', - ])('downgrades %s when repo config executes programs', async (command) => { - expect(await isShellCommandReadOnlyAST(command, { cwd: dirtyRepo })).toBe( - false, - ); - expect(await classifyShellCommandSafety(command, { cwd: dirtyRepo })).toBe( - 'unknown', - ); - // Same command stays read-only in a clean repo. - expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( - true, - ); - }); - - it('downgrades compound commands touching a dirty repo cwd', async () => { - expect( - await isShellCommandReadOnlyAST('git status && git diff', { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('keeps git commands that never consult repo config execution keys', async () => { - for (const command of ['git', 'git --version', 'git --help']) { - expect(await isShellCommandReadOnlyAST(command, { cwd: dirtyRepo })).toBe( - true, - ); - } - }); - - it('does not affect non-git commands', async () => { - expect(await isShellCommandReadOnlyAST('ls -la', { cwd: dirtyRepo })).toBe( - true, - ); - }); - - it('keeps text-side helper detection intact under a dirty cwd', async () => { - // --ext-diff is already non-read-only regardless of config. - expect( - await isShellCommandReadOnlyAST('git diff --ext-diff', { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('is backward compatible without cwd', async () => { - expect(await isShellCommandReadOnlyAST('git diff')).toBe(true); - }); - - it('keeps git read-only when cwd is not inside a repository', async () => { - expect(await isShellCommandReadOnlyAST('git diff', { cwd: root })).toBe( - true, - ); - }); -}); - -// ========================================================================= -// cd tracking for the git config probe (issue #8575) — compound commands -// must be probed against the repository they actually run in. -// ========================================================================= - -describe('git config probe cd tracking (#8575)', () => { - let root: string; - let cleanRepo: string; - let dirtyRepo: string; - - beforeAll(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-ast-cd-tracking-')); - - cleanRepo = path.join(root, 'clean-repo'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(cleanRepo, '.git', 'config'), - '[core]\n\tbare = false\n', - ); - fs.mkdirSync(path.join(cleanRepo, 'sub'), { recursive: true }); - - dirtyRepo = path.join(root, 'dirty-repo'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - }); - - afterAll(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - // On Windows tmpdir paths contain backslashes, which the cd-target - // resolver (correctly) rejects; normalize to slashes so the tracking - // logic is still exercised there (path.win32.isAbsolute accepts `C:/`). - const p = (dir: string) => dir.split(path.sep).join('/'); - - it('probes the post-cd repository for absolute targets', async () => { - expect( - await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)} && git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe(true); - }); - - it('probes the post-cd repository for relative targets', async () => { - expect( - await isShellCommandReadOnlyAST('cd ../dirty-repo && git status', { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('keeps same-repo cd read-only', async () => { - expect( - await isShellCommandReadOnlyAST('cd sub && git status', { - cwd: cleanRepo, - }), - ).toBe(true); - expect( - await classifyShellCommandSafety('cd sub && git status', { - cwd: cleanRepo, - }), - ).toBe('read-only'); - }); - - it('resolves fully quoted cd targets', async () => { - expect( - await isShellCommandReadOnlyAST("cd 'sub' && git status", { - cwd: cleanRepo, - }), - ).toBe(true); - expect( - await isShellCommandReadOnlyAST('cd "sub" && git status', { - cwd: cleanRepo, - }), - ).toBe(true); - }); - - it('downgrades git after an unresolvable cd', async () => { - for (const command of [ - 'cd $TARGET && git status', - 'cd && git status', - 'cd - && git status', - 'popd && git status', - ]) { - expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( - false, - ); - } - }); - - it('tracks chained cd segments', async () => { - expect( - await isShellCommandReadOnlyAST( - 'cd sub && cd ../../dirty-repo && git status', - { cwd: cleanRepo }, - ), - ).toBe(false); - }); - - it('leaves non-git commands after cd untouched', async () => { - expect( - await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)} && ls -la`, { - cwd: cleanRepo, - }), - ).toBe(true); - }); - - it('does not take cd flags as the destination directory', async () => { - for (const flag of ['-P', '-L', '-e', '--']) { - expect( - await isShellCommandReadOnlyAST( - `cd ${flag} ${p(dirtyRepo)} && git status`, - { - cwd: cleanRepo, - }, - ), - ).toBe(false); - } - expect( - await isShellCommandReadOnlyAST(`cd -- ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe(true); - }); - - it('downgrades git after operand-less cd flag forms (cd goes to $HOME)', async () => { - for (const command of [ - 'cd -- && git status', - 'cd -P && git status', - 'cd -e && git status', - ]) { - expect(await isShellCommandReadOnlyAST(command, { cwd: cleanRepo })).toBe( - false, - ); - } - }); - - it('tracks cd across ; and newline separators', async () => { - expect( - await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)}; git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST(`cd ${p(dirtyRepo)}\ngit status`, { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('propagates cd out of brace groups (they run in the current shell)', async () => { - expect( - await isShellCommandReadOnlyAST(`{ cd ${p(dirtyRepo)}; }; git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST(`{ cd ${p(dirtyRepo)}; } && git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('tracks cd wrapped in redirections', async () => { - expect( - await isShellCommandReadOnlyAST( - `cd ${p(dirtyRepo)} { - expect( - await isShellCommandReadOnlyAST(`(cd ${p(dirtyRepo)}; git status)`, { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('does not propagate cd state across || (RHS runs when cd failed)', async () => { - expect( - await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} || git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST( - `cd ${p(dirtyRepo)} || cd ${p(cleanRepo)} && git status`, - { cwd: cleanRepo }, - ), - ).toBe(false); - }); - - it('downgrades git after a multi-argument cd (bash stays put)', async () => { - expect( - await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} extra; git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('downgrades git when the cd target does not exist (bash stays put)', async () => { - expect( - await isShellCommandReadOnlyAST(`cd ${p(root)}/no-such-dir; git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('treats ANSI-C-quoted and backslash-escaped cd targets as unknown', async () => { - expect( - await isShellCommandReadOnlyAST(`cd $'${p(dirtyRepo)}' && git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST('cd dirty\\ repo && git status', { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('treats concatenated quoted/unquoted cd targets as unknown', async () => { - expect( - await isShellCommandReadOnlyAST( - `cd "${p(root)}/"dirty-repo && git status`, - { - cwd: cleanRepo, - }, - ), - ).toBe(false); - }); - - it('never auto-approves commands containing pushd', async () => { - expect( - await isShellCommandReadOnlyAST(`pushd ${p(dirtyRepo)} && git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST(`pushd ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('requires a clean prior directory for ;/newline-separated cds', async () => { - // The cd may fail at runtime (bash stays in the prior directory), so - // the prior directory must also be clean before trusting the target. - expect( - await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)}; git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - expect( - await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)}\ngit status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('does not trust a ||-joined cd as certain (it may be skipped)', async () => { - // `echo hi || cd X` — echo succeeds, bash skips the cd, and git runs - // in the ORIGINAL directory. - expect( - await isShellCommandReadOnlyAST( - `echo hi || cd ${p(cleanRepo)} && git status`, - { cwd: dirtyRepo }, - ), - ).toBe(false); - // Clean prior directory keeps the chain read-only. - expect( - await isShellCommandReadOnlyAST( - `echo hi || cd ${p(cleanRepo)} && git status`, - { cwd: cleanRepo }, - ), - ).toBe(true); - }); - - it('does not propagate cd from backgrounded statements', async () => { - // `&` backgrounds the cd into a subshell; the current shell stays in - // cwd, so the following git command must be probed there. - expect( - await isShellCommandReadOnlyAST(`cd ${p(cleanRepo)} & git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - // Two-hop bypass: the relative cd resolves against the ORIGINAL cwd, - // not the backgrounded target. - const work = path.join(root, 'work'); - const dirtySub = path.join(work, 'sub'); - fs.mkdirSync(path.join(dirtySub, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtySub, '.git', 'config'), - '[diff]\n\texternal = /tmp/evil\n', - ); - expect( - await isShellCommandReadOnlyAST( - `cd ${p(cleanRepo)} & cd sub && git status`, - { cwd: work }, - ), - ).toBe(false); - // Same shape inside a subshell body. - expect( - await isShellCommandReadOnlyAST( - `(cd ${p(cleanRepo)} & cd sub && git status)`, - { cwd: work }, - ), - ).toBe(false); - }); - - it('downgrades git after an unresolvable cd even without a cwd', async () => { - // cd tracking is not gated on a supplied cwd: a target that cannot be - // resolved or probed leaves the effective repository unknown (#8575). - expect(await isShellCommandReadOnlyAST('cd $TARGET && git status')).toBe( - false, - ); - expect( - await isShellCommandReadOnlyAST('(cd /nonexistent && git status)'), - ).toBe(false); - // Plain git commands without a cwd keep their pre-#8575 behavior. - expect(await isShellCommandReadOnlyAST('git status')).toBe(true); - }); - - it('does not propagate cd through negation', async () => { - // `! cd X && …` continues the chain precisely when the cd FAILED. - expect( - await isShellCommandReadOnlyAST(`! cd ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - expect( - await classifyShellCommandSafety(`! cd ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe('unknown'); - // Negation without a cd leaves the context untouched. - expect( - await isShellCommandReadOnlyAST(`! ls && git status`, { - cwd: cleanRepo, - }), - ).toBe(true); - }); -}); diff --git a/packages/core/src/utils/shellAstParser.ts b/packages/core/src/utils/shellAstParser.ts index 40e9b44e453..ec4e6db840e 100644 --- a/packages/core/src/utils/shellAstParser.ts +++ b/packages/core/src/utils/shellAstParser.ts @@ -25,10 +25,6 @@ import { classifySedCommandSafety, hasShellPatternExpansion, } from './shell-safety-rules.js'; -import { - gitConfigMayExecutePrograms, - type ShellReadOnlyCheckOptions, -} from './git-config-safety.js'; export type ShellCommandSafety = 'read-only' | 'write' | 'unknown'; type Safety = ShellCommandSafety; @@ -676,7 +672,8 @@ type SyntaxNode = Parser.SyntaxNode; const SHELL_EXPANSION_TYPES = new Set( 'simple_expansion expansion arithmetic_expansion'.split(' '), ); -const CHILD_STATEMENT = /^(?:pipeline|negated_command)$/; +const CHILD_STATEMENT = + /^(?:pipeline|list|subshell|compound_statement|negated_command)$/; /** Collect all descendant nodes of given types. */ function collectDescendants( node: SyntaxNode, @@ -961,10 +958,7 @@ function processSafety(root: string, args: string[]): Safety { return 'write'; } -function evaluateSubstitutions( - node: SyntaxNode, - checkOptions?: ShellReadOnlyCheckOptions, -): ShellCommandSafety { +function evaluateSubstitutions(node: SyntaxNode): ShellCommandSafety { const substitutions = collectDescendants( node, new Set(['command_substitution', 'process_substitution']), @@ -975,14 +969,11 @@ function evaluateSubstitutions( 'unknown', ...substitutions .flatMap((substitution) => substitution.namedChildren) - .map((child) => evaluateStatementSafety(child, checkOptions)), + .map(evaluateStatementSafety), ); } -function evaluateCommandSafety( - commandNode: SyntaxNode, - checkOptions?: ShellReadOnlyCheckOptions, -): ShellCommandSafety { +function evaluateCommandSafety(commandNode: SyntaxNode): ShellCommandSafety { const rawRoot = commandNode.childForFieldName('name')?.text; const root = getCommandName(commandNode); const argNodes = getArgumentNodes(commandNode); @@ -994,25 +985,8 @@ function evaluateCommandSafety( result = hasHelp(args) ? 'unknown' : 'write'; } else if (/^(kill|killall|pkill)$/.test(root)) { result = processSafety(root, args); - } else if (root === 'git') { - result = evaluateGitSafety(args); - // Whitelisted read-only sub-commands can still execute programs - // configured in the repository-local `.git/config` (diff.external, - // core.fsmonitor, pagers, credential/ssh helpers). Require confirmation - // when such keys are present, or when the effective directory after an - // unresolvable `cd` is unknown. Bare `git`, `git --version` and - // `git --help` are left as-is: they do not run repo-config programs. - // See issue #8575. - if ( - result === 'read-only' && - args.length > 0 && - !args[0]!.startsWith('-') && - (checkOptions?.unknownDir || - (checkOptions?.cwd && gitConfigMayExecutePrograms(checkOptions.cwd))) - ) { - result = 'unknown'; - } - } else if (root === 'find') result = evaluateFindSafety(args); + } else if (root === 'git') result = evaluateGitSafety(args); + else if (root === 'find') result = evaluateFindSafety(args); else if (root === 'sed') result = evaluateSedSafety(args); else if (root === 'awk') result = evaluateAwkSafety(args); else if (root === 'sort' || root === 'tree') { @@ -1072,7 +1046,7 @@ function evaluateCommandSafety( evaluateRedirectionSafety(commandNode), ...commandNode.namedChildren .filter((child) => !child.type.endsWith('_redirect')) - .map((child) => evaluateSubstitutions(child, checkOptions)), + .map(evaluateSubstitutions), ); } @@ -1101,315 +1075,44 @@ function evaluateRedirectionSafety(node: SyntaxNode): ShellCommandSafety { return result; } -function childrenSafety( - node: SyntaxNode, - floor: Safety = 'read-only', - checkOptions?: ShellReadOnlyCheckOptions, -): Safety { - return mergeSafety( - floor, - ...node.namedChildren.map((child) => - evaluateStatementSafety(child, checkOptions), - ), - ); -} - -/** - * Statements in a sequence run one after another (`;` or newline - * separators at program/brace-group level), and `cd`/`pushd` change the - * directory later statements execute in. Track the directory so the - * git-config probe is applied to the repository each git statement - * actually reaches (#8575). - */ -function evaluateSequenceSafety( - node: SyntaxNode, - checkOptions?: ShellReadOnlyCheckOptions, -): ShellCommandSafety { - let context = checkOptions; - let result: ShellCommandSafety = 'read-only'; - const children = node.children; - for (let index = 0; index < children.length; index++) { - const child = children[index]!; - if (!child.isNamed) continue; - result = mergeSafety(result, evaluateStatementSafety(child, context)); - // A `&` terminator backgrounds the statement: it runs in a subshell, - // so its directory changes never reach the statements that follow - // (#8575). (`&` is a terminator between statements, never inside a - // `list` node.) - const terminator = children[index + 1]; - context = - terminator && !terminator.isNamed && terminator.type === '&' - ? context - : contextAfterStatement(child, context); - } - return result; -} - -/** - * Flatten a `list` node into (statement, joining-operator) pairs. Nested - * lists are inlined so `a && b && c` — which tree-sitter may nest — is - * traversed as one chain with its operators intact. - */ -function* iterateListStatements( - node: SyntaxNode, - leadingOperator?: string, -): Generator<{ statement: SyntaxNode; operator?: string }> { - let operator = leadingOperator; - for (const child of node.children) { - if (!child.isNamed) { - // `&` never appears inside a `list` node — it terminates the list - // at program/compound level, where evaluateSequenceSafety handles - // it — so only `&&`/`||` join list members. - if (child.type === '&&' || child.type === '||') { - operator = child.type; - } - continue; - } - if (child.type === 'list') { - yield* iterateListStatements(child, operator); - } else { - yield { statement: child, operator }; - } - operator = undefined; - } +function childrenSafety(node: SyntaxNode, floor: Safety = 'read-only'): Safety { + return mergeSafety(floor, ...node.namedChildren.map(evaluateStatementSafety)); } -/** - * Evaluate a `list` (`&&`/`||` chain), tracking directory changes across - * the segments (#8575). cd state only propagates across `&&`: the segment - * after `||` (or `&`) runs precisely when the preceding chain did not - * complete (or runs in a background subshell), so once a cd was tracked - * the effective directory for everything after a non-`&&` operator is - * unknown. - */ -function evaluateListSafety( - node: SyntaxNode, - checkOptions?: ShellReadOnlyCheckOptions, -): ShellCommandSafety { - let context = checkOptions; - let diverged = false; - let directoryTracked = false; - let result: ShellCommandSafety = 'read-only'; - - for (const { statement, operator } of iterateListStatements(node)) { - if (operator && operator !== '&&' && directoryTracked) { - diverged = true; - } - if (diverged) { - result = mergeSafety( - result, - evaluateStatementSafety(statement, { - cwd: undefined, - unknownDir: true, - }), - ); - continue; - } - result = mergeSafety(result, evaluateStatementSafety(statement, context)); - // A cd joined by `||` may be skipped entirely (the preceding segment - // succeeded), so the following segments can also run in the prior - // directory — pass `certain=false` so contextAfterCd applies its - // prior-directory check (#8575). - const next = contextAfterStatement(statement, context, operator !== '||'); - if (next !== context) { - context = next; - directoryTracked = true; - } - } - return result; -} - -/** - * The execution-directory context after `node` finishes, for the benefit - * of the statements that follow it. Returns the SAME object when the node - * cannot change the directory. `certain` means the next statement only - * runs when this one succeeded (`&&` chaining); otherwise the next - * statement also runs when a cd fails and bash stays put. - */ -function contextAfterStatement( - node: SyntaxNode, - context?: ShellReadOnlyCheckOptions, - certain = false, -): ShellReadOnlyCheckOptions | undefined { - if (node.type === 'redirected_statement') { - // Redirection still runs the body in the current shell. - const body = node.namedChildren[0]; - return body - ? contextAfterStatement(body, context, certain) - : { ...context, cwd: undefined, unknownDir: true }; - } - if (node.type === 'negated_command') { - // `! cd X && …` continues the chain precisely when the cd FAILED — - // the resolved target would point at a directory git never reaches. - return containsCurrentShellCd(node) - ? { ...context, cwd: undefined, unknownDir: true } - : context; - } - if (node.type === 'command') { - const name = getCommandName(node); - if (name === 'cd' || name === 'pushd') { - return contextAfterCd(node, context, certain); - } - if (name === 'popd') { - return { ...context, cwd: undefined, unknownDir: true }; - } - return context; - } - if (node.type === 'compound_statement') { - // Brace groups run in the current shell — fold the net effect of - // the group's `;`/newline-separated body. - let ctx = context; - for (const child of node.namedChildren) { - ctx = contextAfterStatement(child, ctx); - } - return ctx; - } - if (node.type === 'subshell') { - // Child process — directory changes stay inside. - return context; - } - // if/for/while/case/function bodies run in the current shell but only - // conditionally — a cd inside leaves the following directory unknown. - return containsCurrentShellCd(node) - ? { ...context, cwd: undefined, unknownDir: true } - : context; -} - -function contextAfterCd( - commandNode: SyntaxNode, - context?: ShellReadOnlyCheckOptions, - certain = false, -): ShellReadOnlyCheckOptions { - const resolved = resolveCdContext(commandNode, context); - if (!resolved.cwd || resolved.unknownDir) { - return { ...context, cwd: undefined, unknownDir: true }; - } - if (certain) return resolved; - // The following statement also runs when the cd fails (bash stays in - // the prior directory), so the prior directory must be clean too before - // the resolved target can be trusted. - const priorMayExecute = - context?.unknownDir === true || - (!!context?.cwd && gitConfigMayExecutePrograms(context.cwd)); - return priorMayExecute - ? { ...context, cwd: undefined, unknownDir: true } - : resolved; -} - -/** True when a cd/pushd/popd runs in the current shell below `node`. */ -function containsCurrentShellCd(node: SyntaxNode): boolean { - if ( - node.type === 'subshell' || - node.type === 'command_substitution' || - node.type === 'process_substitution' - ) { - return false; // child process - } - if (node.type === 'command') { - const name = getCommandName(node); - if (name === 'cd' || name === 'pushd' || name === 'popd') return true; - } - return node.namedChildren.some((child) => containsCurrentShellCd(child)); -} - -/** - * The directory a cd/pushd argument points to, when it can be resolved - * statically. Anything else (concatenated quote segments, ANSI-C quoting, - * backslash escapes, expansions) is unresolvable — the probe would inspect - * a fabricated path while bash cds to the real one. - */ -function staticallyResolvableCdTarget(node: SyntaxNode): string | undefined { - const { text } = node; - if (node.type === 'raw_string') return text.slice(1, -1); - if (node.type === 'string') { - const inner = text.slice(1, -1); - return /[\\"$`]/.test(inner) ? undefined : inner; - } - if (node.type === 'word') { - return /[\\"'$`]/.test(text) ? undefined : text; - } - return undefined; -} - -function resolveCdContext( - commandNode: SyntaxNode, - context?: ShellReadOnlyCheckOptions, -): ShellReadOnlyCheckOptions { - const unknown = { ...context, cwd: undefined, unknownDir: true }; - const argNodes = getArgumentNodes(commandNode); - if (argNodes.some((arg) => hasShellExpansion(arg))) return unknown; - const operands: SyntaxNode[] = []; - for (const arg of argNodes) { - if (arg.text === '-') return unknown; // `cd -` goes to OLDPWD - if (arg.text.startsWith('-')) continue; // -P/-L/-e/-- are flags, not targets - operands.push(arg); - } - // No operand cds to $HOME; more than one is rejected by bash (`cd: too - // many arguments`) or rewrites $PWD (`cd old new`) — neither resolvable. - if (operands.length !== 1) return unknown; - const target = staticallyResolvableCdTarget(operands[0]!); - if (target === undefined || target.startsWith('~')) return unknown; - const resolved = path.isAbsolute(target) - ? target - : context?.cwd - ? path.resolve(context.cwd, target) - : undefined; - if (!resolved) return unknown; - // bash refuses to enter a missing target or a non-directory and stays - // put; fail closed regardless — the target can appear before execution. - try { - if (!fs.statSync(resolved).isDirectory()) return unknown; - } catch { - return unknown; - } - return { ...context, cwd: resolved, unknownDir: false }; -} - -function evaluateStatementSafety( - node: SyntaxNode, - checkOptions?: ShellReadOnlyCheckOptions, -): ShellCommandSafety { - if (node.type === 'command') return evaluateCommandSafety(node, checkOptions); - if (node.type === 'list') return evaluateListSafety(node, checkOptions); - if (node.type === 'compound_statement' || node.type === 'subshell') - return evaluateSequenceSafety(node, checkOptions); - if (CHILD_STATEMENT.test(node.type)) - return childrenSafety(node, 'read-only', checkOptions); +function evaluateStatementSafety(node: SyntaxNode): ShellCommandSafety { + if (node.type === 'command') return evaluateCommandSafety(node); + if (CHILD_STATEMENT.test(node.type)) return childrenSafety(node); if (node.type === 'redirected_statement') return mergeSafety( ...node.namedChildren .filter((child) => !child.type.endsWith('_redirect')) - .map((child) => evaluateStatementSafety(child, checkOptions)), + .map((child) => evaluateStatementSafety(child)), evaluateRedirectionSafety(node), ); if (/^variable_assignments?$/.test(node.type)) return mergeSafety( node.parent?.namedChildCount === 1 ? 'read-only' : 'unknown', - evaluateSubstitutions(node, checkOptions), + evaluateSubstitutions(node), ); if (node.type === 'function_definition') return 'unknown'; - return childrenSafety(node, 'unknown', checkOptions); + return childrenSafety(node, 'unknown'); } -async function classifyInternal( - command: string, - checkOptions?: ShellReadOnlyCheckOptions, -): Promise { +async function classifyInternal(command: string): Promise { const tree = await parseShellCommand(command); try { const root = tree.rootNode; if (root.namedChildCount === 0 || root.hasError) return 'unknown'; - return evaluateSequenceSafety(root, checkOptions); + return mergeSafety(...root.namedChildren.map(evaluateStatementSafety)); } finally { tree.delete(); } } export async function classifyShellCommandSafety( command: string, - checkOptions?: ShellReadOnlyCheckOptions, ): Promise { if (typeof command !== 'string' || !command.trim()) return 'unknown'; - return classifyInternal(command, checkOptions).catch(() => 'unknown'); + return classifyInternal(command).catch(() => 'unknown'); } /** @@ -1423,13 +1126,10 @@ export async function classifyShellCommandSafety( * - Sub-shells, heredocs, etc. * * @param command - The shell command string to evaluate. - * @param checkOptions - Optional `cwd` so git commands can be downgraded when - * the repository-local config contains program-executing keys (#8575). * @returns `true` if the command only performs read-only operations. */ export async function isShellCommandReadOnlyAST( command: string, - checkOptions?: ShellReadOnlyCheckOptions, ): Promise { if (typeof command !== 'string' || !command.trim()) return false; @@ -1437,15 +1137,15 @@ 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, checkOptions); + return isShellCommandReadOnly(command); } try { - return (await classifyInternal(command, checkOptions)) === 'read-only'; + return (await classifyInternal(command)) === '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, checkOptions); + return isShellCommandReadOnly(command); } } diff --git a/packages/core/src/utils/shellReadOnlyChecker.test.ts b/packages/core/src/utils/shellReadOnlyChecker.test.ts index 885cd2312da..6e0530b3147 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.test.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.test.ts @@ -4,10 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; +import { describe, expect, it } from 'vitest'; import { isShellCommandReadOnly } from './shellReadOnlyChecker.js'; describe('evaluateShellCommandReadOnly', () => { @@ -460,7 +457,7 @@ describe('evaluateShellCommandReadOnly', () => { `git status ${'\\{'.repeat(10_000)}`, ]; const startedAt = performance.now(); - expect(commands.map((c) => isShellCommandReadOnly(c))).toEqual([ + expect(commands.map(isShellCommandReadOnly)).toEqual([ false, true, true, @@ -470,312 +467,3 @@ describe('evaluateShellCommandReadOnly', () => { }); }); }); - -// ========================================================================= -// Git config execution probe (issue #8575) — the regex fallback must apply -// the same repo-local config downgrade as the AST classifier. -// ========================================================================= - -describe('git config execution probe (#8575)', () => { - let root: string; - let cleanRepo: string; - let dirtyRepo: string; - - beforeAll(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-regex-git-config-')); - - cleanRepo = path.join(root, 'clean'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(cleanRepo, '.git', 'config'), - '[core]\n\tbare = false\n', - ); - - dirtyRepo = path.join(root, 'dirty'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[core]\n\tfsmonitor = /tmp/evil\n', - ); - }); - - afterAll(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - it.each([ - 'git diff', - 'git status', - 'git log', - 'git remote show origin', - 'git branch', - 'git branch --list', - ])('downgrades %s when repo config executes programs', (command) => { - expect(isShellCommandReadOnly(command, { cwd: dirtyRepo })).toBe(false); - expect(isShellCommandReadOnly(command, { cwd: cleanRepo })).toBe(true); - }); - - it('keeps git --version and bare git read-only under a dirty cwd', () => { - expect(isShellCommandReadOnly('git --version', { cwd: dirtyRepo })).toBe( - true, - ); - expect(isShellCommandReadOnly('git', { cwd: dirtyRepo })).toBe(true); - }); - - it('does not affect non-git commands', () => { - expect(isShellCommandReadOnly('ls -la', { cwd: dirtyRepo })).toBe(true); - }); - - it('is backward compatible without cwd', () => { - expect(isShellCommandReadOnly('git diff')).toBe(true); - }); -}); - -describe('git config probe cd tracking (#8575)', () => { - let root: string; - let cleanRepo: string; - let dirtyRepo: string; - - beforeAll(() => { - root = fs.mkdtempSync(path.join(os.tmpdir(), 'shell-regex-cd-tracking-')); - - cleanRepo = path.join(root, 'clean-repo'); - fs.mkdirSync(path.join(cleanRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(cleanRepo, '.git', 'config'), - '[core]\n\tbare = false\n', - ); - fs.mkdirSync(path.join(cleanRepo, 'sub'), { recursive: true }); - - dirtyRepo = path.join(root, 'dirty-repo'); - fs.mkdirSync(path.join(dirtyRepo, '.git'), { recursive: true }); - fs.writeFileSync( - path.join(dirtyRepo, '.git', 'config'), - '[core]\n\tfsmonitor = /tmp/evil\n', - ); - }); - - afterAll(() => { - fs.rmSync(root, { recursive: true, force: true }); - }); - - // On Windows tmpdir paths contain backslashes, which the cd-target - // resolver (correctly) rejects; normalize to slashes so the tracking - // logic is still exercised there. - const p = (dir: string) => dir.split(path.sep).join('/'); - - it('probes the post-cd repository', () => { - expect( - isShellCommandReadOnly(`cd ${p(dirtyRepo)} && git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly('cd ../dirty-repo && git status', { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly(`cd ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe(true); - }); - - it('keeps same-repo cd read-only', () => { - expect( - isShellCommandReadOnly('cd sub && git status', { cwd: cleanRepo }), - ).toBe(true); - }); - - it('resolves fully quoted cd targets (parity with the AST path)', () => { - expect( - isShellCommandReadOnly("cd 'sub' && git status", { cwd: cleanRepo }), - ).toBe(true); - expect( - isShellCommandReadOnly('cd "sub" && git status', { cwd: cleanRepo }), - ).toBe(true); - // Expansions and escapes inside double quotes stay unresolvable. - expect( - isShellCommandReadOnly('cd "$TARGET" && git status', { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly('cd "su\\b" && git status', { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('keeps the original cwd read-only after a backgrounded cd', () => { - // `cd sub &` runs in a background subshell; the following git command - // executes in the ORIGINAL cwd (parity with the AST path). - expect( - isShellCommandReadOnly('cd sub & git status', { cwd: cleanRepo }), - ).toBe(true); - expect( - isShellCommandReadOnly('cd sub & git status', { cwd: dirtyRepo }), - ).toBe(false); - }); - - it('downgrades git after an unresolvable cd', () => { - expect( - isShellCommandReadOnly('cd $TARGET && git status', { cwd: cleanRepo }), - ).toBe(false); - expect(isShellCommandReadOnly('cd && git status', { cwd: cleanRepo })).toBe( - false, - ); - }); - - it('leaves non-git commands after cd untouched', () => { - expect( - isShellCommandReadOnly(`cd ${p(dirtyRepo)} && ls -la`, { - cwd: cleanRepo, - }), - ).toBe(true); - }); - - it('does not take cd flags as the destination directory', () => { - expect( - isShellCommandReadOnly(`cd -P ${p(dirtyRepo)} && git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly(`cd -- ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe(true); - }); - - it('downgrades git after operand-less cd flag forms (cd goes to $HOME)', () => { - expect( - isShellCommandReadOnly('cd -- && git status', { cwd: cleanRepo }), - ).toBe(false); - expect( - isShellCommandReadOnly('cd -P && git status', { cwd: cleanRepo }), - ).toBe(false); - }); - - it('does not propagate cd state across non-&& separators', () => { - expect( - isShellCommandReadOnly(`cd ${p(cleanRepo)} || git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly(`cd ${p(dirtyRepo)}; git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - // The guard must fire for ALL five non-&& separators (#8575). - expect( - isShellCommandReadOnly(`cd ${p(cleanRepo)} & git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly(`cd ${p(cleanRepo)} | git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly(`cd ${p(cleanRepo)}\ngit status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('does not trust a ||-joined cd (it may be skipped entirely)', () => { - expect( - isShellCommandReadOnly(`echo hi || cd ${p(cleanRepo)} && git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - expect( - isShellCommandReadOnly(`echo hi || cd ${p(cleanRepo)} && git status`, { - cwd: cleanRepo, - }), - ).toBe(true); - }); - - it('ignores cd in pipeline members (they run in subshells)', () => { - expect( - isShellCommandReadOnly( - `cat /dev/null | cd ${p(cleanRepo)} && git status`, - { cwd: dirtyRepo }, - ), - ).toBe(false); - expect( - isShellCommandReadOnly( - `cat /dev/null | cd ${p(dirtyRepo)} && git status`, - { cwd: cleanRepo }, - ), - ).toBe(true); - expect( - isShellCommandReadOnly( - `cat /dev/null |& cd ${p(cleanRepo)} && git status`, - { cwd: dirtyRepo }, - ), - ).toBe(false); - }); - - it('fails closed for quoted and escaped cd forms', () => { - for (const form of ['"cd"', "'cd'", '\\cd', 'c\\d']) { - expect( - isShellCommandReadOnly(`${form} ${p(dirtyRepo)} && git status`, { - cwd: cleanRepo, - }), - ).toBe(false); - } - }); - - it('fails closed when cd is glued to an input redirection', () => { - expect( - isShellCommandReadOnly(`cd { - expect( - isShellCommandReadOnly(`(cd ${p(dirtyRepo)} && git status)`, { - cwd: cleanRepo, - }), - ).toBe(false); - }); - - it('downgrades git after a multi-argument cd (bash stays put)', () => { - expect( - isShellCommandReadOnly(`cd ${p(cleanRepo)} extra; git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('downgrades git when the cd target does not exist (bash stays put)', () => { - expect( - isShellCommandReadOnly(`cd ${p(root)}/no-such-dir; git status`, { - cwd: dirtyRepo, - }), - ).toBe(false); - }); - - it('downgrades git after an unresolvable cd even without a cwd', () => { - // cd tracking is not gated on a supplied cwd: a target that cannot be - // resolved or probed leaves the effective repository unknown (#8575). - expect(isShellCommandReadOnly('cd $TARGET && git status')).toBe(false); - expect(isShellCommandReadOnly('(cd /nonexistent && git status)')).toBe( - false, - ); - // Plain git commands without a cwd keep their pre-#8575 behavior. - expect(isShellCommandReadOnly('git status')).toBe(true); - }); -}); diff --git a/packages/core/src/utils/shellReadOnlyChecker.ts b/packages/core/src/utils/shellReadOnlyChecker.ts index 99ad7882106..206213a1f13 100644 --- a/packages/core/src/utils/shellReadOnlyChecker.ts +++ b/packages/core/src/utils/shellReadOnlyChecker.ts @@ -11,11 +11,9 @@ */ import { parse } from 'shell-quote'; -import fs from 'node:fs'; -import path from 'node:path'; import { detectCommandSubstitution, - splitCommandsWithSeparators, + splitCommands, stripShellWrapper, } from './shell-utils.js'; import { @@ -23,10 +21,6 @@ import { classifySedCommandSafety, hasShellBraceExpansion, } from './shell-safety-rules.js'; -import { - gitConfigMayExecutePrograms, - type ShellReadOnlyCheckOptions, -} from './git-config-safety.js'; const READ_ONLY_ROOT_COMMANDS = new Set([ 'awk', @@ -220,10 +214,7 @@ function evaluateGitBranchArgs(args: string[]): boolean { return args.length === 0 || (args.length === 1 && args[0] === '--list'); } -function evaluateGitCommand( - tokens: string[], - checkOptions?: ShellReadOnlyCheckOptions, -): boolean { +function evaluateGitCommand(tokens: string[]): boolean { let index = 1; while (index < tokens.length && tokens[index]!.startsWith('-')) { const flag = tokens[index++]!.toLowerCase(); @@ -251,33 +242,21 @@ function evaluateGitCommand( return false; if (options.some((arg) => /^(?:--help|--version)$/i.test(arg))) return false; - let allowed: boolean; if (subcommand === 'remote') { - allowed = evaluateGitRemoteArgs(args); - } else if (subcommand === 'branch') { - allowed = evaluateGitBranchArgs(args); - } else if (['blame', 'diff', 'log', 'show'].includes(subcommand)) { - allowed = !options.some((arg) => /^--output(?:=|$)/.test(arg)); - } else { - allowed = true; + return evaluateGitRemoteArgs(args); + } + + if (subcommand === 'branch') { + return evaluateGitBranchArgs(args); } - // A whitelisted sub-command can still execute programs configured in the - // repository-local `.git/config` (diff.external, core.fsmonitor, pagers, - // credential/ssh helpers). Require confirmation when such keys are - // present, or when the effective directory after an unresolvable `cd` is - // unknown. See issue #8575. - return ( - allowed && - !checkOptions?.unknownDir && - !(checkOptions?.cwd && gitConfigMayExecutePrograms(checkOptions.cwd)) - ); + if (['blame', 'diff', 'log', 'show'].includes(subcommand)) { + return !options.some((arg) => /^--output(?:=|$)/.test(arg)); + } + return true; } -function evaluateShellSegment( - segment: string, - checkOptions?: ShellReadOnlyCheckOptions, -): boolean { +function evaluateShellSegment(segment: string): boolean { if (!segment.trim()) { return true; } @@ -345,102 +324,18 @@ function evaluateShellSegment( } if (normalizedRoot === 'git') { - return evaluateGitCommand([normalizedRoot, ...args], checkOptions); + return evaluateGitCommand([normalizedRoot, ...args]); } return true; } -/** - * Update the tracked execution directory across compound segments. `cd` - * with a statically resolvable target moves the probe's base directory; - * anything unresolvable (`cd` alone, `cd -`, flag-only forms, multi-arg - * forms, expansions, a subshell-wrapped cd) marks the directory as unknown - * so later git segments are downgraded (#8575). `pushd`/`popd` never reach - * this function — they are not whitelisted read-only roots, so their - * segments are rejected before tracking runs. - */ -const CD_COMMAND = /^cd(?:[)\s]|$)/; - -function trackDirectoryChange( - segment: string, - currentCwd: string | undefined, -): { currentCwd?: string; unknownDir: boolean } { - const trimmed = segment.trim(); - const wrapped = trimmed.startsWith('('); - const bare = wrapped ? trimmed.replace(/^\(+\s*/, '') : trimmed; - if (!CD_COMMAND.test(bare)) { - // A disguised cd still changes the directory in bash even though the - // raw text misses the bare-cd regex: quoted or escaped roots (`"cd"`, - // `'cd'`, `\cd`) are unquoted before command lookup, and a glued - // input redirection (`cd|;&]/.test(target)) { - return unknown; - } - const resolved = path.isAbsolute(target) - ? target - : currentCwd - ? path.resolve(currentCwd, target) - : undefined; - if (!resolved) return unknown; - // bash refuses to enter a missing target or a non-directory and stays - // put; fail closed regardless — the target can appear before execution. - try { - if (!fs.statSync(resolved).isDirectory()) return unknown; - } catch { - return unknown; - } - return { currentCwd: resolved, unknownDir: false }; -} - /** * @deprecated Use `isShellCommandReadOnlyAST` from `./shellAstParser.js` instead. * This function uses regex + shell-quote for command parsing with known edge-case * limitations. The AST-based replacement provides accurate parsing via tree-sitter-bash. - * - * @param command - The shell command string to evaluate. - * @param checkOptions - Optional `cwd` so git commands can be downgraded when - * the repository-local config contains program-executing keys (#8575). */ -export function isShellCommandReadOnly( - command: string, - checkOptions?: ShellReadOnlyCheckOptions, -): boolean { +export function isShellCommandReadOnly(command: string): boolean { if (typeof command !== 'string' || !command.trim()) { return false; } @@ -451,65 +346,12 @@ export function isShellCommandReadOnly( ) return false; - const segments = splitCommandsWithSeparators(command); - - let currentCwd = checkOptions?.cwd; - let unknownDir = checkOptions?.unknownDir === true; - let dirChanged = false; - let diverged = false; - - for (let index = 0; index < segments.length; index++) { - const segment = segments[index]!.command; - const incoming = index > 0 ? segments[index - 1]!.separator : null; - // A segment after a non-`&&` operator (`;`, `||`, `|`, newline, `&`) - // also runs when a preceding cd did not take effect, so the tracked - // directory no longer applies once one was involved (#8575). - if (incoming !== null && incoming !== '&&' && dirChanged) { - diverged = true; - } - if (diverged) { - unknownDir = true; - currentCwd = undefined; - } - const segmentOptions: ShellReadOnlyCheckOptions | undefined = unknownDir - ? { cwd: undefined, unknownDir: true } - : currentCwd - ? { cwd: currentCwd } - : undefined; - if (!evaluateShellSegment(segment, segmentOptions)) { + const segments = splitCommands(command); + + for (const segment of segments) { + if (!evaluateShellSegment(segment)) { return false; } - if (diverged) continue; - // Every pipeline member runs in a subshell — a cd there never moves - // the directory the following segments execute in (#8575). - if (incoming === '|' || incoming === '|&') continue; - // A `&` backgrounds the segment the same way: a cd there runs in a - // subshell and leaves the tracked directory alone (#8575). - if (segments[index]!.separator === '&') continue; - const tracked = trackDirectoryChange(segment, currentCwd); - if (tracked.unknownDir) { - unknownDir = true; - currentCwd = undefined; - dirChanged = true; - } else if ( - tracked.currentCwd !== undefined && - tracked.currentCwd !== currentCwd - ) { - // A cd joined by `||` may be skipped entirely (the preceding - // segment succeeded), in which case the following segments run in - // the prior directory — it must be clean too (#8575). - if ( - incoming === '||' && - currentCwd !== undefined && - gitConfigMayExecutePrograms(currentCwd) - ) { - unknownDir = true; - currentCwd = undefined; - } else { - currentCwd = tracked.currentCwd; - } - dirChanged = true; - } } return segments.length > 0; From 8000fafd7bba6a91adccd05f118ef027293f7e0d Mon Sep 17 00:00:00 2001 From: yiliang114 Date: Sat, 8 Aug 2026 15:32:35 +0800 Subject: [PATCH 15/15] fix(core): use Git config semantics for read-only probes --- .../2026-08-08-read-only-git-config-safety.md | 9 ++ .../core/src/core/plan-mode-shell-policy.ts | 9 +- packages/core/src/followup/speculation.ts | 1 + .../core/src/followup/speculationToolGate.ts | 15 ++- .../src/memory/memory-scoped-agent-config.ts | 5 +- .../permissions/permission-manager.test.ts | 14 +++ .../src/permissions/permission-manager.ts | 23 ++++- packages/core/src/tools/monitor.test.ts | 2 +- packages/core/src/tools/monitor.ts | 18 ++-- packages/core/src/tools/shell.ts | 9 +- packages/core/src/utils/git-config-safety.ts | 84 ++++++++++++++++ .../core/src/utils/shellAstParser.test.ts | 97 +++++++++++++++++++ packages/core/src/utils/shellAstParser.ts | 87 ++++++++++++++++- 13 files changed, 349 insertions(+), 24 deletions(-) create mode 100644 docs/design/2026-08-08-read-only-git-config-safety.md create mode 100644 packages/core/src/utils/git-config-safety.ts 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)) + ); } }