Skip to content

fix(core): handle simple Bash comments in permission rules - #12096

Merged
yiliang114 merged 14 commits into
mainfrom
codex/11815-bash-comments-safe
Sep 19, 2026
Merged

yiliang114 merged 14 commits into
mainfrom
codex/11815-bash-comments-safe

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 17, 2026 •

Copy link
Copy Markdown
Collaborator

What this PR does

This PR fixes the false permission decision from #11815 for simple single-line commands that are known to run through Bash. A trailing Bash comment now keeps operators in the comment from becoming phantom command segments, while non-Bash shells and unsupported syntax continue through the existing conservative path.

Why it's needed

Before this change, echo 'a' # comment ; rm -rf /tmp/x could be denied by Bash(rm *) even though Bash only executes the allowed echo. Applying Bash comment semantics to the shared shell-agnostic splitter would be unsafe for other shells, so this change limits the behavior to the permission boundary where the active shell is authoritative.

Reviewer Test Plan

How to verify

Configure Bash(echo *) as allowed and Bash(rm *) as denied, then evaluate echo 'a' # comment ; rm -rf /tmp/x. Confirm that Bash returns allow, while the same text under cmd and PowerShell remains deny. Also confirm that multiline input, substitution syntax, and a real operator before the comment remain on the conservative deny path.

Evidence (Before & After)

origin/main: echo 'a' # comment ; rm -rf /tmp/x -> deny
this PR:     echo 'a' # comment ; rm -rf /tmp/x -> allow

The complete permissions test directory passes: 11 files, 933 tests.

Tested on

OS Status
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

Node.js v22.22.0. Verified with the repository's npm install path, full build, full typecheck, focused lint/format checks, and the complete permissions test directory.

Risk & Scope

Linked Issues

Refs #11815. Supersedes the closed #11821 with a smaller, fail-closed implementation.

中文说明

本 PR 做了什么

本 PR 修复 #11815:对确认由 Bash 执行的简单单行命令,行尾注释中的操作符不再被识别为虚假的命令分段;非 Bash shell 和不支持的语法仍沿用现有保守路径。

为什么需要

改动前,echo 'a' # comment ; rm -rf /tmp/x 可能被 Bash(rm *) 拒绝,尽管 Bash 实际只执行已允许的 echo。把 Bash 注释语义直接加入共享的 shell-agnostic 切分器会影响其他 shell,因此本次改动只在能够确定当前 shell 的权限边界应用该语义。

Reviewer 测试计划

验证方式

配置允许 Bash(echo *)、拒绝 Bash(rm *),然后评估 echo 'a' # comment ; rm -rf /tmp/x。确认 Bash 下返回 allow,相同文本在 cmd 和 PowerShell 下仍返回 deny;同时确认多行输入、substitution 语法以及注释前存在真实操作符时仍走保守的 deny 路径。

前后证据

origin/main: echo 'a' # comment ; rm -rf /tmp/x -> deny
本 PR:       echo 'a' # comment ; rm -rf /tmp/x -> allow

完整 permissions 测试目录通过:11 个文件,933 个测试。

测试平台

OS 状态
🍏 macOS ✅
🪟 Windows ⚠️
🐧 Linux ⚠️

环境

Node.js v22.22.0。已使用仓库权威的 npm 安装路径验证全量 build、全量 typecheck、聚焦 lint/format 检查以及完整 permissions 测试目录。

风险与范围

关联 Issue

Refs #11815。以更小、fail-closed 的实现替代已关闭的 #11821。

@yiliang114

Copy link
Copy Markdown
Collaborator Author

E2E Test Report

Result: PASS

Baseline reproduction on origin/main (204c81886b40): Bash executed only echo, while permission evaluation returned deny for echo 'a' # comment ; rm -rf /tmp/x when Bash(rm *) was denied.

Current branch decision matrix:

Case Expected Observed
Simple single-line Bash comment allow allow
Same text under cmd deny deny
Same text under PowerShell deny deny
Bash multiline input deny deny
Bash substitution deny deny
Bash heredoc deny deny
Bash process substitution deny deny
Real operator before the comment deny deny
# inside a word deny deny

Verification completed on macOS arm64 with Node.js v22.22.0:

  • npm run build — passed
  • npm run typecheck — passed
  • cd packages/core && npx vitest run src/permissions — 11 files, 933 tests passed
  • Focused ESLint and Prettier checks — passed

No fail-open behavior was observed. Full TUI interaction was not used because the regression is deterministic at the permission-decision boundary and does not require model output.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Closeout pass applied the two bounded review suggestions at 3395c652a611:

  • Added fail-closed rows for # inside a word and for vertical-tab before #.
  • Documented why the boundary is ASCII space/tab only.
  • Changed Closes #11815 to Refs #11815 because the deliberately unsupported backslash cases remain open.

Verification: the full focused file passes (451 tests); the eight comment cases pass; replacing the boundary with /\s/ makes the new vertical-tab row fail, confirming the guard is load-bearing. Focused ESLint, Prettier, and git diff --check pass.

The current Ubuntu unit failure is not caused by this PR. Main CI at the exact base d7db7d16cf1b fails the same four tests with the same assertions: one workspace-settings.test.ts alias-drift failure and three hookEventHandler.test.ts invocation-id failures (main run, PR run). No unrelated base-CI patch was added here.

doudouOUC
doudouOUC previously approved these changes Sep 17, 2026

@doudouOUC doudouOUC left a comment

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.

LGTM. splitCommandForRules only collapses a bash one-liner when # sits on a space/tab boundary and the prefix has no operator/escape/expansion; everything else keeps the old splitter (fail-closed). All four Bash-rule call sites go through it, and echo a#b / echo a\v#… stay deny. Orthogonal to the splitter work in PR 11865 / PR 11765.

yiliang114 and others added 2 commits September 18, 2026 04:53
The Bash comment fast path is only sound when the string it scans is the
string the shell executes. For monitor that is false: the analysed command
is normalizeMonitorCommand()'s quote-stripped safetyCommand while monitor
spawns spawnCommand, so a '#' that only exists inside the wrapper's inner
quotes was read as an unquoted comment start and swallowed a separator the
spawned command really runs. "bash -c 'echo hi # done' ; rm -rf /tmp/x"
collapsed to one segment and resolved to allow under Bash(echo *) instead of
splitting and denying Bash(rm *); the cmd-wrapper shape behaved the same way.
Gate the fast path on run_shell_command so monitor keeps the conservative
splitter and stays covered by Bash(...) rules through it.

Also drop the "i === 0" boundary disjunct: a collapsed comment-leading
command produces a segment starting with '#', which no Bash(...) rule can
match, so "# noop ; rm -rf /tmp/x" went deny -> ask (deny -> allow with a
broad Bash allow rule). That contradicted the design doc claim that no input
can gain a broader allow decision from this change.

Pin both behaviours plus the guards the existing table never exercised
(quote state, tab boundary, the '>' and '|' bail characters, '\r') with
it.each rows. Each new row was verified to go red under the matching
single-point mutation, and green again after restoring it.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu5zarnx1r
"Keep every Bash-rule consumer on the same segmentation decision" was not
true in-tree: ShellTool.getConfirmationDetails still segments with the legacy
comment-blind splitCommands, so the confirmation dialog can list a sub-command
taken from comment text and propose a rule for it. Narrow the goal to the
run_shell_command paths inside PermissionManager and record the two remaining
legacy-splitter consumers (custom commands, the confirmation dialog) under
Non-goals with #11882 as the owner.

Also document the two conditions this change adds to the fast path (tool must
be run_shell_command; the '#' must not be at index 0) and the matching
acceptance criteria, in EN and zh-CN together.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu5zarnx1r
yiliang114 and others added 5 commits September 18, 2026 09:54
`splitCommandForRules` only refused to collapse a `#` sitting at index 0, so
any leading whitespace still put the whole line on the comment fast path:
`' # noop ; rm -rf /tmp/x'` became one segment that no `Bash(...)` rule can
match, and an explicit `deny` rule silently stopped applying even though the
index-0 spelling of the same text kept matching. Bash executes nothing for
either spelling, so the defect is the permission layer certifying against the
user's own rule on incidental whitespace. Require non-whitespace code before
the `#`, and keep both design-doc twins (EN + zh-CN, per AGENTS.md) stating the
widened precondition and its acceptance criterion.

Test-only follow-ups in the same file:
- pin the three other Bash-rule consumers on the same segmentation decision
  (`findMatchingDenyRule`, `hasRelevantRules`, `hasMatchingAskRule`), each
  bash arm paired with the `cmd` arm that still expects the split, so reverting
  any one call site to `splitCompoundCommand` goes red;
- title each row of the comment table by its command string, so a regression
  names the flipped input instead of repeating one label 13 times;
- hoist the `shellTypeMock` reset to file scope, covering the five later
  top-level describes that build real PermissionManagers from the same
  file-global singleton.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu6a0kjc2c
Only packages/core/src/permissions/permission-manager.test.ts conflicted:
both sides appended an independent it.each block at the same anchor inside
the same describe. Resolved as a pure union, verified with zero deletions
against either parent: plus 177 lines versus the PR side (main's 11865
rows) and plus 142 lines versus main (this PR's comment rows).

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu6eaw492k
Three follow-ups from review. No behaviour change: one docblock comment,
two design-doc corrections, and three test rows.

- R1-5: add the three missing guard rows to the comment it.each. The
  backslash, the dollar sign and the backtick are disjuncts of their own on
  the bail line, not members of the operator literal, and no existing row
  made any of them the first guard character the scan meets, so deleting any
  one of the three left the whole suite green. Each new row now dies under
  exactly its own single-disjunct mutation (verified M1/M2/M3 individually,
  1 failed / 495 each, source restored byte-identical).
- R2-2: state on isCommandAllowed that it hardcodes run_shell_command, name
  the two callers that pass splitCommands fragments rather than literal
  shell text, and record why the whitespace collapse stays sound for them
  (both split first, and that scanner already treats newlines as separators).
- R1-3 / R1-4: correct both design docs. The Non-goal about the legacy
  splitter read as if custom commands were untouched; they do pick up the
  fast path through isCommandAllowed, which changes shellProcessor's
  whole-text isAllowedBySettings short-circuit. Risks now records that the
  virtual shell-operation pass still evaluates Read/Edit/Write/WebFetch
  rules against commented-out text - escalate-only, and predating this PR,
  which does not touch the extractor path.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu6eaw492k
Rows 2-4 (the backslash shapes) and row 6 (the apostrophe shape) of the
measured table had no test anywhere in packages/core/src. Rows 2-4 join
the existing comment table, where Bash(echo *) covers the single masked
segment. Row 6 needs its own case: that segment starts with `git`, so
the table's echo rule cannot match and the verdict would come from the
read-only default instead of a rule; it also pins the one-segment split,
because a bare `echo B` resolves to allow on its own.

All four reach their verdict via unterminated-quote masking that the
still-open #11765 will change, so pinning them makes that flip visible
instead of silent.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu6gg1w62o
Under a deny-rule-only config the comment fast path moves a comment-bearing
command from a hard deny to the tool default ask, and the confirmation dialog
that ask now reaches still segments with the legacy comment-blind splitter:
for "echo 'a' # comment ; rm -rf /tmp/x" under deny: ['Bash(rm *)'] it lists
the never-executed "rm -rf /tmp/x" as confirmable and proposes Bash(rm *)
from text sitting inside the comment. One "Always allow" click persists that
rule; it stays inert while the operator's own deny stands and goes live the
moment that deny is edited or removed.

Both halves were measured at 124f193, not inferred:

  evaluate(deny-only)                  = ask   (merge base: deny)
  findMatchingDenyRule / hasRelevant   = undefined / false
  dialog splitCommands                 = ["echo 'a' # comment","rm -rf /tmp/x"]
  extractCommandRules('rm -rf /tmp/x') = ["rm *"]
  evaluate(allow rm + deny rm)         = deny  (deny priority holds)
  evaluate(allow rm only)              = allow (latent arm)
  evaluate(deny-only, real rm)         = deny  (fast path does not leak)

The demotion is the intended direction of #11815 and is not reversible
without also breaking the allow+deny acceptance row, which must stay allow:
a gate bailing out on a deny match inside the hidden segment would bail out
in that case too. So no production code changes here. The dialog half is
already a stated Non-goal of this PR, owned by #11882.

What this adds is the record the Non-goal left out. The transition is now in
both design docs' "Risks and constraints" with an acceptance bullet, and
pinned by a permission-manager.test.ts row so it is deliberate rather than
incidental. Reverting splitCommandForRules to splitCompoundCommand reds the
new bash arm back to deny (verified by mutation).

Refs #11815. Dialog convergence remains #11882.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu6p0oyy34
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Applied the two remaining bounded verification suggestions in 2a62ffe0c8:

  • Reordered the pure predicates in splitCommandForRules so the O(1) space/tab boundary check runs before slice(...).trim(). The decision result is unchanged, while #-dense inputs no longer repeatedly rescan the leading prefix.
  • Corrected both design docs: splitCommands separates LF/CRLF, and the fast path own command.includes("\\r") guard rejects a remaining lone CR.

Verification: focused ESLint passed, Prettier passed, and git diff --check passed. The focused Vitest file could not start in the isolated fresh worktree because the workspace dist/ prerequisites were absent; an attempted core build exposed unrelated missing shared dependencies (QuickJS/OpenTelemetry), so I did not claim a unit-test pass from that environment.

No permission semantics or scope beyond those two review suggestions changed.

@qwen-code /review

R1-15 (Critical): the guard added with the comment fast path asked whether
anything but whitespace preceded *this* `#` — but an earlier `#` and its
comment text are not whitespace, so a comment-only line carrying a second
word-start `#` still collapsed into one segment. That segment starts with
`#`, so no `Bash(...)` rule can match any text in it and an explicit deny
silently stops applying: `# noop # ; rm -rf /tmp/x` returned `ask` under
`deny: ['Bash(rm *)']`, and `allow` under a broad `Bash(*)`. Test the line
instead — `!command.trimStart().startsWith('#')` — so every spelling of a
comment-only line splits conservatively, matching the merge base.

R3-2: the comment paths claimed the scanned string is literally the text the
shell executes. It is not, and string equality is unachievable: execution
rewrites the text after this decision (`ShellTool.execute()` splices
attribution trailers, cmd/PowerShell prepend an `applyUtf8Prefix()` prefix),
and three production callers pass something other than the original command —
including `shellProcessor.ts`'s whole un-split `!{...}` injection. State the
property the fast path needs and record what actually guarantees it, rather
than documenting a guarantee the code does not give and a guard a maintainer
would delete as dead.

R3-3: pin the deny-only criterion at the layer that decides it. The collapse
makes `hasRelevantRules` false, so the shipped verdict for a shell-wrapper
command is L3's `ShellToolInvocation.getDefaultPermission()`, which gates
substitution on the raw command but classifies `stripShellWrapper(command)`
and returns `allow` where the merge base returned `deny`. `pm.evaluate()`
structurally cannot observe that, so the flow is now pinned by a test.

R1-5: nine single deletions inside `splitCommandForRules` each left the suite
green. Add one row per surviving disjunct — the six remaining bail-literal
characters `&`, `<`, `(`, `)`, `{`, `}` and both quote cross-checks — each
making its target the FIRST guard the scan meets, and carrying no other bail
character ahead of its `#` so its deletion really flips the verdict. All nine
are verified red by a deletion sweep.

Both design-doc twins document the reworded property, the load-bearing line
guard, and the L3 fall-through in the same change.
@yiliang114

Copy link
Copy Markdown
Collaborator Author

@doudouOUC re-requesting your review — your LGTM was voided automatically by the push below, not withdrawn, and this repo dismisses stale approvals on every new commit.

Head moved 2a62ffe0c8 → b0f1713b66. It closes the five findings from the round-3 review that were still open, each with an evidence reply on its own thread:

  • R1-15 (Critical) — the comment fast path now tests the line rather than the individual #: !command.trimStart().startsWith('#') replaces command.slice(0, i).trim() !== '', so a comment-only line carrying a second word-start # (# noop # ; rm -rf /tmp/x) splits conservatively again and an explicit deny keeps applying. The three pinned allow rows and the index-0 / leading-space / leading-tab deny rows are unchanged.
  • R3-2 — both docblocks reworded to state the property the fast path actually needs instead of claiming string equality with the executed text, and the third caller (shellProcessor.ts's un-split !{...} injection) is now named. It also corrects the earlier claim that the one-physical-line guard is unreachable: splitCommands splits \n/\r\n only outside quotes, backticks and substitutions, so the guard is load-bearing.
  • R3-3 — the deny-only criterion is now pinned at the layer that decides it, with a flow test, since the collapse makes hasRelevantRules false and the shipped verdict comes from L3's ShellToolInvocation.getDefaultPermission().
  • R1-5 — one row per surviving disjunct (all nine, not the seven listed), each making its target the first guard the scan meets; all nine verified red by a single-deletion sweep.
  • R3-1 — the residual-hazard paragraph in both design-doc twins no longer claims the minted rule stays inert while any operator deny stands.

All 19 threads are resolved. Lint & Static, Test (ubuntu, Node 22) and review-pr are re-running on the new head.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-verified the R1-15 lineage against head b0f1713 — the Critical is fixed with the right shape.

The guard now tests the line (!command.trimStart().startsWith('#')) rather than the # the scan stopped at, and I re-traced every witness spelling against the head code: # noop # ; rm -rf /tmp/x, # a # b ; rm -rf /tmp/x (comment-only, any spelling of leading whitespace) → no collapse → conservative split → the operator's deny: ['Bash(rm *)'] still matches, so the falsely-certified ask/allow flip is gone. The fast path still collapses only lines with an executable head (echo hi # c ; rm …), where the comment tail is inert to bash anyway — semantics match the shell in both directions. Quote-tracked # (echo "a # b" ; rm …) and metachar-first lines (echo 'a' ; # x ; rm …) both fall through to the conservative split before the # branch is even reached.

All 19 threads are resolved and the remaining round-3 items were doc/test-layering Suggestions the author answered point-by-point. CI is green on this head (review-pr's round 4 is queued in the runner window). No blockers from me — needs a non-author vote to merge, as I can't approve my own PR.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

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.

APPROVE at b0f1713.

This relaxes a permission decision from deny to allow for a class of commands, so I reviewed it as a security change and tried to construct a fail-open rather than confirm the happy path. I could not.

All three historical Criticals are closed on this head, and each is pinned by a test row rather than by argument.

  • R1-1 (the monitor blind spot, where the scanned string is a safetyCommand reconstruction rather than what is spawned) is closed by gating the fast path on toolName === 'run_shell_command', and monitor keeps the conservative splitter.
  • Both instances of R1-15 — leading whitespace defeating an index-0 guard, and a comment-only line carrying a second word-start hash such as '# noop # ; rm -rf /tmp/x' — are closed by testing the line rather than the offset via !command.trimStart().startsWith('#'). Rows 2462, 2470, 2472 and 2473 pin all four spellings to deny.
  • The guard mutations R1-5 reported as surviving a green suite now have rows: quoted single and double hash (2474, 2475), the redirect and pipe bail characters (2476, 2477), carriage return, the tab boundary, and the non-boundary 'a#b' spelling.

Why the collapse is sound, checked against the predicate rather than the examples. The fast path fires only when the tool is run_shell_command, when getShellConfiguration().shell is bash — the same configuration the executor spawns with — when the command contains no newline or carriage return, and when the scan met none of the bail characters (backslash, dollar, backtick, semicolon, ampersand, pipe, parentheses, braces, angle brackets) before the hash. That bail set makes the pre-comment prefix provably a single simple command: no separator, no substitution, no escape, no redirection. The hash is then required to sit outside both quote states and to be preceded by an ASCII space or tab, which is exactly Bash's word-boundary condition for opening a comment. With no newline in the string the comment runs to the end of the only line, so nothing after it can execute. The three traps I went looking for are all handled: 'echo a#b ; rm -rf /tmp/x' is not collapsed because the hash fails the boundary test and the later semicolon bails; a quoted hash is skipped by the quote-state guard; and a comment-only line is never collapsed at any indentation.

The one interaction that could have turned an allow into an executed tail does not. The doc comment's worry is that ShellTool.execute() splices an attribution trailer after this decision, and a splice landing after the hash would insert a newline that ends the comment and revives the tail. At the splice site the segment is cut at findUnquotedCommentStart first, so the trailer lands ahead of the hash. The applyUtf8Prefix case cannot arise at all: it applies only to cmd and PowerShell, and the fast path requires bash.

Required CI is complete and green on this head — unit tests, lint and static, integration (no-AK), desktop shell on both OSes, web-shell E2E smoke. review-pr is the reviewer bot's own workflow and is excluded deliberately; the main ruleset declares no required status checks.

No new Critical. One residual I want on the record rather than buried, because it is the part an operator can act on. It is disclosed in both design-doc twins and owned by #11882, and it widens a pre-existing dialog defect rather than being a defect in this diff:

  • Under a deny-only configuration narrower than what extractCommandRules derives, the intended deny-to-ask demotion reaches a confirmation dialog that still segments with the legacy comment-blind splitter. It therefore offers Bash(rm *) derived from text inside a comment, and one Always-allow click persists a rule that immediately and permanently outranks the operator's narrower deny. Bash never executes that text, so this is not a bypass, but the minted rule is real. The docs' guidance — treat a Bash allow rule proposed on a comment-bearing command as untrusted until #11882 converges the splitter — is the right call and worth carrying into the release note.
  • Also disclosed and worth knowing: shellProcessor gates a bang-brace injection two ways over the same string, so an injection that prompted at the merge base can now skip confirmation. Defensible for the same reason, but it is a live confirmation-behaviour change.

已核对 head b0f1713:三个历史 Critical 均已关闭且各有测试钉住,required CI 全绿,我按安全变更审查并尝试构造 fail-open 未果,未发现新的 Critical。

@qqqys qqqys left a comment

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-only review at b0f1713b, base c2902ae7.

Verdict: COMMENT — no new Critical found, and one of the two historical blocking issues is confirmed fixed at this head. The other rests on a cross-file invariant I could not confirm within this pass's budget, so this is not an approval. Both items are listed below with what was and was not established.

Confirmed fixed: the comment-only-line family

The earlier rounds chased a guard that kept closing only one spelling of the same case — first column 0, then the whitespace-led spelling. At this head the guard tests the line rather than the individual character:

(command[i - 1] === ' ' || command[i - 1] === '\t') &&
!command.trimStart().startsWith('#')

I traced the shapes that previously slipped through. A line whose first non-whitespace character is # never collapses, at index 0 or behind any number of spaces and tabs, and regardless of a second word-start # later in the comment text — so # note # more falls through to conservative splitting instead of collapsing to a segment that no Bash(...) rule could match. A genuine trailing comment on a real command, git status # don't, still collapses as intended. A # that is not word-started, as in git#status, correctly does not collapse, matching Bash's own rule that only a word-initial # begins a comment. Testing the line is what makes the second-# case split conservatively without needing to enumerate positions.

The surrounding guards also hold up. The fast path requires run_shell_command, a bash shell configuration, and a single physical line, and it bails to conservative splitting on encountering \, $, a backtick, or any of ;&|(){}<> before reaching a #. Quote state is tracked in both directions with the usual mutual exclusion. The consequence of collapsing is that the whole string is evaluated as one segment, and since the executed portion is a simple command with no separator and no substitution ahead of the comment, nothing that runs is hidden from rule matching. The direction of every failure I could construct is towards more scrutiny rather than less. monitor is excluded for a stated and coherent reason: its scanned string is a reconstruction that is not the string spawned.

Not confirmed: the scanned-string versus executed-string invariant

This was the substance of the first round's Critical, and the head's own documentation states the mitigation precisely: the collapse is sound only if a # recognised here still begins a comment in the text the shell executes, and equality is unachievable because the attribution trailer is spliced into a quoted argument after this decision and because cmd/PowerShell execution prepends an encoding prefix. The argument offered is that both rewriters trim a trailing unquoted comment first, so the splice lands ahead of the #, and that a rewriter splicing after it would insert a newline that ends the comment and revives the tail.

I confirmed half of that. applyUtf8Prefix in packages/core/src/services/shellExecutionService.ts prepends, and prepending cannot revive a trailing comment; combined with the guard that refuses to collapse a line starting with #, the prefix also cannot merge with a leading # to change where the comment begins.

I could not confirm the other half. The comment-splitting helper splitTrailingShellComment in packages/core/src/tools/shell.ts has exactly two non-test call sites, detectBlockedSleepPatternDetails and hasTopLevelTrailingBackgroundOperator, and neither is the attribution-trailer splice. trimTrailingShellComment is reached only from the second of those. So the claim that the trailer path trims a trailing comment before inserting is not something I could locate, and this PR does not add it. That leaves the invariant asserted in a comment rather than demonstrated in code, on the one axis where this PR has already been through three Critical rounds.

Also unconfirmed, for the same budget reason: the new documentation on the permission-decision entry point records that three production callers pass a reconstruction rather than the original command, and argues soundness there rests on the caller's own pre-split and on a hard denial for command substitution rather than on the carriage-return disjunct. The reasoning is plausible and unusually well documented, but I did not verify it against those callers.

CI

No failures across this head's checks, with the ubuntu test lane, the lint and static checks and the no-AK integration lane all green; a small number of routing-class jobs were cancelled and two remain queued, neither attributable to this change and neither treated as a gate. The added test coverage in the two test files passes on that lane.

Suggested next step

Make the trailer half of the invariant checkable rather than asserted: point at the splice site that trims the trailing comment, or add that trim, and ideally pin it with a test that a command carrying a trailing comment still has the trailer land ahead of the #. With that in place the remaining item above closes on evidence, and the reconstruction-caller argument is worth the same treatment so the next reviewer does not have to reconstruct it.

@qqqys

qqqys commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Correction to our review 5254936594 (2026-09-19T06:59:59Z) on this head: the invariant we said we could not confirm is confirmed, one of our two claims was a false negative, and our "suggested next step" asks for code that already exists.

Our earlier comment declined to approve on two unconfirmed items. Both are now settled against the head blob.

Head note, so the line numbers below are unambiguous. We measured at b0f1713b6632, the head our review was filed against. The branch has since taken a merge from main and the head is now bc25f00cae06. Every file cited here is blob-identical across that move — permission-manager.ts 2b1a2e7499bb, tools/shell.ts 6a3e3d780124, utils/shell-utils.ts 9c7b95613848, shellProcessor.ts 6ea440b693e5, with shell.ts and shell-utils.ts re-extracted at the new head and compared byte-for-byte (225,863 B and 72,525 B, cmp identical) — so every line number and every measured result below holds at bc25f00cae06 unchanged. The merge brought in main's changes, not changes to this PR's production delta.

1. The attribution-trailer half — our claim was wrong; we grepped for the wrong symbol

We wrote that splitTrailingShellComment has exactly two non-test call sites and that neither is the trailer splice, concluding "the claim that the trailer path trims a trailing comment before inserting is not something I could locate, and this PR does not add it."

The trim is present. Both rewriters trim through findUnquotedCommentStart — defined at packages/core/src/tools/shell.ts:189, with exactly two call sites, one per rewriter:

rewriter trim site
addCoAuthorToGitCommit shell.ts:4851-4855
addAttributionToPR shell.ts:5003-5005

Each takes fullSegment from the attributable segment range, then const segment = commentStart >= 0 ? fullSegment.slice(0, commentStart) : fullSegment;, and only afterwards runs lastMatchOf(segment.matchAll(...)) into pickOuterLastMatch. So the -m / --body match that keys the splice cannot be selected out of comment text, and the inserted \n\nCo-authored-by: … lands inside the quoted body ahead of the #. That is the property the docblock claims, and it is why a newline inserted there cannot end the comment and revive the tail.

qwen-code-ci-bot read this correctly in its 2026-09-17 review of this head (it cited shell.ts:4845-4850 — a few lines off, same code). Our comment contradicted it and should not have. Nothing is needed from the author here: the "point at the splice site that trims the trailing comment, or add that trim" next step is already satisfied, and this is that pointer.

2. The reconstruction-caller half — the argument holds; all three callers walked

  • tools/shell.ts:2158 passes un-normalized splitCommands() fragments. A # preceded by a space or tab inside such a fragment is preceded by the same character in the text that executes, so the collapse cannot fire on a boundary the shell does not have.
  • utils/shell-utils.ts:2154 is the one worth pinning, because :2128-2129 folds each fragment with trim().replace(/\s+/g, ' ') first, and that fold can manufacture a word boundary bash does not have. Measured on GNU bash 4.4.20(1) using echo HEAD_CMD<ch># foo && echo TAIL_RAN, the tail executes — i.e. the # does not begin a comment — for 16 characters that node confirms JS /\s/ matches and normalize folds to a space: \v, \f, U+00A0, U+1680, U+2000, U+2001, U+2004, U+2007, U+2009, U+200A, U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF. ASCII space and tab are the controls and do behave as comment starts; U+200B is bash-divergent but is not in /\s/, so the fold never produces it. We built the attack out of that measurement and executed it against this head (b0f1713b6632, built worktree, allow: ['Bash(echo *)'], deny: ['Bash(rm *)']): for the payload echo a<ch># comment ; rm -rf /tmp/x, all 16 characters return allAllowed: false with isHardDenial: true and disallowedCommands: ['rm -rf /tmp/x'], 16/16. It fails at two independent guards, not one: splitCommands runs at :2129 before the fold and splits on &&, ||, |&, ;, &, |, CRLF and LF outside quotes/backticks/substitution (shell-utils.ts:359-397), so rm -rf /tmp/x reaches isCommandAllowed as its own fragment (pre_split_fragments = 2 for every one of the 16); and separately, on the un-normalized path the collapse never fires at all, because splitCommandForRules requires the preceding character to be a literal space or tab — pm.evaluate on the raw string returns deny for all 16 while returning allow for the space and tab spellings. detectCommandSubstitution at :2118 independently hard-denies the substitution shapes.
    Non-vacuity, so the 16/16 is not green because the mechanism never ran: pm.evaluate('echo a # note ; echo b') returns allow at this head, i.e. the collapse does fire and does swallow a live ; when the # is a genuine bash comment start — the same shape the attack needs, denied only because the two guards above remove the boundary and the separator respectively.
  • cli/src/services/prompt-processors/shellProcessor.ts:142 passes the whole raw resolvedCommand — the same string :186-194 hands to ShellExecutionService.execute — so scanned and executed text are identical on that path.

One non-blocking observation, offered only because it is what sent us down this path: the design doc's "one physical line" bullet says splitCommands "separates only unquoted LF and CRLF". In the newline context that bullet is arguing the point stands — an unterminated quote inside comment text really does mask a following newline. Read as a description of the separator set it is incomplete (;, &, |, &&, || and |& are separators too), and the incompleteness is load-bearing for precisely the soundness argument above: the pre-split is what makes the fold unexploitable.

Scope, as of the state read immediately before posting

This corrects two factual claims in our own earlier review and nothing else. It is not an approval and carries no merge-readiness verdict. Head is bc25f00cae06, mergeStateStatus is BLOCKED, reviewDecision is REVIEW_REQUIRED, and we have posted no tmux e2e report on this PR. Note also that the two APPROVED rows now pinned to this head (qwen-code-ci-bot 06:48:38Z, qwen-code-dev-bot 06:59:40Z) were both submitted before this head's commit date of 07:47:54Z and were re-anchored onto it by the merge from main, so they are approvals of the pre-merge tree rather than of bc25f00cae06 as it now stands.

@yiliang114
yiliang114 added this pull request to the merge queue Sep 19, 2026
Merged via the queue into main with commit 9036855 Sep 19, 2026
33 checks passed

@chiga0 chiga0 left a comment

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.

No blocking findings. Approved.

Scope

Tier: Standard — security-relevant fix in the permission manager: Bash trailing-comment handling to prevent false denials from comment tails. 5 files, 525 +5 − (including two design docs and tests).

Not reviewed: Windows/macOS shell-detection paths (POSIX only); Integration Tests (CLI, No Sandbox).


What was checked

Core logic — splitCommandForRules

The fast path (treats the #-tail as a comment, returns [command] as one segment) fires only when ALL of these hold:

  1. toolName === 'run_shell_command' — excludes monitor (scanned string is normalizeMonitorCommand().safetyCommand, not the real invocation)
  2. getShellConfiguration().shell === 'bash' — cmd/PowerShell use the old splitter
  3. No \n or \r — multi-line stays conservative
  4. The scan reaches # without hitting \, $, backtick, or any operator — any of those exits to splitCompoundCommand immediately
  5. command[i-1] is ASCII space or tab — mid-word echo a#b stays conservative
  6. !command.trimStart().startsWith('#') — the LINE does not start with #

Guard 6 is the R1-15 fix at b0f1713b. The old guard (command.slice(0, i).trim() !== '') tested "is there non-whitespace before this #", which failed for # noop # ; rm -rf /tmp/x: the second word-boundary # saw '# noop ' before it, .trim() = '# noop', and !== '' was true — so the whole line collapsed and no Bash(...) rule could match the rm. The new guard tests the full line: '# noop # ; rm -rf /tmp/x'.trimStart().startsWith('#') is true, so the condition is false and the line stays conservative.

Four call sites converted: pm.evaluate, findMatchingDenyRule, hasRelevantRules, hasMatchingAskRule — all confirmed in diff. Virtual-op path at line 405 is upstream of the split and unaffected.

Cross-check against prior rounds

Finding Status at current head
R1-1: monitor unsafe with comment fast path Fixed — gated on toolName === 'run_shell_command'
R1-15 (space-led # comment) Fixed in f17f500
R1-15 fix-induced: # noop # ; rm -rf second # collapses Fixed in b0f1713b — !command.trimStart().startsWith('#') tests the line
R3-2: precondition comment overstated Fixed in b0f1713b — comment updated to "property, not string equality"

Remaining deferred items (R2-1 quote-nesting mutation survivors, R3-1/R3-3 doc/test precision) are Suggestion-level and do not block merge.


Approval blockers: none.

Reviewed with AI assistance.

TianYuan1024 added a commit to TianYuan1024/qwen-code that referenced this pull request Sep 19, 2026
…-quote-backslash-split

QwenLM#12096 pinned three `echo 'a\' # …` rows as `allow` and marked them to flip
when this branch lands: the splitter now closes `'a\'` as bash does and splits
at the operator inside the comment, so they resolve to `ask`.
TianYuan1024 added a commit to TianYuan1024/qwen-code that referenced this pull request Sep 20, 2026
bash runs one command in `git commit -m 'x' # saved to 'C:\'⏎rm draft`, but
the pre-fix reading splits inside the comment and a `Bash(rm *)` deny refuses
it. Pin that, the `monitor` spelling, and the single-line spelling QwenLM#12096's
comment fast path allows again.
yiliang114 added a commit that referenced this pull request Sep 21, 2026
…ek opt-out

The upgrade-risk section certified that v0.24.1...v0.24.2 held only 8
files / 6 PRs and that #10410 was the sole default-behaviour change.
Re-derived from the tag range: 15 non-test files under
packages/core/src/{tools,permissions}; three commits flip defaults —
#10410 (deferral), #12096 (Bash comment permission verdicts, ungated),
#12198 (undecided-workspace trust gating) — so the section now lists
all three, gains risk rows for the two ungated flips, scopes the
one-line rollback to deferral only, and notes at the re-baseline table
that #12119 changed the /context derivation in the same release.

Also extend the §0.1 status table to §6/§7: §6 row 7's DeepSeek
automatic ToolSearch opt-out was deleted by #10410 (the only remaining
kill-switch is the explicit tools.toolSearch.enabled: false at
config.ts:2102-2114), §7 item 3 is closed now that #10410 shipped, and
the plan doc's twin DeepSeek claim is corrected in the same pass.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuakc4l1ae
yiliang114 pushed a commit that referenced this pull request Sep 21, 2026
My first version certified that "only #10410 changed default behaviour" in
`v0.24.1...v0.24.2` and put that under a heading saying it is the only thing to
worry about. Review found three counterexamples in the range and I confirmed all
three:

- **#12096** (`9036855d`) adds `splitCommandForRules` at four evaluation sites in
  `permission-manager.ts` (+103/−5), gated only on tool name and whether the
  command contains a newline — no setting, no env. A comment-bearing command can
  flip deny→ask, or deny→allow under a broad `Bash(echo *)` rule. Nothing about
  deferral surfaces or undoes that, so the section as written would have sent an
  operator down a toolSearch-only ladder for a permission change.
- **#12198** (`c9839a94`) flips `isTrustedFolder()`'s undecided default.
- **#12034** (`4f027777`) added `MEMORY_CONTEXT_WARNING_MAX_TOKENS`, itself a
  default, which the sibling plan-doc row already records as live.

The "8 files / 6 PRs" count did not reproduce under any filter either, and the
list of six named two PRs that touch neither path. Re-derived from the tag range
with the filter stated: 53 commits, three touching non-test files under
`packages/core/src/{tools,permissions}/`, and a separate table for "changed a
default", which is the wider set the section actually needed. The claim is
narrowed to what is true — the *token*-relevant default change is #10410 — and a
risk row for #12096 is added whose detection step is re-testing existing
`Bash(...)` deny rules against comment-bearing commands, with a note that the
one-line rollback cannot touch it.

§8 item 0 gains the instrument caveat: #12119 shipped in the same release and
rewrote how `/context detail` derives the built-in-tools row, so a pre-upgrade
and a post-upgrade reading are not the same ruler. Both runs should record the
whole `/context detail` output rather than one category number.

Claude-Session: https://claude.ai/code/session_01P31kyHJf9VXo8w6h9uZetg
yiliang114 pushed a commit that referenced this pull request Sep 21, 2026
…wo rulers

`0ed2941b` fixed the enumeration this review flagged, including the #12096
permission-verdict row. One half of the same finding is still open: §8 item 0
asks the reader to subtract a pre-upgrade `/context detail` reading from a
post-upgrade one and call the difference #10410's saving, but #12119 shipped in
the same release and rewrote how that row is derived. The two numbers come from
different instruments, so the subtraction silently folds a measurement-method
change into the attribution the item exists to separate.

Asks for the whole `/context detail` output on both runs rather than the one
category number, so the method difference stays recoverable afterwards, and says
what the first row is worth if only a single figure survives.

Claude-Session: https://claude.ai/code/session_01P31kyHJf9VXo8w6h9uZetg
yiliang114 added a commit that referenced this pull request Sep 22, 2026
…fix bullets

context-cost.md: the prefix-cache bullet stated prefix stability for deferral
unconditionally. Carry the exception tool-search.ts:133 documents -- a tool-set
refresh may still re-declare a withheld tool when the live history contains a
direct call to it -- without implying the bridge path itself reveals. (R3-7)

context-token-governance README: the #12096 row's detection column audited only
the deny side, which cannot surface the flip's lasting consequence. Rewrite it
to lead with the allow side (re-audit the broad Bash(...) allow rule minted by
an "Always allow" click on a comment-bearing command) and link the in-repo
design doc that records the hazard. (R3-9)

R3-5 is declined on the thread with evidence: no reachable configuration puts a
tools.eager-demoted tool both outside getDeferredToolSummary() and genuinely
unreachable for the session.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmubzrz80d3
wenshao pushed a commit to doudouOUC/qwen-code that referenced this pull request Sep 22, 2026
…ntested (QwenLM#12355)

* fix(core,docs): correct what the deferred-tool bridge made stale or untested

QwenLM#10410 landed (`70edbf47`) and invalidated three claims that are now in main,
two of them mine, plus it exempted a tool from the eager allowlist without a
test.

**An exemption shipped untested.** `isExemptFromEagerAllowList` gained
`tool_call` — necessary, because `tool_search` reviews a withheld schema and
`tool_call` invokes it, so withholding `tool_call` under a narrow allowlist
would leave every demoted tool readable and uncallable. The exemption table in
`permission-manager.test.ts` lists the other seven and not this one, so
deleting that arm of the source left the whole suite green. Added, which also
extends the paired "a whole-tool deny still wins over the exemption" case to it.

**`docs/users/features/context-cost.md` was advising against the new default.**
Its trap said a mid-session reveal invalidates the prompt-cache prefix and that
`threshold: 0` only wins when a session never needs the deferred tools. The
bridge keeps the declared list byte-stable, so discovery no longer rebuilds the
prefix, and `threshold` now defaults to `0` for exactly that reason. Rewritten
to say what the cost actually is — one round trip before first use — and when
raising the threshold buys it back.

Two smaller corrections on the same page: the exempt-family list omitted
`tool_call`, and "it needs `tool_search` to stay on" is now both halves of the
bridge, with the asymmetry that matters spelled out — ordinary deferred tools
fall back to eager declaration when a bridge half is missing, while tools
demoted by `tools.eager` do not. It also gains a baseline note, because an
allowlist's saving is now only the eager-by-default schemas it withholds: the
on-demand pool already left the first request, so a figure measured before
QwenLM#10410 attributes that saving to the wrong lever.

**The plan doc's cost model is void, not pending.** Its section 6 carried a
warning that QwenLM#10410 *would* invalidate the arithmetic; it has, so the note now
says so outright and names what replaced it, and the status row for QwenLM#12029
records that both halves of that issue are settled.

Refs QwenLM#12028, QwenLM#12029

Claude-Session: https://claude.ai/code/session_01P31kyHJf9VXo8w6h9uZetg

* docs(verification): re-baseline the token handoff for v0.24.2

The handoff brief was written on 2026-09-16 against a build where
`tools.toolSearch.threshold` defaulted to `10`, so the deferred pool was
preloaded at session start whenever it fit in 10% of the window — which on a 1M
window it always did. `v0.24.2` ships QwenLM#10410, which makes discovery go through
the `tool_search` → `tool_call` bridge without rewriting the declared list, and
flips that default to `0`. Two of the brief's sections are void as a result: its
measured baseline, and its cost model for demotion.

Adds §0.1, placed before everything else and pointed at from the header:

- what changed and why the on-demand path only now engages by default, which is
  also the honest explanation for part of the "tens of thousands on the first
  turn" the brief was written to attack;
- a section-by-section list of what still holds — the allowlist semantics do,
  with `tool_call` added to the exempt families; the subagent risk does, verified
  today against `agent-core.ts`, which still asks for `includeDeferred: true`;
- the re-baseline procedure, with the version floor that silently invalidates a
  run (0.24.1 and earlier measure the old behaviour), the derived expectation
  stated as derived, and the three things to check when the number does not move;
- a risk table for what `v0.24.2` changes for existing users, with the one
  silent failure mode — the model not thinking to look for a withheld tool — and
  a one-line rollback that restores the old preload.

§8 gains the table that separates the two attributions, because the whole point
of re-measuring is to tell what QwenLM#10410 gave away for free from what an eager
allowlist is worth; measured together, the upstream saving lands on the
deployment's configuration. It also gains the routing-miss question, which has
no automated gate and matters more than any of the token figures.

Refs QwenLM#12028, QwenLM#12333

Claude-Session: https://claude.ai/code/session_01P31kyHJf9VXo8w6h9uZetg

* fix(docs): drop the removed DeepSeek ToolSearch opt-out from context-cost.md

Two clauses still claimed prefix-caching models such as DeepSeek are
automatically opted out of the ToolSearch + ToolCall bridge: the rewritten
bullet on when the bridge ends up unregistered, and the trap-list entry that
ends with "DeepSeek models opt out of ToolSearch automatically for this
reason". That opt-out was removed in QwenLM#10410: packages/cli/src/config/config.ts
gates purely on settings.tools?.toolSearch?.enabled === false and explicitly
keeps the bridge enabled for prefix-cache-sensitive models, and the config
tests assert deepseek models no longer get tool_search/tool_call denied.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmu9wrczi92

* docs(users): say the prefix-cache trade no longer inverts for deferral

`3d302b36` removed the false fact — DeepSeek models are no longer opted out of
ToolSearch automatically, since QwenLM#10410 deleted the model-name regex. The
sentence it left behind still carries the false implication: that for a
prefix-caching model, keeping the declaration list identical beats keeping it
small. Reaching a withheld tool is now prefix-stable, so for the deferral
decision there is nothing left to invert, and a deployment that disabled
ToolSearch by hand to protect a prefix is holding a reason that has expired.

Prefix stability still argues against everything else that rewrites the prefix
mid-session, so the bullet keeps that half rather than disappearing.

Claude-Session: https://claude.ai/code/session_01P31kyHJf9VXo8w6h9uZetg

* docs(verification): correct the v0.24.2 change enumeration and DeepSeek opt-out

The upgrade-risk section certified that v0.24.1...v0.24.2 held only 8
files / 6 PRs and that QwenLM#10410 was the sole default-behaviour change.
Re-derived from the tag range: 15 non-test files under
packages/core/src/{tools,permissions}; three commits flip defaults —
QwenLM#10410 (deferral), QwenLM#12096 (Bash comment permission verdicts, ungated),
QwenLM#12198 (undecided-workspace trust gating) — so the section now lists
all three, gains risk rows for the two ungated flips, scopes the
one-line rollback to deferral only, and notes at the re-baseline table
that QwenLM#12119 changed the /context derivation in the same release.

Also extend the §0.1 status table to §6/§7: §6 row 7's DeepSeek
automatic ToolSearch opt-out was deleted by QwenLM#10410 (the only remaining
kill-switch is the explicit tools.toolSearch.enabled: false at
config.ts:2102-2114), §7 item 3 is closed now that QwenLM#10410 shipped, and
the plan doc's twin DeepSeek claim is corrected in the same pass.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuakc4l1ae

* docs(users): align tools.eager bullets with the bridge's real semantics

Three corrections against the repo's own authoritative text
(settingsSchema tool descriptions, the client.ts incomplete-bridge
warning, tool-registry.ts:1055):

- Removal enumeration: dropping a non-exempt tool takes a whole-tool
  permissions.deny rule, a tools.disabled entry, or --exclude-tools
  (not permissions.deny alone); for the bridge pair,
  tools.toolSearch.enabled: false removes both halves at once. Bullet
  4's incomplete-bridge cause list is widened to match the warning.
- A withheld tool with a missing bridge half is not "at no route at
  all": it is not offered to the model and cannot load through the
  bridge, but stays registered and a direct call by name still goes
  through normal approval.
- Removing a bridge half is not equivalent to giving up the allowlist:
  every ordinary deferred tool is force-declared into every request
  while the withheld tools lose their discovery route.
- The threshold advice is scoped to ordinary deferred tools: the
  preload never reveals tools demoted by tools.eager.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmuakc4l1ae

* docs(verification): correct the v0.24.2 upgrade-risk triage

My first version certified that "only QwenLM#10410 changed default behaviour" in
`v0.24.1...v0.24.2` and put that under a heading saying it is the only thing to
worry about. Review found three counterexamples in the range and I confirmed all
three:

- **QwenLM#12096** (`9036855d`) adds `splitCommandForRules` at four evaluation sites in
  `permission-manager.ts` (+103/−5), gated only on tool name and whether the
  command contains a newline — no setting, no env. A comment-bearing command can
  flip deny→ask, or deny→allow under a broad `Bash(echo *)` rule. Nothing about
  deferral surfaces or undoes that, so the section as written would have sent an
  operator down a toolSearch-only ladder for a permission change.
- **QwenLM#12198** (`c9839a94`) flips `isTrustedFolder()`'s undecided default.
- **QwenLM#12034** (`4f027777`) added `MEMORY_CONTEXT_WARNING_MAX_TOKENS`, itself a
  default, which the sibling plan-doc row already records as live.

The "8 files / 6 PRs" count did not reproduce under any filter either, and the
list of six named two PRs that touch neither path. Re-derived from the tag range
with the filter stated: 53 commits, three touching non-test files under
`packages/core/src/{tools,permissions}/`, and a separate table for "changed a
default", which is the wider set the section actually needed. The claim is
narrowed to what is true — the *token*-relevant default change is QwenLM#10410 — and a
risk row for QwenLM#12096 is added whose detection step is re-testing existing
`Bash(...)` deny rules against comment-bearing commands, with a note that the
one-line rollback cannot touch it.

§8 item 0 gains the instrument caveat: QwenLM#12119 shipped in the same release and
rewrote how `/context detail` derives the built-in-tools row, so a pre-upgrade
and a post-upgrade reading are not the same ruler. Both runs should record the
whole `/context detail` output rather than one category number.

Claude-Session: https://claude.ai/code/session_01P31kyHJf9VXo8w6h9uZetg

* docs: close the five remaining review findings on this PR

R1-5, R1-6, R1-7, R1-8, R1-9. R1-1/R1-2/R1-3/R1-4 and §7 item 3 were handled in
`0ed2941b`, `57b26d13` and `70c9c3f8`.

**R1-5 — the QwenLM#12029 cell over-closed.** QwenLM#10410 changed a *default*; it added no
cap. `client.ts` still sizes the budget as `floor(window × percent / 100)` with
the percentage clamped to 100 — symmetric in percent, not in tokens — and the
preload stays all-or-nothing. Default `0` only means it does not fire by
default, and this repo's own verification brief tells a reader to raise the
threshold when a session will certainly need those tools, which brings the gap
straight back. Recorded as half-closed, with the reason.

**R1-6 — the plan's §10 table contradicted the sibling README it is cross-linked
to.** QwenLM#12142 was listed as awaiting review after shipping in `v0.24.2`
(`36887c49`), and QwenLM#12030's first half as awaiting review after merging
(`fbd6ccc2`) on an issue closed the same day. Both rows corrected, with the
part that is genuinely not done said plainly: the conditional-rule mechanism
landed, and nobody has moved any content onto it.

**R1-7 — the plan's own baseline had no void banner** while the README marked
its equivalent explicitly. Added at the top, naming which sections the numbers
belong to and pointing at §0.1 for the re-baseline. It also corrects §10's "less
than a tenth of that step" against the 4.2k/11.4k split this PR's own README
states, and notes §3's exemption list needs `tool_call`.

**R1-8 — the exempt-floor figure is stale.** 2,163 counted `task_stop` (which is
`shouldDefer=true` and not resident) and omitted `tool_call` (exempt and
resident since QwenLM#10410), while `tool_search` has drifted 375 → 394. Restated at
~2,230 from the reviewer's probe, attributed to them rather than claimed as
mine, with the reader's own `/context detail` named as the authority.

**R1-9 — item 0 marked the wrong row required.** Row 1 is conditional on an
environment the reader may not still have; row 2 is what §0.1 makes mandatory
and what both subtractions need. Row 2 is now the required one, with the derived
~17,300 named as the fallback when row 1 is unobtainable.

Claude-Session: https://claude.ai/code/session_01P31kyHJf9VXo8w6h9uZetg

* docs: cite the tool-search gate by symbol, fix the v0.24.2 commit-count basis

Three accuracy corrections to text this PR itself added, taken from the
approving review's non-blocking nits.

- `config.ts:2102-2114` named auth-type resolution and
  `validateModelProvidersConfig`; the `tools.toolSearch.enabled === false`
  gate is `shouldDisableToolSearch`. Both files now cite the symbol rather
  than a line range, so the citation cannot drift again.
- §0.1 announced a `packages/core/src/{tools,permissions}` counting basis
  and then listed six commits on a topical one. QwenLM#12198 touches no file
  under either directory and QwenLM#12271/QwenLM#12346 only tests, so the announced
  filter yields three. The two bases are now stated separately.
- `70edbf47` has a single parent, so it is a squash commit, not a merge
  commit.

* docs: replace the staleness banners with the corrections they announced

Every stale claim this PR identified is now corrected in place instead of
being voided by a remote banner that the reader has to have read first.

plan doc:
- §3: the exemption list gains `tool_call`, and the "bridge incomplete"
  fallback row states the real behaviour -- `resolveDeferredToolsForReminder`
  reveals ordinary deferred tools but withholds the `tools.eager`-demoted ones
  (still registered, direct call by name still uses normal approval) and warns
  once -- replacing "急,揭示全部". The exemption bullet now separates
  "exempt" from "resident" and lists the real removal levers.
- §6: the whole-section void notice sits under `## 6. 成本模型`, which is
  where the top banner points; the `threshold: 0` subsection notice defers to
  it instead of contradicting its scope. §4's 盈亏线 reference and §8's
  "≤ 2 次" line carry their own markers.
- §7 item 4 and §3's table cite `revealDeferredToolsReferencedInHistory` by
  symbol instead of the drifted `client.ts:1783-1822` range.
- §10: the retracted "不到这一步的十分之一" sentence is rewritten in place to
  the post-QwenLM#10410 attribution (~4.2k from QwenLM#10410, ~11.4k left to the
  allowlist), keeping the 21,461 -> 4,080-5,766 baseline pair.

verification README:
- §2.6: heading, ¥ table, the ≤2 SLO blockquote and the "QwenLM#10410(open)"
  closing line are all marked or corrected in place, so the section no longer
  contradicts §7 item 3's 已收口.
- §0.1: the "still near 21,461" ladder gains the QwenLM#12119 instrument rung;
  `MEMORY_CONTEXT_WARNING_MAX_TOKENS` is described as the warning threshold it
  actually is (`Math.min(ratioTokens, MAX)`, nothing truncates memoryContent)
  rather than a cap on loaded context, matching the plan doc, and the new
  post-upgrade warning gets its own risk-table row; the QwenLM#12198 flip is scoped
  to `security.folderTrust.enabled`, which defaults false.
- §2.1: "exempt" and "resident" are split, so `task_stop` / `mcp__*` /
  `computer_use__*` are no longer described as resident, while `task_stop`
  stays on the exemption list with the reason denying it is unsafe.
- §8 item 0: one attribution rule with explicit precedence -- row3-row2 is the
  reliable one, row2-row1 only when row 1 is a real measurement, and ~17,300
  is a row-2 expectation rather than a row-1 substitute. Item 1's direction is
  corrected: both §2.5 projections are floor + allowlisted schemas with no
  on-demand-pool component, and the floor rose 2,163 -> 2,230, so they read
  about 67 higher on >= 0.24.2, not lower.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmubr7c3ecn

* docs: close out the three deferred findings on the risk table and prefix bullets

context-cost.md: the prefix-cache bullet stated prefix stability for deferral
unconditionally. Carry the exception tool-search.ts:133 documents -- a tool-set
refresh may still re-declare a withheld tool when the live history contains a
direct call to it -- without implying the bridge path itself reveals. (R3-7)

context-token-governance README: the QwenLM#12096 row's detection column audited only
the deny side, which cannot surface the flip's lasting consequence. Rewrite it
to lead with the allow side (re-audit the broad Bash(...) allow rule minted by
an "Always allow" click on a comment-bearing command) and link the in-repo
design doc that records the hazard. (R3-9)

R3-5 is declined on the thread with evidence: no reachable configuration puts a
tools.eager-demoted tool both outside getDeferredToolSummary() and genuinely
unreachable for the session.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmubzrz80d3

* docs: correct the allowlist-reading direction and four stale claims

Applies the doc-accuracy findings two independent verifiers raised at
1b956a8 (wenshao's local real-environment run and the /triage sandbox
round). Every one of them is in text this PR writes; no production code
is touched.

- README §2.5 and §8 item 1 predicted the /context allowlist reading
  would go up by ~67 on >= 0.24.2. Measured in the real TUI it goes down
  681 for the aggressive list (~-0.8k for the conservative one), because
  an allowlist entry admits its whole family (toolMatchesRuleToolName):
  read_file brings zoom_image, run_shell_command brings monitor, and both
  are on-demand tools that v0.24.1's threshold-10 preload declared. The
  +67 floor term stays, now labelled as one of two opposing forces; the
  two sentences asserting the direction are removed. §2.1's correction
  also notes that "本来就不常驻" holds on >= 0.24.2 but not for the
  v0.24.1 reading it corrects.
- README §0.1 MCP row and context-cost.md: the preload runs at session
  start and MCP servers usually connect after it, so a fresh launch
  declares no MCP tools at any threshold (v0.24.1 measured 0/8).
- permission-manager.test.ts: the tool_call comment claimed withholding
  it would leave every demoted tool readable and uncallable. Both bridge
  halves are alwaysLoad=true, so the declaration list does not move
  without this arm; what it protects is the permission-deferred state
  other readers consult (speculation, workflow-authoring skill). The test
  row itself is unchanged.
- QwenLM#12032 rows: 1,104 characters / ~276 tokens is already stale on the
  main this PR merges into (prompts.test.ts there pins 1,421 / ~355), so
  both rows are corrected here instead of deferred.
- Plan doc §3 table: four re-emitted line ranges no longer point at what
  they name (permission-manager.ts:848-866 now lands on
  isExemptFromEagerAllowList itself), so they are cited by symbol, per the
  convention the same table already uses for its other rows.

Co-authored-by: Qwen-Coder <[email protected]>
Patrol-Run: qwen-pr-closeout/jmucrn2bbei

---------

Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
pull Bot pushed a commit to Stars1233/qwen-code that referenced this pull request Sep 23, 2026
…tting (QwenLM#11765)

* fix(core): read a backslash inside single quotes as literal when splitting

A backslash is an ordinary character inside single quotes in bash, so a
command ending in one closes its string and an operator after it separates
two real commands. The compound-command splitter read that backslash as an
escape, consumed the closing quote, and stayed inside the quote to the end
of the input, returning the whole line as a single segment.

Permission rules are matched per segment, so the carrier command's allow
rule then covered whatever followed the operator: with only Bash(echo *)
allowed, `echo 'a\' ; rm -rf x` was auto-approved and the rm ran with no
prompt. An explicit deny rule was lost the same way, because the command it
names never became a segment to match against.

The escape now applies outside quotes and inside double quotes only, which
is what bash does.

* fix(core): keep the escape inside bash's ANSI-C quotes when splitting

The previous commit suppressed the backslash escape for the whole of an open
single-quoted string. That is right for a plain string, where a backslash is
an ordinary character, but wrong for bash's ANSI-C form: inside $'…' the
backslash does escape, including before the closing quote, so $'a\'' is a
complete word and an operator after it still separates two commands. Reading
it as plain swallowed the real closing quote and glued the line back into one
segment — the same fail-open the previous commit closed, reached from the
other side, and a regression against the merge base rather than a gap it
merely left open.

The scanner now records which form opened the string and suppresses the
escape only for the plain one. A `$` opens the ANSI-C form only when it is
itself unquoted, unescaped and not already spent as the second half of `$$`,
so `\$'a\'`, `$$'a\'` and `"$"'a\'` stay plain, as bash reads them; treating
those as ANSI-C would reintroduce the original bypass through a different
door.

Eight cases pin the behaviour across the three directions, each verified
against `bash -x` as the oracle and each red under the corresponding mutant.

* test(core): cover mixed quote forms on one line when splitting commands

The differential run against bash turned up shapes the existing cases do not
reach: a plain and an ANSI-C string side by side in either order, `$$$'…'`
where the PID expansion is followed by a real ANSI-C string, and `$'\''`
whose entire body is an escaped quote. bash runs each of these as two
commands; three of the four go red when the escape is suppressed for every
single-quoted string, so they pin the same guard the ANSI-C cases do from a
different direction.

No production change — the split already handled all four.

* fix(core): elide a line continuation before deciding the quote form

A `$` separated from its opening quote by a backslash-newline lost its
pending state, so `$\<newline>'a\''` was read as a plain `'…'` string.
The plain reading then swallowed the real closing quote and held the
scanner inside the string to the end of input, returning one segment for
a line bash runs as two commands — the bypass the surrounding exception
exists to close, re-entered through a continuation.

bash removes a backslash-newline before it decides what `$'` means, so
treat the pair as elided and carry the pending `$` across it. The newline
is consumed with the backslash because `'\n'` is itself a command
separator here; leaving it to the `escaped` flag would split every
`echo a\<newline>b` continuation instead.

Also cover the separators and shapes the earlier cases did not reach: a
bare `&` and a newline as the separator for both quote forms, the line
continuation that must not split, and the re-opened quote
`echo 'a\'' ; rm x'`, which bash runs as a single command and which the
corrected scanner keeps in one segment.

* docs(core): correct why the continuation branch and ANSI-C quoting work

Two comments stated the mechanism wrongly, in the direction that invites
someone to undo the fix.

The line-continuation branch was justified by `'\n'` being a command
separator, as though leaving the pair to the `escaped` flag would split
the line. It would not: the `escaped` check precedes the operator loop,
so that route consumes the newline too, and deleting the branch keeps
`echo a\<newline>b` whole. What the branch is actually for is the
top-of-loop `dollarPending` reset, which the `escaped` route runs through
and which drops the pending `$`.

The ANSI-C comment put the closing quote one position early: `$'a\''`
holds three quotes and closes at the third, the second being the escaped
one that belongs to the string. Read literally, the old wording is the
blanket-escape reading that glues the line back into one segment.

Also pin the even backslash run. The introducer rule turns on the parity
of the run before the `$` — `\$'` is a plain string, `\\$'` is ANSI-C —
and only the odd side was covered, so a refactor reading an even run as
escaping the `$` would reopen the bypass with the suite green.

* fix(core): keep every boundary either backslash reading finds when splitting

The plain-quote rule reads a backslash inside '…' as literal, which is
right for a real quoted string but wrong in the regions this scanner does
not model: a # comment, a backtick body and a heredoc body, where bash does
not read the quote characters as quotes at all. There `'a\''` closed at its
second quote, re-opened a string at the third and swallowed the next
command into an allow-covered segment, so `echo done # note 'a\''` followed
by a newline and `rm -rf x` was auto-approved where main asked, and an
explicit deny rule was never reached.

Scan the input under both readings and split wherever either finds an
operator, so the plain-quote fix never removes a boundary the scanner found
before it. Pin the three carriers, and put the plain `'c\'` first in the
ANSI-C, even-run and continuation rows so only bash's reading can split
them and the coarse `!inSingle` guard still turns them red.

* refactor(core): condense splitter comments and quote-handling tests

Fold the per-case splitter tests and the per-carrier PermissionManager
tests into it.each tables and shorten the explanatory comments. Coverage
is unchanged: every guard mutation still turns at least one case red.

* perf(core): skip the second scan when the command has no backslash

The two backslash readings can only differ at a backslash, so a command
without one needs a single scan. Restores main's timing for backslash-free
input; commands containing a backslash still pay ~2x.

Also pin the terminator the merge loop reports, and restore the ANSI-C
rows that keep the coarse `!inSingle` guard above single digits (11 red).

* fix(core): trim only the whitespace bash's lexer discards when splitting

String.trim() also strips \r, \v, \f and \u00a0, which bash treats as
ordinary word characters, so trimming a segment deleted a redirection
target such as `echo x >\u00a0` and the virtual write op vanished from a
verdict that was a deny. Use the same BASH_WORD_SEPARATORS pair QwenLM#11865
adds, dropping a \r only as the first half of a CRLF.

Also pin the boundary sort, which no case covered, and move the
terminator case under describe('splitCompoundCommandSegments').

* test(core): pin the one-character reach of a pending `$`

A `$` opens ANSI-C only when the quote follows it directly. Without the
per-character reset, `echo $x'a\' ; touch /tmp/x` collapsed into one segment
and the suite stayed green. Also point the stale "last row" comment at the row
it describes.

* test(core): pin the false deny this split declares as its tradeoff

bash runs one command in `git commit -m 'x' # saved to 'C:\'⏎rm draft`, but
the pre-fix reading splits inside the comment and a `Bash(rm *)` deny refuses
it. Pin that, the `monitor` spelling, and the single-line spelling QwenLM#12096's
comment fast path allows again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants