Repository navigation
fix(core): handle simple Bash comments in permission rules - #12096
Conversation
E2E Test ReportResult: PASS Baseline reproduction on Current branch decision matrix:
Verification completed on macOS arm64 with Node.js v22.22.0:
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. |
|
Closeout pass applied the two bounded review suggestions at
Verification: the full focused file passes (451 tests); the eight comment cases pass; replacing the boundary with The current Ubuntu unit failure is not caused by this PR. Main CI at the exact base |
doudouOUC
left a comment
There was a problem hiding this comment.
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.
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
`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
|
@qwen-code /triage |
Co-authored-by: Qwen-Coder <[email protected]> Patrol-Run: qwen-pr-conflict/jmu6rvkq439
|
Applied the two remaining bounded verification suggestions in
Verification: focused ESLint passed, Prettier passed, and 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.
|
@doudouOUC re-requesting your review — your Head moved
All 19 threads are resolved. |
|
@qwen-code /triage |
yiliang114
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
extractCommandRulesderives, the intended deny-to-ask demotion reaches a confirmation dialog that still segments with the legacy comment-blind splitter. It therefore offersBash(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:
shellProcessorgates 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
left a comment
There was a problem hiding this comment.
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.
|
Correction to our review 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 1. The attribution-trailer half — our claim was wrong; we grepped for the wrong symbolWe wrote that The trim is present. Both rewriters trim through
Each takes
2. The reconstruction-caller half — the argument holds; all three callers walked
One non-blocking observation, offered only because it is what sent us down this path: the design doc's "one physical line" bullet says Scope, as of the state read immediately before postingThis 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 |
chiga0
left a comment
There was a problem hiding this comment.
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:
toolName === 'run_shell_command'— excludesmonitor(scanned string isnormalizeMonitorCommand().safetyCommand, not the real invocation)getShellConfiguration().shell === 'bash'— cmd/PowerShell use the old splitter- No
\nor\r— multi-line stays conservative - The scan reaches
#without hitting\,$, backtick, or any operator — any of those exits tosplitCompoundCommandimmediately command[i-1]is ASCII space or tab — mid-wordecho a#bstays conservative!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.
…-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`.
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.
…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
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
…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
…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
…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]>
…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.
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/xcould be denied byBash(rm *)even though Bash only executes the allowedecho. 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 andBash(rm *)as denied, then evaluateecho 'a' # comment ; rm -rf /tmp/x. Confirm that Bash returnsallow, while the same text undercmdand PowerShell remainsdeny. Also confirm that multiline input, substitution syntax, and a real operator before the comment remain on the conservativedenypath.Evidence (Before & After)
The complete permissions test directory passes: 11 files, 933 tests.
Tested on
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路径。前后证据
完整 permissions 测试目录通过:11 个文件,933 个测试。
测试平台
环境
Node.js v22.22.0。已使用仓库权威的 npm 安装路径验证全量 build、全量 typecheck、聚焦 lint/format 检查以及完整 permissions 测试目录。
风险与范围
关联 Issue
Refs #11815。以更小、fail-closed 的实现替代已关闭的 #11821。