Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
1bec78f
fix(shell): reject executable Git diff drivers
SLP-DEV1 Aug 26, 2026
9b6b24e
ci: apply PR 10201 review fixes
SLP-DEV1 Aug 26, 2026
f2740ae
ci: stage PR 10201 patch script
SLP-DEV1 Aug 26, 2026
54a10d5
ci: fix PR 10201 review workflow
SLP-DEV1 Aug 26, 2026
0a74f0c
fix(shell): close Git diff-driver review gaps
github-actions[bot] Aug 26, 2026
c174e65
Merge branch 'main' into fix/10193-git-diff-driver-safety
SLP-DEV1 Aug 27, 2026
23ef367
ci: validate PR 10201 round-2 fixes
SLP-DEV1 Aug 27, 2026
fc9640c
ci: validate PR 10201 round-2 fixes
SLP-DEV1 Aug 27, 2026
57ce345
ci: add validated PR 10201 patch script
SLP-DEV1 Aug 27, 2026
d53bfc0
ci: run PR 10201 security validation
SLP-DEV1 Aug 27, 2026
a086307
ci: fix round-3 test env typing
SLP-DEV1 Aug 27, 2026
59bc1be
ci: fix vitest assertion in Git safety regression
SLP-DEV1 Aug 27, 2026
ac95907
Merge branch 'main' into fix/10193-git-diff-driver-safety
SLP-DEV1 Aug 28, 2026
ab52db8
fix(shell): expand repository Git config risk detection
SLP-DEV1 Aug 30, 2026
cca0ba2
fix(shell): fail closed for querying git remote show
SLP-DEV1 Aug 30, 2026
675cd00
chore(ci): remove PR self-modifying patch script
SLP-DEV1 Aug 30, 2026
0e25962
chore(ci): remove PR round-2 finalize workflow
SLP-DEV1 Aug 30, 2026
0f5219f
chore(ci): remove PR round-3 finalize workflow
SLP-DEV1 Aug 30, 2026
82cf55b
fix(shell): gate read-only Git config execution hooks
SLP-DEV1 Aug 30, 2026
58d3357
docs(shell): document complete read-only Git config safety model
SLP-DEV1 Aug 30, 2026
a30c28a
ci: add one-shot source fix for PR 10201
SLP-DEV1 Aug 31, 2026
5f0fe1c
ci: run one-shot PR 10201 source validation
SLP-DEV1 Aug 31, 2026
f32bc61
chore(ci): remove temporary PR source helper
SLP-DEV1 Aug 31, 2026
7297642
chore(ci): remove temporary PR source workflow
SLP-DEV1 Aug 31, 2026
0e5e411
ci: stage final PR 10201 security validation
SLP-DEV1 Aug 31, 2026
c2aac90
fix(shell): cover remaining Git execution hooks
github-actions[bot] Aug 31, 2026
58188d8
test(shell): strengthen Git config scope and filter regressions
SLP-DEV1 Aug 31, 2026
2c6e981
Merge branch 'main' into fix/10193-git-diff-driver-safety
SLP-DEV1 Aug 31, 2026
a3c9dba
fix(shell): cover remaining executable Git config families
SLP-DEV1 Aug 31, 2026
3f237ba
fix(shell): close git remote terminator and remerge helper gaps
SLP-DEV1 Aug 31, 2026
1cde66a
fix(shell): make Git config consumer checks fail closed by risk
SLP-DEV1 Aug 31, 2026
87f3099
test(shell): cover remaining Git config safety bypasses
SLP-DEV1 Aug 31, 2026
738da08
test(shell): use canonical Git config safety test name
SLP-DEV1 Aug 31, 2026
750b63d
docs(shell): document default-deny Git config safety model
SLP-DEV1 Aug 31, 2026
80263c5
test(shell): pin boolean pager command config
SLP-DEV1 Aug 31, 2026
6d34dc0
fix(shell): close round-five Git safety gaps
SLP-DEV1 Aug 31, 2026
eb8f332
fix(shell): cover remaining executable Git config hooks
SLP-DEV1 Sep 1, 2026
da23b7b
test(shell): pin round-six execution safety regressions
SLP-DEV1 Sep 1, 2026
a53c712
fix(shell): reject persistent brace-group assignments
SLP-DEV1 Sep 1, 2026
568288b
refactor(shell): keep round-six fix focused
SLP-DEV1 Sep 1, 2026
0c8c758
docs(shell): document remaining Git execution hooks
SLP-DEV1 Sep 1, 2026
477d485
fix(shell): close round-7 Git execution gaps
SLP-DEV1 Sep 2, 2026
b9336fc
fix(shell): widen Git signature helper detection
SLP-DEV1 Sep 2, 2026
1cad7bf
fix(shell): fail closed on escaped shell tokens
SLP-DEV1 Sep 2, 2026
de90ed9
test(shell): cover round-7 Git execution regressions
SLP-DEV1 Sep 2, 2026
0c4b0ae
style(shell): preserve fallback checker formatting
SLP-DEV1 Sep 2, 2026
d5b9062
style(shell): preserve shared safety formatting
SLP-DEV1 Sep 2, 2026
6beb0f0
Merge branch 'main' into fix/10193-git-diff-driver-safety
wenshao Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions docs/design/2026-08-08-read-only-git-config-safety.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
# 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`.
Repository-local Git configuration can turn commands that look read-only into program execution. The classifier therefore treats a read-only Git invocation as safe only when the repository configuration cannot activate an executable helper for that invocation.

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.
The config probe uses `git config --includes --show-scope --null --get-regexp`, so Git owns config parsing, includes, precedence, and worktree behavior. Only effective `local` and `worktree` executable values are considered repository-controlled execution hooks; probe/parse failures fail closed. Empty helper values are ignored. Boolean `core.fsmonitor` and pager values keep their non-command semantics. For Git boolean switches that activate execution paths, explicit false values and numeric zero are false while other values fail closed as true/risky, covering Git-accepted spellings such as `-1`, `+1`, `0x1`, and unit-suffixed non-zero values.

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.
The covered execution paths are:

Other execution-bearing Git settings are follow-up work only after an independent reproduction identifies the affected read-only subcommand.
- `diff.external` and `diff.<driver>.command` (including an empty driver subsection such as `diff..command`) for `git diff`.
- `diff.<driver>.textconv` for `git blame`, `git diff`, `git log`, and `git show`.
- command-form `core.fsmonitor` for index/worktree consumers. Broad worktree/index risks use a default-deny consumer model so newly discovered Git flags do not silently create new auto-approval entrances; this covers, among others, `git blame`, `git diff`, worktree `git grep`, `git ls-files`, `git status`, and dirty/broken `git describe` paths. Proven non-consumers are maintained through one shared safe-subcommand set rather than duplicated lists.
- `filter.<driver>.clean` / `filter.<driver>.process` for worktree-content consumers. These share the same default-deny/non-consumer model as fsmonitor rather than a list of only currently known flag spellings, covering `git ls-files` option abbreviations and dirty/broken `git describe` paths.
- command-form `core.pager` / `pager.<cmd>` for Git commands otherwise classified read-only.
- repository-local/worktree `gpg.program` / `gpg.<format>.program` when effective Git configuration requests signature verification for `git log` or `git show`. Activation is resolved from effective `log.showSignature`, `format.pretty`, and `pretty.<name>` values, so a user/global pretty format containing `%G*` still protects against a repository-local GPG program while a user/global GPG program alone is not treated as repository-controlled.
- partial-clone/promisor state (`extensions.partialClone`, `remote.<name>.promisor`, and `remote.<name>.partialCloneFilter`). Promisor repositories are fail-closed for every otherwise read-only Git command: even an apparently commit-only `git log -1` can lazy-fetch when the referenced commit object is missing locally, so there is no auto-approval exemption.
- `merge.<driver>.driver`, together with remerge-capable `git log` / `git show` paths. `--remerge-diff`, `--diff-merges=remerge`, and the documented short spelling `--diff-merges=r` are recognized directly as helper-capable options in both AST and regex classifiers.
- repository-local/worktree `core.alternateRefsCommand`. Git can execute it while evaluating alternate refs for otherwise read-only history commands such as `git log --alternate-refs`, so this risk is deliberately fail-closed for every read-only Git command rather than tied to a hand-maintained flag list.
- repository-local/worktree `core.hooksPath`. Read-oriented commands can still update index metadata and invoke hooks such as `post-index-change`; because the set of consumers is wider than a stable subcommand list, this risk is also fail-closed for every read-only Git command.

`git remote show <name>` is handled structurally rather than by config inspection because it can contact the remote and invoke transport helpers. It is not auto-approved unless `-n` / `--no-query` occurs before the `--` option terminator and therefore really prevents the query. The same rule is mirrored in the regex fallback classifier.

Commands that change directory before a relevant Git command ask instead of trying to simulate shell cwd state. Parser fallback is deliberately more conservative: when repository config contains any covered execution risk, a fallback-classified Git command asks rather than silently auto-executing. The fallback checks `Object.values(risk).some(Boolean)` so future risk fields cannot be accidentally omitted from its fail-closed gate. Regression tests keep the AST and fallback helper-option rules in lockstep for remote-show, remerge, and `%G*` cases.

Leading environment assignments and shell substitutions are checked on the raw command before `stripShellWrapper()` can discard them. The normal shell permission path returns `ask`; memory-scoped/dream worker permission evaluation returns `deny` because those agents run under forced-YOLO semantics where an `ask` decision would not provide a reliable stop. This prevents `GIT_CONFIG_* ... bash -c 'git …'` from opening a sibling auto-approval path. Explicit user-configured `Bash(...)` allow rules in the normal permission manager remain a separate authorization layer and can intentionally override the default `ask` decision; this PR does not change that pre-existing rule-matching behavior.

AST assignment handling also distinguishes a pure top-level assignment from an assignment inside a brace group. A standalone `FOO=bar` remains read-only, but `{ FOO=bar; } && command` is not: brace groups execute in the current shell, so the assignment can persist and change how the following command resolves or behaves. Such grouped assignments therefore classify as unknown instead of inheriting the standalone-assignment exemption.

When the repository-config probe finds active risk bits, a debug-log entry records the working directory and active risk names so confirmation changes can be diagnosed without reverse-engineering the gate.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import type { Config } from '../config/config.js';
import type { PermissionManager } from '../permissions/permission-manager.js';
import { ToolNames } from '../tools/tool-names.js';
import { createMemoryScopedAgentConfig } from './memory-scoped-agent-config.js';

describe('memory-scoped shell safety', () => {
const tempDirs: string[] = [];

const createRepo = (): string => {
const cwd = mkdtempSync(path.join(tmpdir(), 'qwen-memory-shell-'));
tempDirs.push(cwd);
execFileSync('git', ['init', '-q'], { cwd });
return cwd;
};

const permissionManager = (config: Config): PermissionManager => {
const pm = config.getPermissionManager?.();
if (!pm) throw new Error('missing permission manager');
return pm;
};

afterEach(() => {
for (const cwd of tempDirs.splice(0)) {
rmSync(cwd, { recursive: true, force: true });
}
});

it('rejects raw env assignments before shell wrapper stripping', async () => {
const projectRoot = createRepo();
const pm = permissionManager(
createMemoryScopedAgentConfig({} as Config, projectRoot, {
allowShell: true,
}),
);

await expect(
pm.evaluate({
toolName: ToolNames.SHELL,
cwd: projectRoot,
command: "bash -c 'git status'",
}),
).resolves.toBe('allow');

await expect(
pm.evaluate({
toolName: ToolNames.SHELL,
cwd: projectRoot,
command:
"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=/tmp/evil bash -c 'git diff'",
}),
).resolves.toBe('deny');

await expect(
pm.evaluate({
toolName: ToolNames.SHELL,
cwd: projectRoot,
command: "FOO=$(printf x) bash -c 'git status'",
}),
).resolves.toBe('deny');
});
});
18 changes: 17 additions & 1 deletion packages/core/src/memory/memory-scoped-agent-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,19 @@ import type {
} from '../permissions/types.js';
import { ToolNames } from '../tools/tool-names.js';
import { isShellCommandReadOnlyASTInDirectory } from '../utils/shellAstParser.js';
import { stripShellWrapper } from '../utils/shell-utils.js';
import {
hasShellSubstitution,
stripShellWrapper,
} from '../utils/shell-utils.js';
import {
AUTO_MEMORY_PINNED_DIRNAME,
getAutoMemoryRoot,
getAutoMemoryTrustedAnchor,
getUserAutoMemoryRoot,
} from './paths.js';

const LEADING_ENV_ASSIGNMENT_RE = /^\s*[A-Za-z_][A-Za-z0-9_]*=/;

type MemoryScopedPermissionManager = Pick<
PermissionManager,
| 'evaluate'
Expand Down Expand Up @@ -301,6 +306,17 @@ async function evaluateScopedDecision(
if (!opts.allowShell || !ctx.command) {
return 'deny';
}
// The scoped permission manager runs under forced-YOLO agents, where an
// `ask` decision can still auto-approve. Reject raw substitutions and
// leading environment assignments before stripShellWrapper() can erase
// them; this mirrors the main shell permission boundary without a second
// fail-open path for GIT_CONFIG_* command-scope injection.
if (
hasShellSubstitution(ctx.command) ||
LEADING_ENV_ASSIGNMENT_RE.test(ctx.command)
) {
return 'deny';
}
const isReadOnly = await isShellCommandReadOnlyASTInDirectory(
stripShellWrapper(ctx.command),
ctx.cwd ?? projectRoot,
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/tools/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8000,6 +8000,20 @@ describe('ShellTool', () => {
expect(await invocation.getDefaultPermission()).toBe('ask');
});

it('should keep env-prefixed Git wrappers confirmable before stripping', async () => {
for (const command of [
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=/tmp/helper bash -c 'git diff'`,
`GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/helper git status`,
]) {
const invocation = shellTool.build({
command,
is_background: false,
});

expect(await invocation.getDefaultPermission()).toBe('ask');
}
});

it('should request confirmation for a non-read-only command and return details', async () => {
const params = { command: 'npm install', is_background: false };
const invocation = shellTool.build(params);
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2043,6 +2043,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
* AST-based permission check for the shell command.
* - Substitution-bearing commands (any form, including inside an
* env-prefix wrapper that `stripShellWrapper` would discard) → 'ask'
* - Commands with leading environment assignments → 'ask' before wrapper stripping
* - Read-only commands (via AST analysis) → 'allow'
* - All other commands → 'ask'
*/
Expand All @@ -2054,7 +2055,13 @@ export class ShellToolInvocation extends BaseToolInvocation<
// `echo ok`, which the AST classifies as read-only. Without this
// gate the command auto-executes silently with no confirmation
// dialog and no warning. See PR #4386 R6 (cid 3298521039).
if (hasShellSubstitution(this.params.command)) {
// GIT_CONFIG_COUNT/GIT_CONFIG_KEY_* can inject executable Git config
// only into the spawned child. Keep all leading env assignments visible
// to the permission boundary instead of stripping them first.
Comment thread
SLP-DEV1 marked this conversation as resolved.
if (
hasShellSubstitution(this.params.command) ||
LEADING_ENV_ASSIGNMENT_RE.test(this.params.command)
Comment thread
SLP-DEV1 marked this conversation as resolved.
Comment on lines +2062 to +2063

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] R9-1: [certifies-falsely] [new-surface] The new GIT_CONFIG_* gate lives only in the default-permission fallback, but the confirmation surface it hands off to — getConfirmationDetails, in this same file and untouched by this diff — still derives rootCommand and the "Always allow" permissionRules from the env-stripped command, and allow-rule matching itself discards leading assignments. So the dialog this gate creates offers a rule describing the injection-free inner command, and accepting it switches the gate off permanently. This falsifies the design-doc sentence added in the same hunk, which frames rule override as only "Explicit user-configured Bash(...) allow rules".

The model proposes GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=/tmp/evil bash -c 'git diff' — a form that reaches a dialog only because this gate now stops it. The dialog shows root command git and offers Bash(git diff), with no warning mentioning the injection. One "Always allow" click persists that rule; from then on GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/evil2 git status --short round-trips to finalPermission: 'allow' with pmForcedAsk: false, and git executes /tmp/evil2 with no prompt — precisely the repository-controlled-helper execution this PR's config probe exists to stop. The same override reaches a user who never saw this dialog: any pre-existing hand-written Bash(git status) or Bash(git *) rule already matches the env-injected form.

Witness:

driven through the real ShellToolInvocation and the real evaluatePermissionFlow at 6beb0f0bdf:

{"raw":"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=/tmp/helper bash -c 'git diff'",
 "defaultPermission":"ask","dialogCommand":"<raw, unchanged>","dialogRootCommand":"git",
 "dialogPermissionRules":["Bash(git diff)"],"dialogWarnings":null}
{"raw":"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/helper git status",
 "defaultPermission":"ask","dialogRootCommand":"git",
 "dialogPermissionRules":["Bash(git status)"],"dialogWarnings":null}

round trip after persisting the dialog's own suggested rule:
  suggested rules from dialog = ["Bash(git status)"]
  {"laterCommand":"GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/evil2 git status --short",
   "defaultPermission":"ask","finalPermission":"allow","pmForcedAsk":false,"pmEvaluate":"allow"}

baseline A/B at 60ccd1a: the wrapper form -> defaultPermission "allow"
  (silent auto-execute, no dialog, no rule), so the dialog this diff newly creates
  is what introduces the laundering path for that form

tested fix: suppressing derived rules when the raw command carries an env prefix or a
wrapper flips the probe to dialogPermissionRules:[] and round-trip finalPermission:"ask",
and is not defeated by central injection because injectPermissionRulesIfMissing
(permission-helpers.ts:164-176) tests `!confirmationDetails.permissionRules` and [] is truthy

Suppress the derived rules for exactly the forms the new gate stops, keep the raw command in the dialog, and surface the injection as a warning so the user can see what they are approving — then update the design-doc sentence to say that auto-suggested rules are suppressed for these forms, not merely that user-configured rules can override:

const gatedRawForm =
  LEADING_ENV_ASSIGNMENT_RE.test(this.params.command) ||
  stripShellWrapper(this.params.command) !== this.params.command;
// ... when gatedRawForm: permissionRules = [], and push an env-injection warning

Two facts this fix rests on. permissions/rule-parser.ts:978 reads const normalizedCommand = stripLeadingVariableAssignments(command); — allow-rule matching discards leading assignments, so any persisted rule that omits the env prefix matches every later env-injected invocation of the same root/subcommand; a fix that only changes the displayed rule text, or that qualifies the rule with the prefix, does not close the match. And the design doc states explicit user-configured Bash(...) allow rules "remain a separate authorization layer and can intentionally override the default ask decision; this PR does not change that pre-existing rule-matching behavior", so the fix must narrow only the auto-suggested rule, never disable explicit rule matching — the existing lever for hiding "Always allow" is pmForcedAsk, set only when pmDecision === 'ask' && pm.hasMatchingAskRule(pmCtx) (permission-helpers.ts:139-141). One mechanism correction for whoever implements it: for the bare-prefix form stripShellWrapper is a no-op (measured stripped === raw); the prefix is dropped by rule extraction itself, shellAstParser.ts:1401-1404 returning [] for pure assignments.

Please extend the case this diff adds, "should keep env-prefixed Git wrappers confirmable before stripping" in shell.test.ts, to also assert (await invocation.getConfirmationDetails(signal)).permissionRules is [] for both commands in its list (today it yields Bash(git diff) and Bash(git status)), plus a round-trip assertion that after persisting the dialog's own suggested rule evaluatePermissionFlow still yields finalPermission: 'ask' for the same raw command — deleting the suppression must turn both red.

中文说明

[Critical] R9-1:[certifies-falsely] [new-surface] 新的 GIT_CONFIG_* 门只存在于默认权限回退分支中,但它交棒给的确认界面 —— 同一个文件里、本 diff 未改动的 getConfirmationDetails —— 仍然从剥离环境变量后的命令推导 rootCommand 和 "Always allow" 的 permissionRules,而 allow 规则匹配本身也会丢弃前导赋值。于是这个门所创建的对话框提供了一条描述"无注入内层命令"的规则,接受它就会永久关闭这个门。这与同一 hunk 中新增的设计文档表述相矛盾 —— 那句话把规则覆盖限定为"用户显式配置的 Bash(...) allow 规则"。

模型提出 GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.external GIT_CONFIG_VALUE_0=/tmp/evil bash -c 'git diff' —— 这种形式之所以会走到对话框,正是因为这个新门拦住了它(在此之前它会静默自动允许,既没有对话框也没有规则)。对话框显示根命令为 git,并提供 Bash(git diff),警告中完全不提这次注入。用户点一次 "Always allow" 就会持久化该规则;此后 GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=core.fsmonitor GIT_CONFIG_VALUE_0=/tmp/evil2 git status --short 会一路走到 finalPermission: 'allow'pmForcedAsk: false,git 在没有任何提示的情况下执行 /tmp/evil2 —— 这恰恰是本 PR 的配置探测要阻止的"仓库可控辅助程序执行"。同样的覆盖也会影响从未见过这个对话框的用户:任何既有的手写 Bash(git status)Bash(git *) 规则已经能匹配这种带环境注入的形式。

修复方式是:只对新门拦下的这些形式抑制推导出的规则,在对话框中保留原始命令,并把注入作为警告显示出来,让用户看清自己批准的是什么 —— 然后更新设计文档那句话,说明对这些形式会抑制自动建议的规则,而不只是说用户配置的规则可以覆盖。

该修复依赖两个事实。permissions/rule-parser.ts:978const normalizedCommand = stripLeadingVariableAssignments(command); —— allow 规则匹配会丢弃前导赋值,所以任何省略了环境变量前缀的持久化规则都会匹配之后每一次同根/同子命令的带注入调用;只改显示的规则文本、或者给规则加上前缀限定,都关不上这个匹配。另外设计文档写明用户显式配置的 Bash(...) allow 规则"仍是独立的授权层,可以有意覆盖默认的 ask 决定;本 PR 不改变这一既有规则匹配行为",所以修复只能收窄自动建议的规则,绝不能禁用显式规则匹配 —— 隐藏 "Always allow" 的既有开关是 pmForcedAsk,仅在 pmDecision === 'ask' && pm.hasMatchingAskRule(pmCtx) 时设置(permission-helpers.ts:139-141)。给实现者的一处机制更正:对纯前缀形式,stripShellWrapper 是空操作(实测 stripped === raw);前缀是被规则抽取本身丢掉的 —— shellAstParser.ts:1401-1404 对纯赋值返回 []

请扩展本 diff 新增的 shell.test.ts 用例 "should keep env-prefixed Git wrappers confirmable before stripping",同时断言其命令列表中两条命令的 (await invocation.getConfirmationDetails(signal)).permissionRules[](今天会得到 Bash(git diff)Bash(git status)),并补一个往返断言:在持久化对话框自己建议的规则之后,对同一条原始命令 evaluatePermissionFlow 仍应得到 finalPermission: 'ask' —— 删除该抑制必须让这两条断言变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

) {
return 'ask';
}
Comment thread
SLP-DEV1 marked this conversation as resolved.

Expand Down
Loading
Loading