Merge remote-tracking branch 'origin/main' into ci/autofix-takeover-label

This commit is contained in:
wenshao 2026-07-19 00:17:34 +08:00
commit 427301c641
46 changed files with 15304 additions and 930 deletions

View file

@ -331,6 +331,22 @@ jobs:
fi
echo "Settings schema is up-to-date"
- name: 'Generate VS Code companion notices'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: 'npm run generate:notices --workspace=qwen-code-vscode-ide-companion'
- name: 'Check VS Code companion notices are up-to-date'
if: "${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' }}"
run: |-
if [[ -n $(git status --porcelain packages/vscode-ide-companion/NOTICES.txt) ]]; then
echo "Error: NOTICES.txt is out of date."
echo "Please run: npm run generate:notices --workspace=qwen-code-vscode-ide-companion"
echo "Then commit the updated file."
git diff --stat packages/vscode-ide-companion/NOTICES.txt
exit 1
fi
echo "NOTICES.txt is up-to-date"
# Keep this Linux-only PR gate explicit. macOS/Windows merge-queue jobs run
# npm run test:ci only, so they intentionally do not repeat this
# platform-independent bundle closure check.

View file

@ -12,6 +12,145 @@ are listed; nightly and preview pre-releases are intentionally omitted.
> [GitHub Releases](https://github.com/QwenLM/qwen-code/releases). Do not edit it
> by hand — run `npm run changelog` to regenerate.
## [0.19.12](https://github.com/QwenLM/qwen-code/releases/tag/v0.19.12) - 2026-07-18
### Highlights
_See the complete change list below._
### Breaking Changes
No known breaking changes.
### Complete Change List
#### Features
- feat(daemon): Trace cold first-session startup ([#6907](https://github.com/QwenLM/qwen-code/pull/6907)) by @doudouOUC
- feat(web-shell): add archived session export ([#6910](https://github.com/QwenLM/qwen-code/pull/6910)) by @doudouOUC
- feat(serve): add workspace session-info aggregate endpoint ([#7077](https://github.com/QwenLM/qwen-code/pull/7077)) by @samuelhsin
- feat(web-shell): add skill management pages ([#7018](https://github.com/QwenLM/qwen-code/pull/7018)) by @ytahdn
- feat(cli): mouse text selection and copy in VP mode ([#6937](https://github.com/QwenLM/qwen-code/pull/6937)) by @chiga0
- feat(channels): stamp daemon sourceId with channel instance name on created sessions ([#7078](https://github.com/QwenLM/qwen-code/pull/7078)) by @xurik
- feat(channels): confirm natural memory mutations ([#7066](https://github.com/QwenLM/qwen-code/pull/7066)) by @qqqys
- feat(channels): save explicit multi-fact memory safely ([#7092](https://github.com/QwenLM/qwen-code/pull/7092)) by @qqqys
- feat(serve): Complete legacy session workspace telemetry ([#7003](https://github.com/QwenLM/qwen-code/pull/7003)) by @doudouOUC
- feat(core): Enable artifact defaults and write reminders ([#7068](https://github.com/QwenLM/qwen-code/pull/7068)) by @chiga0
- feat(web-shell): suggest sending new-topic drafts in a new session ([#7098](https://github.com/QwenLM/qwen-code/pull/7098)) by @carffuca
- feat(tools): add formatDisplayPath() and wire grep/glob/ripGrep descriptions ([#7050](https://github.com/QwenLM/qwen-code/pull/7050)) by @zjunothing
- feat(daemon): propagate prompt IDs to turn events ([#7082](https://github.com/QwenLM/qwen-code/pull/7082)) by @makwingchi
- feat(web-shell): paginate restored session history ([#7064](https://github.com/QwenLM/qwen-code/pull/7064)) by @ytahdn
- feat(web-shell): toggle the session sidebar with Cmd+B / Ctrl+B ([#7135](https://github.com/QwenLM/qwen-code/pull/7135)) by @zjunothing
- feat(web-shell): add directory autocomplete to the Add Workspace dialog ([#7125](https://github.com/QwenLM/qwen-code/pull/7125)) by @zjunothing
- feat(cli): support same-turn message steering ([#7090](https://github.com/QwenLM/qwen-code/pull/7090)) by @LaZzyMan
- feat: support full-turn multimodal routing for image prompts ([#7045](https://github.com/QwenLM/qwen-code/pull/7045)) by @yiliang114
- feat(agents): support per-model sub-agent concurrency limits ([#6984](https://github.com/QwenLM/qwen-code/pull/6984)) by @qwen-code-dev-bot
- feat(cli): add daemon Todo stop guard ([#6945](https://github.com/QwenLM/qwen-code/pull/6945)) by @doudouOUC
- feat(cli): show active path in compact tool summaries ([#7043](https://github.com/QwenLM/qwen-code/pull/7043)) by @zjunothing
- feat(vscode): route logs to the Qwen Code Companion output channel ([#7121](https://github.com/QwenLM/qwen-code/pull/7121)) by @yiliang114
- feat(core): overhaul web_fetch — content fidelity, binary handling, security, and resilience ([#7146](https://github.com/QwenLM/qwen-code/pull/7146)) by @tanzhenxin
- feat(channels): expose workspace-scoped observed contacts ([#7109](https://github.com/QwenLM/qwen-code/pull/7109)) by @BenGuanRan
- feat(web-shell): persist the split view across refresh, per tab ([#7136](https://github.com/QwenLM/qwen-code/pull/7136)) by @wenshao
- feat(web-shell): add a workspace Goals page, and stop losing /goal on daemon resume ([#6561](https://github.com/QwenLM/qwen-code/pull/6561)) by @wenshao
- feat(core): improve subagent delegation defaults and guardrails ([#7048](https://github.com/QwenLM/qwen-code/pull/7048)) by @DragonnZhang
- feat(review): build every Step 5 auditor of a round in one call, identity first ([#7150](https://github.com/QwenLM/qwen-code/pull/7150)) by @wenshao
- feat(channels): observe group names from inbound messages ([#7155](https://github.com/QwenLM/qwen-code/pull/7155)) by @BenGuanRan
- feat(web-shell): git status chip, visual working-tree diff, and sidebar git status ([#7054](https://github.com/QwenLM/qwen-code/pull/7054)) by @wenshao
- feat(channels): recall relevant memory per message ([#7157](https://github.com/QwenLM/qwen-code/pull/7157)) by @qwen-code-dev-bot
#### Bug Fixes
- fix(serve): Harden multi-workspace ownership guards ([#7005](https://github.com/QwenLM/qwen-code/pull/7005)) by @doudouOUC
- fix: bound usage-only streams and abort on quit ([#7038](https://github.com/QwenLM/qwen-code/pull/7038)) by @yiliang114
- fix(acp): disambiguate model routes ([#7028](https://github.com/QwenLM/qwen-code/pull/7028)) by @yiliang114
- fix(web-shell): batch transcript dispatch to avoid tab-return freeze ([#7012](https://github.com/QwenLM/qwen-code/pull/7012)) by @wenshao
- fix(core): Require explicit approval to exit Plan mode ([#6967](https://github.com/QwenLM/qwen-code/pull/6967)) by @doudouOUC
- fix(web-shell): use formatSettingCategory for fallback UI category ([#7055](https://github.com/QwenLM/qwen-code/pull/7055)) by @wenshao
- fix(integration): add missing session_info to E2E capabilities baseline ([#7091](https://github.com/QwenLM/qwen-code/pull/7091)) by @qwen-code-dev-bot
- fix(tui): pin MaxSizedBox rows and gate the pending backstop for show-more diff (#6809) ([#6957](https://github.com/QwenLM/qwen-code/pull/6957)) by @azurecgx
- fix(integration): harden flaky interactive read-then-write test ([#7105](https://github.com/QwenLM/qwen-code/pull/7105)) by @wenshao
- fix(core): retry empty tool-result continuations ([#7039](https://github.com/QwenLM/qwen-code/pull/7039)) by @yiliang114
- fix(core): align planning and response guidance ([#7085](https://github.com/QwenLM/qwen-code/pull/7085)) by @DragonnZhang
- fix(web-shell): render built-in tag icons ([#7024](https://github.com/QwenLM/qwen-code/pull/7024)) by @callmeYe
- fix(core): force tool_choice in generateJson to prevent auto-mode classifier deadlock ([#6929](https://github.com/QwenLM/qwen-code/pull/6929)) by @qwen-code-dev-bot
- fix(ci): notify silent triage re-runs ([#7079](https://github.com/QwenLM/qwen-code/pull/7079)) by @yiliang114
- fix(cli): pop the kitty keyboard protocol after leaving the alternate screen ([#7115](https://github.com/QwenLM/qwen-code/pull/7115)) by @zjunothing
- fix(web-shell): optionally restart SSE after prompt admission ([#7080](https://github.com/QwenLM/qwen-code/pull/7080)) by @ytahdn
- fix(core): accept subagents created after startup instead of rejecting on stale cache ([#7112](https://github.com/QwenLM/qwen-code/pull/7112)) by @zjunothing
- fix(cli): keep streaming code blocks intact when split across commits ([#7020](https://github.com/QwenLM/qwen-code/pull/7020)) by @MikeWang0316tw
- fix(i18n): correct "extenison" typo in extension enable/disable scope help ([#7057](https://github.com/QwenLM/qwen-code/pull/7057)) by @chinesepowered
- fix(core): make the per-turn tool-call cap adaptive ([#7052](https://github.com/QwenLM/qwen-code/pull/7052)) by @wenshao
- fix(vscode): preserve Electron Node mode for ACP launch ([#7106](https://github.com/QwenLM/qwen-code/pull/7106)) by @yiliang114
- fix(core): preserve complete skill descriptions ([#7032](https://github.com/QwenLM/qwen-code/pull/7032)) by @callmeYe
- fix(core): persist resolved subagent model in metadata ([#7104](https://github.com/QwenLM/qwen-code/pull/7104)) by @ARE404
- fix(web-shell): stop stacking duplicate copies when restoring prompt text ([#7134](https://github.com/QwenLM/qwen-code/pull/7134)) by @zjunothing
- fix(core): retry malformed repeated thinking tags ([#7100](https://github.com/QwenLM/qwen-code/pull/7100)) by @yiliang114
- fix(cli): correct misspelled handleUpdateRecieved -> handleUpdateReceived ([#7132](https://github.com/QwenLM/qwen-code/pull/7132)) by @chinesepowered
- fix(cli): hide sticky task panel when agent is idle ([#7062](https://github.com/QwenLM/qwen-code/pull/7062)) by @qwen-code-dev-bot
- fix(cli): keep the model override when a background notification drains ([#7119](https://github.com/QwenLM/qwen-code/pull/7119)) by @zjunothing
- fix(core): align prompt tool examples with schemas ([#7088](https://github.com/QwenLM/qwen-code/pull/7088)) by @DragonnZhang
- fix(cli): require a second Ctrl+C within 1s before a real SIGINT exits the TUI ([#7129](https://github.com/QwenLM/qwen-code/pull/7129)) by @zjunothing
- fix: keep quit and stream logging reliable ([#7124](https://github.com/QwenLM/qwen-code/pull/7124)) by @yiliang114
- fix(ui): keep SkillReviewDialog right border inside the dialog container ([#7047](https://github.com/QwenLM/qwen-code/pull/7047)) by @zjunothing
- fix(core): preserve existing work in system prompt ([#7087](https://github.com/QwenLM/qwen-code/pull/7087)) by @DragonnZhang
- fix(mcp): terminate descendants after discovery timeout ([#6926](https://github.com/QwenLM/qwen-code/pull/6926)) by @morluto
- fix(review): report what the transcripts prove; build the roster in one call ([#7033](https://github.com/QwenLM/qwen-code/pull/7033)) by @wenshao
- fix(cli): establish extension store generation baseline on first read ([#7072](https://github.com/QwenLM/qwen-code/pull/7072)) by @qwen-code-dev-bot
- fix(core): remove ask_user_question from the Explore agent's toolset ([#7133](https://github.com/QwenLM/qwen-code/pull/7133)) by @zjunothing
- fix(web-shell): scope advanced table overlays ([#7097](https://github.com/QwenLM/qwen-code/pull/7097)) by @ytahdn
- fix(core): correct "supercedes" typo in plan-mode system prompt ([#7058](https://github.com/QwenLM/qwen-code/pull/7058)) by @chinesepowered
- fix(ask-user-question): accept long headers and size chips to the container width ([#7063](https://github.com/QwenLM/qwen-code/pull/7063)) by @tanzhenxin
- fix(core): canonicalize restrictive permission paths ([#6923](https://github.com/QwenLM/qwen-code/pull/6923)) by @morluto
- fix(core): align system prompt with interaction mode ([#7089](https://github.com/QwenLM/qwen-code/pull/7089)) by @DragonnZhang
- fix(memory): resolve root symlinks in isAllowedMemoryPath before creation ([#6842](https://github.com/QwenLM/qwen-code/pull/6842)) by @wenshao
- fix(core): add kimi-k3 token limits (1M context, 128K output) ([#7144](https://github.com/QwenLM/qwen-code/pull/7144)) by @tanzhenxin
- fix(integration): use lenient assertion and harden poll in interactive file-system test ([#7113](https://github.com/QwenLM/qwen-code/pull/7113)) by @qwen-code-dev-bot
- fix(core): resolve a parameter expansion in command position to its command root ([#7143](https://github.com/QwenLM/qwen-code/pull/7143)) by @wenshao
- fix(cli): make auto output language follow user input ([#6953](https://github.com/QwenLM/qwen-code/pull/6953)) by @han-dreamer
- fix(core): respect enableManagedAutoMemory in memory availability ([#6941](https://github.com/QwenLM/qwen-code/pull/6941)) by @han-dreamer
- fix(cli): tighten VP-mode controls footprint and fix shell tool indicator overlap ([#6931](https://github.com/QwenLM/qwen-code/pull/6931)) by @chiga0
- fix(acp): resolve textual @ image paths ([#7123](https://github.com/QwenLM/qwen-code/pull/7123)) by @yiliang114
- fix: correct typos in comments, a tool description, and docs ([#7131](https://github.com/QwenLM/qwen-code/pull/7131)) by @chinesepowered
- fix: harden desktop MCP permission-request lifecycle so pending prompts never hang or leak ([#7013](https://github.com/QwenLM/qwen-code/pull/7013)) by @mvanhorn
- fix(cli): correct misspelled migratedInMemorScopes -> migratedInMemoryScopes ([#7140](https://github.com/QwenLM/qwen-code/pull/7140)) by @chinesepowered
- fix(web-shell): recover new-session decisions wrapped in prose or fences ([#7122](https://github.com/QwenLM/qwen-code/pull/7122)) by @wenshao
- fix(web-shell): make approval and question overlays keyboard accessible ([#7074](https://github.com/QwenLM/qwen-code/pull/7074)) by @wenshao
- fix(cli): restore cancelled prompt after streamed output ([#7149](https://github.com/QwenLM/qwen-code/pull/7149)) by @barry166
- fix(channels): scope pairing and allowlist state by workspace ([#7065](https://github.com/QwenLM/qwen-code/pull/7065)) by @zjunothing
#### Performance
- feat(daemon): Profile ACP channel initialization ([#7145](https://github.com/QwenLM/qwen-code/pull/7145)) by @doudouOUC
#### Documentation
- docs(serve): Close multi-workspace hardening gaps ([#7019](https://github.com/QwenLM/qwen-code/pull/7019)) by @doudouOUC
- docs(cua-driver): fix broken relative-coordinate design link ([#7130](https://github.com/QwenLM/qwen-code/pull/7130)) by @chinesepowered
- docs(autofix): make bot PR comments bilingual with collapsed Chinese ([#7137](https://github.com/QwenLM/qwen-code/pull/7137)) by @wenshao
#### Internal Changes
- refactor(web-shell): drop redundant primary-workspace label ([#7035](https://github.com/QwenLM/qwen-code/pull/7035)) by @wenshao
- test(web-shell): make visual-preview captures deterministic + add workspace-sidebar scenario ([#7041](https://github.com/QwenLM/qwen-code/pull/7041)) by @wenshao
- ci(autofix): recover from generated-artifact CI gates and stop silent stalls ([#6998](https://github.com/QwenLM/qwen-code/pull/6998)) by @wenshao
- ci(autofix): run the schema gate from a trusted staged copy, not the branch tree ([#7076](https://github.com/QwenLM/qwen-code/pull/7076)) by @wenshao
- test(web-shell): align workspace sidebar visual smoke ([#7107](https://github.com/QwenLM/qwen-code/pull/7107)) by @yiliang114
- test(cli): isolate sandbox-relaunch tests from ambient QWEN_SANDBOX_IMAGE ([#7093](https://github.com/QwenLM/qwen-code/pull/7093)) by @wenshao
- ci(autofix): treat Suggestion-level review findings as actionable per AGENTS.md ([#7094](https://github.com/QwenLM/qwen-code/pull/7094)) by @wenshao
- test(serve): cover session-info regressions ([#7083](https://github.com/QwenLM/qwen-code/pull/7083)) by @samuelhsin
- test(cli): actually exercise the paste-workaround path in useKeypress ([#7141](https://github.com/QwenLM/qwen-code/pull/7141)) by @chinesepowered
- ci(autofix): fan out review targets and stop route-scan starvation ([#7127](https://github.com/QwenLM/qwen-code/pull/7127)) by @wenshao
- ci(shepherd): add Fleet Shepherd — automated unblocking of the bot-PR fleet ([#7142](https://github.com/QwenLM/qwen-code/pull/7142)) by @wenshao
- refactor(core): Classify shell safety as read-only, write, or unknown ([#7053](https://github.com/QwenLM/qwen-code/pull/7053)) by @doudouOUC
### New Contributors
- @azurecgx made their first contribution in [#6957](https://github.com/QwenLM/qwen-code/pull/6957)
- @makwingchi made their first contribution in [#7082](https://github.com/QwenLM/qwen-code/pull/7082)
- @ARE404 made their first contribution in [#7104](https://github.com/QwenLM/qwen-code/pull/7104)
**Full Changelog**: https://github.com/QwenLM/qwen-code/compare/v0.19.11...v0.19.12
## [0.19.11](https://github.com/QwenLM/qwen-code/releases/tag/v0.19.11) - 2026-07-16
### Highlights

View file

@ -0,0 +1,47 @@
# Shell safety classification
## Context and scope
Issue [#6949](https://github.com/QwenLM/qwen-code/issues/6949) requires Plan mode to distinguish commands that are proven read-only from commands whose behavior cannot be established statically. A boolean cannot retain that distinction, so this change introduces a three-state fact layer in `shellAstParser.ts` without changing permission routing.
This change does not modify routing or call-site logic in Shell, Monitor, PermissionManager, speculation, memory-scoped agents, ACP, Plan-mode prompts, or Plan exit behavior. Existing boolean consumers can become more conservative where the classifier is hardened. A follow-up change can route `unknown` commands to one-off approval using the new fact without changing this classifier.
## Contract
`classifyShellCommandSafety(command)` is an internal module API with these results:
| Result | Meaning |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `read-only` | Every executable path is proven by the current rules not to modify persistent or external state. |
| `write` | The syntax contains positive evidence of a file, Git, process, or other state mutation. The command need not ultimately succeed. |
| `unknown` | The command cannot be proved safe or mutating by the supported static rules. |
For a valid AST, results combine in the order `write > unknown > read-only`. A tree containing `ERROR` is classified as `unknown` before evaluating partial syntax. Command and process substitutions impose an `unknown` floor while their executable contents are scanned, so a nested known writer promotes the result to `write`. Redirect analysis owns substitutions inside redirect nodes while command and statement evaluators exclude those nodes from their substitution scans, preventing repeated traversal of nested substitutions. Control flow uses the same unknown floor and scans possible branches. A function definition is not execution and therefore remains `unknown` without classifying its body as an executed write.
A standalone pure assignment and `cd` preserve the existing compatibility behavior. An assignment that prefixes a command or shares a compound sequence with another statement imposes an `unknown` floor because variables such as `LD_PRELOAD`, `PATH`, `PAGER`, or tool-specific configuration can change behavior; explicit write evidence still wins. Subshells and command groups aggregate their executed contents. The API analyzes only the supplied source string; it does not unwrap `sudo` or interpreters, resolve PATH or aliases, or load shell configuration.
## Parser failure and compatibility API
The private classifier may throw while loading or running tree-sitter. The public three-state API maps those failures to `unknown` and never substitutes regex certainty. A parser that throws while parsing is discarded and rebuilt from the already loaded Bash language, because the failed instance may remain poisoned; this does not reload the runtime or language. The existing `isShellCommandReadOnlyAST()` compatibility API returns `true` only for `read-only`, but retains the existing regex fallback when tree-sitter cannot load or throws at runtime. A syntactically invalid tree is a normal `unknown` result, not a parser failure, so it never enters that fallback. Every successfully returned tree is released once in a `finally` block.
This asymmetry is intentional: new consumers need an honest uncertainty fact, while existing boolean consumers keep their parser-availability behavior until they migrate explicitly.
## Supported evidence
The classifier recognizes a bounded, case-sensitive set of direct filesystem writers, process signaling commands, output redirections, Git mutation families, and explicit write modes in `find`, `sed`, `awk`, `sort`, `tree`, `uniq`, `tee`, and `dd`. Sed and AWK use shared linear scanners that distinguish inline programs from option values and file arguments, so escaped, malformed, or highly repetitive input cannot trigger regex backtracking or fabricate write evidence from a filename. Git output files for `diff`, `log`, and `show` are writes. Stateful `printf -v` forms are unknown. Explicit Git helpers and signature verification, including pager/config environment options, diff/text-conversion helpers, grep's external pager, and signature placeholders, are unknown; unsupported Git global options and subcommand help paths also fail closed because help may launch an external viewer. Dynamic execution, external scripts, ambiguous output targets, interpreters and wrappers, `sort --compress-program`, ripgrep preprocessors, hostname helpers, and archive search (`--pre`, `--hostname-bin`, `--search-zip`, and `-z`), and ordinary pager commands remain `unknown`. Option terminators and the supported options' value arity are interpreted so a filename or message literally named `--help` is not mistaken for a help invocation. Differently-cased command names, unlisted package managers, services, and custom executables also remain `unknown`; the classifier is not a sandbox.
The deprecated synchronous checker mirrors every newly rejected pattern needed by synchronous scheduling. It preserves parameter expansions with sentinels instead of allowing `shell-quote` to erase them, rejects malformed trailing pipelines and assignment-bearing compounds, and evaluates wrappers from the original command. It intentionally remains boolean and is more conservative than the AST classifier: `printf`, option-heavy `sort`, `tree`, `uniq`, `rg`, and `ripgrep` commands, and Git branch forms beyond the simplest listing modes, run sequentially.
## Consumers and migration boundary
Current boolean consumers are Shell, Monitor, PermissionManager, the speculation gate, and memory-scoped agent configuration; their call sites do not change in this refactor. The synchronous checker is also used by the core tool scheduler and the legacy shell permission utility. The scheduler now passes the original command to the checker so wrappers remain unknown instead of being unwrapped into an apparently read-only command. `extractCommandRules()` remains independent of safety classification.
The follow-up `fix(core): Route unknown Plan shell commands to one-off approval` should consume `classifyShellCommandSafety()` only at the Plan permission boundary. It must separately define approval provenance, lifetime, ACP behavior, and the interaction with Plan exit; those policies do not belong in the fact layer.
## Claude Code reference
Claude Code's Bash analysis is useful as evidence for two design principles: parsing uncertainty must be represented explicitly, and permission decisions must fail closed when parsing is unavailable or too complex. Its larger Bash parser and policy engine are not copied because Qwen Code needs only a small classifier at the current boundary.
## Verification
Unit coverage uses table-driven matrices for all three states, compound precedence, substitutions, syntax errors, parser initialization and runtime failures, bounded behavior for adversarial nested and escaped input, and compatibility monotonicity. The synchronous checker and scheduler tests prevent newly known unsafe commands from joining concurrent Shell batches.

View file

@ -173,7 +173,7 @@ When `senderPolicy` is set to `"pairing"`, unknown senders go through an approva
qwen channel pairing approve my-channel VEQDDWXJ
```
Once approved, the user's ID is saved to `~/.qwen/channels/<name>-allowlist.json` and all future messages go through normally.
Once approved, the user's ID is saved to the channel's workspace-scoped allowlist (`~/.qwen/channels/<workspace-scope>/<name>-allowlist.json`) and all future messages go through normally. Pairing state is scoped per workspace, so two workspaces using the same channel name keep separate approvals.
### Pairing CLI Commands
@ -185,13 +185,15 @@ qwen channel pairing list my-channel
qwen channel pairing approve my-channel <CODE>
```
Run these from the channel's workspace directory (or pass `--cwd <dir>`) — pairing state is stored per workspace.
### Pairing Rules
- Codes are 8 characters, uppercase, using an unambiguous alphabet (no `0`/`O`/`1`/`I`)
- Codes expire after 1 hour
- Maximum 3 pending requests per channel at a time — additional requests are ignored until one expires or is approved
- Users listed in `allowedUsers` in `settings.json` always skip pairing
- Approved users are stored in `~/.qwen/channels/<name>-allowlist.json` — treat this file as sensitive
- Approved users are stored per workspace in `~/.qwen/channels/<workspace-scope>/<name>-allowlist.json` — treat this file as sensitive
## Group Chats

42
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.19.11",
"version": "0.19.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.19.11",
"version": "0.19.12",
"hasInstallScript": true,
"workspaces": [
"packages/*",
@ -27867,7 +27867,7 @@
},
"packages/acp-bridge": {
"name": "@qwen-code/acp-bridge",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@qwen-code/qwen-code-core": "file:../core"
@ -27882,7 +27882,7 @@
},
"packages/audio-capture": {
"name": "@qwen-code/audio-capture",
"version": "0.19.11",
"version": "0.19.12",
"hasInstallScript": true,
"dependencies": {
"node-gyp-build": "^4.8.4"
@ -27899,7 +27899,7 @@
},
"packages/channels/base": {
"name": "@qwen-code/channel-base",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1"
},
@ -27909,7 +27909,7 @@
},
"packages/channels/dingtalk": {
"name": "@qwen-code/channel-dingtalk",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"dingtalk-stream-sdk-nodejs": "^2.0.4"
@ -27920,7 +27920,7 @@
},
"packages/channels/feishu": {
"name": "@qwen-code/channel-feishu",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@larksuiteoapi/node-sdk": "^1.45.0",
"@qwen-code/channel-base": "file:../base"
@ -27931,7 +27931,7 @@
},
"packages/channels/plugin-example": {
"name": "@qwen-code/channel-plugin-example",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"ws": "^8.18.0"
@ -27945,7 +27945,7 @@
},
"packages/channels/qqbot": {
"name": "@qwen-code/channel-qqbot",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"@tencent-connect/qqbot-connector": "^1.1.0",
@ -27957,7 +27957,7 @@
},
"packages/channels/telegram": {
"name": "@qwen-code/channel-telegram",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"grammy": "^1.41.1",
@ -27970,7 +27970,7 @@
},
"packages/channels/wecom": {
"name": "@qwen-code/channel-wecom",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@qwen-code/channel-base": "file:../base",
"@wecom/aibot-node-sdk": "^1.0.7"
@ -27981,7 +27981,7 @@
},
"packages/channels/weixin": {
"name": "@qwen-code/channel-weixin",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@qwen-code/channel-base": "file:../base"
},
@ -27991,7 +27991,7 @@
},
"packages/chrome-extension": {
"name": "@qwen-code/chrome-bridge",
"version": "0.19.11",
"version": "0.19.12",
"license": "Apache-2.0",
"devDependencies": {
"@types/chrome": "^0.1.32",
@ -28004,7 +28004,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
"@google/genai": "2.6.0",
@ -28034,7 +28034,7 @@
"fzf": "^0.5.2",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
"ink": "^7.0.3",
"ink": "7.0.3",
"ink-gradient": "^3.0.0",
"ink-link": "^4.1.0",
"ink-spinner": "^5.0.0",
@ -28254,7 +28254,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.19.11",
"version": "0.19.12",
"hasInstallScript": true,
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
@ -28428,7 +28428,7 @@
},
"packages/mobile-mcp": {
"name": "@qwen-code/mobile-mcp",
"version": "0.19.11",
"version": "0.19.12",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "1.26.0",
@ -31131,7 +31131,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.19.11",
"version": "0.19.12",
"license": "LICENSE",
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",
@ -31200,7 +31200,7 @@
},
"packages/web-shell": {
"name": "@qwen-code/web-shell",
"version": "0.19.11",
"version": "0.19.12",
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.7.0",
@ -31931,7 +31931,7 @@
},
"packages/web-templates": {
"name": "@qwen-code/web-templates",
"version": "0.19.11",
"version": "0.19.12",
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@ -32438,7 +32438,7 @@
},
"packages/webui": {
"name": "@qwen-code/webui",
"version": "0.19.11",
"version": "0.19.12",
"license": "MIT",
"dependencies": {
"@qwen-code/sdk": "file:../sdk-typescript",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.19.11",
"version": "0.19.12",
"engines": {
"node": ">=22.0.0"
},
@ -22,7 +22,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.19.11"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.19.12"
},
"scripts": {
"start": "node scripts/start.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/acp-bridge",
"version": "0.19.11",
"version": "0.19.12",
"description": "Shared ACP bridge core (createHttpAcpBridge factory, BridgeClient, defaultSpawnChannelFactory, BridgeFileSystem injection seam) + primitives (EventBus, AcpChannel, in-memory channel, PermissionMediator interface) used by qwen serve, channels, IDE, TUI, and remote-control adapters.",
"repository": {
"type": "git",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/audio-capture",
"version": "0.19.11",
"version": "0.19.12",
"description": "Native microphone capture backend for Qwen Code voice input",
"type": "module",
"main": "dist/index.js",

View file

@ -298,10 +298,10 @@ When `requireMention` is `true` (default), group messages are only processed if
### PairingStore
```typescript
constructor(channelName: string)
constructor(channelName: string, workspaceCwd?: string)
```
Persists pairing state to `~/.qwen/channels/{channelName}-pairing.json` and `{channelName}-allowlist.json`.
Persists pairing state to `{channelName}-pairing.json` and `{channelName}-allowlist.json`. With `workspaceCwd` (what `ChannelBase` passes — the channel's `cwd`), the files live under the workspace-scoped directory `~/.qwen/channels/<workspace-scope>/` so two workspaces reusing the same channel name never share pairing requests or allowlist entries. Without it, the legacy global `~/.qwen/channels/` layout is used. The first time a given (workspace, channel) pair is constructed, existing legacy global files are copied in once (grandfathering) so already-approved senders stay approved; a per-channel `<channel>.migrated` sentinel in the scope directory marks that decision, after which legacy files are never consulted again for that channel. Channel names are URI-encoded in file names, so a name containing path separators cannot escape the scope directory. To revoke a sender, remove their entry from the scoped allowlist (and from the legacy global file, while it exists) — deleting the scoped file does not revoke, and recreating the scope directory from scratch re-imports the legacy baseline.
| Method | Description |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- |

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-base",
"version": "0.19.11",
"version": "0.19.12",
"description": "Base channel infrastructure for Qwen Code",
"type": "module",
"main": "dist/index.js",
@ -15,7 +15,9 @@
"dist"
],
"scripts": {
"build": "tsc --build"
"build": "tsc --build",
"test": "vitest run",
"test:ci": "vitest run"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1"

View file

@ -501,8 +501,12 @@ export abstract class ChannelBase {
this.groupGate = new GroupGate(config.groupPolicy, config.groups);
this.dmGate = new DmGate(config.dmPolicy);
// Scoped by the channel's workspace cwd: two workspaces reusing the same
// channel name must not share pairing/allowlist state (#7017).
const pairingStore =
config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined;
config.senderPolicy === 'pairing'
? new PairingStore(name, config.cwd)
: undefined;
this.gate = new SenderGate(
config.senderPolicy,
config.allowedUsers,

View file

@ -0,0 +1,373 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { PairingStore } from './PairingStore.js';
import { getWorkspaceScopeDirName } from './paths.js';
describe('PairingStore workspace scoping (#7017)', () => {
let qwenHome: string;
let workspaceA: string;
let workspaceB: string;
let prevQwenHome: string | undefined;
beforeEach(() => {
qwenHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pairing-home-'));
workspaceA = fs.mkdtempSync(path.join(os.tmpdir(), 'pairing-ws-a-'));
workspaceB = fs.mkdtempSync(path.join(os.tmpdir(), 'pairing-ws-b-'));
prevQwenHome = process.env['QWEN_HOME'];
process.env['QWEN_HOME'] = qwenHome;
});
afterEach(() => {
if (prevQwenHome === undefined) {
delete process.env['QWEN_HOME'];
} else {
process.env['QWEN_HOME'] = prevQwenHome;
}
for (const dir of [qwenHome, workspaceA, workspaceB]) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
const channelsRoot = () => path.join(qwenHome, 'channels');
it('isolates pending requests between workspaces using the same channel name', () => {
const storeA = new PairingStore('support-bot', workspaceA);
const storeB = new PairingStore('support-bot', workspaceB);
const code = storeA.createRequest('sender-1', 'Sender One');
expect(code).toBeTruthy();
expect(storeA.listPending()).toHaveLength(1);
expect(storeB.listPending()).toHaveLength(0);
});
it('isolates allowlists: approval in one workspace does not approve in another', () => {
const storeA = new PairingStore('support-bot', workspaceA);
const storeB = new PairingStore('support-bot', workspaceB);
const code = storeA.createRequest('sender-1', 'Sender One')!;
const approved = storeA.approve(code);
expect(approved?.senderId).toBe('sender-1');
expect(storeA.isApproved('sender-1')).toBe(true);
expect(storeB.isApproved('sender-1')).toBe(false);
});
it('keeps path-traversal channel names inside the workspace scope', () => {
// Channel names come from unrestricted config keys. Without encoding,
// `../support` climbs out of the scope directory and both workspaces
// share one file at the channels root — silently undoing the isolation.
const storeA = new PairingStore('../support', workspaceA);
const code = storeA.createRequest('mallory', 'Mallory')!;
storeA.approve(code);
const storeB = new PairingStore('../support', workspaceB);
expect(storeB.isApproved('mallory')).toBe(false);
// Nothing may leak to the channels root.
const rootFiles = fs
.readdirSync(channelsRoot())
.filter((f) => f.endsWith('.json'));
expect(rootFiles).toEqual([]);
});
it('maps equivalent spellings of the same workspace to the same store', () => {
const store = new PairingStore('support-bot', workspaceA);
const sameViaRelativeHop = new PairingStore(
'support-bot',
path.join(workspaceA, 'sub', '..'),
);
const code = store.createRequest('sender-1', 'Sender One');
expect(code).toBeTruthy();
expect(sameViaRelativeHop.listPending()).toHaveLength(1);
});
it('writes scoped files under channels/<workspace-scope>/, not the global dir', () => {
const store = new PairingStore('support-bot', workspaceA);
store.createRequest('sender-1', 'Sender One');
const scopeDir = path.join(
channelsRoot(),
getWorkspaceScopeDirName(workspaceA),
);
expect(fs.existsSync(path.join(scopeDir, 'support-bot-pairing.json'))).toBe(
true,
);
expect(
fs.existsSync(path.join(channelsRoot(), 'support-bot-pairing.json')),
).toBe(false);
});
it('keeps the legacy global layout when no workspace is given', () => {
const store = new PairingStore('support-bot');
const code = store.createRequest('sender-1', 'Sender One')!;
store.approve(code);
expect(
fs.existsSync(path.join(channelsRoot(), 'support-bot-allowlist.json')),
).toBe(true);
});
describe('legacy migration (grandfathering)', () => {
const seedLegacy = () => {
fs.mkdirSync(channelsRoot(), { recursive: true });
fs.writeFileSync(
path.join(channelsRoot(), 'support-bot-allowlist.json'),
JSON.stringify(['legacy-sender']),
);
fs.writeFileSync(
path.join(channelsRoot(), 'support-bot-pairing.json'),
JSON.stringify([
{
senderId: 'pending-sender',
senderName: 'Pending',
code: 'ABCDEFGH',
createdAt: Date.now(),
},
]),
);
};
it('copies legacy global state into the scoped store once', () => {
seedLegacy();
const store = new PairingStore('support-bot', workspaceA);
expect(store.isApproved('legacy-sender')).toBe(true);
expect(store.listPending().map((r) => r.senderId)).toEqual([
'pending-sender',
]);
});
it('lets every workspace grandfather the same legacy baseline (copy, not move)', () => {
seedLegacy();
const storeA = new PairingStore('support-bot', workspaceA);
const storeB = new PairingStore('support-bot', workspaceB);
expect(storeA.isApproved('legacy-sender')).toBe(true);
expect(storeB.isApproved('legacy-sender')).toBe(true);
expect(
fs.existsSync(path.join(channelsRoot(), 'support-bot-allowlist.json')),
).toBe(true);
});
it('diverges after migration: post-migration approvals stay per-workspace', () => {
seedLegacy();
const storeA = new PairingStore('support-bot', workspaceA);
const storeB = new PairingStore('support-bot', workspaceB);
const code = storeA.createRequest('new-sender', 'New Sender')!;
storeA.approve(code);
expect(storeA.isApproved('new-sender')).toBe(true);
expect(storeB.isApproved('new-sender')).toBe(false);
// The legacy global file is left untouched by scoped writes.
const legacy = JSON.parse(
fs.readFileSync(
path.join(channelsRoot(), 'support-bot-allowlist.json'),
'utf-8',
),
) as string[];
expect(legacy).toEqual(['legacy-sender']);
});
it('does not resurrect senders revoked by deleting the scoped allowlist file', () => {
seedLegacy();
const store = new PairingStore('support-bot', workspaceA);
expect(store.isApproved('legacy-sender')).toBe(true);
// Operator "revokes" by deleting the scoped allowlist file. The scope
// directory itself remains, which marks the migration as done — the
// legacy allowlist must not be copied back in on the next start.
const scopedDir = path.join(
channelsRoot(),
getWorkspaceScopeDirName(workspaceA),
);
fs.rmSync(path.join(scopedDir, 'support-bot-allowlist.json'));
const reopened = new PairingStore('support-bot', workspaceA);
expect(reopened.isApproved('legacy-sender')).toBe(false);
});
it('does not absorb a legacy file that appears after the scope is in use', () => {
// Scope comes into existence with only a pending file in the legacy
// layout — the allowlist shows up later (e.g. written by an older
// version still running). An in-use scope must not import it.
fs.mkdirSync(channelsRoot(), { recursive: true });
fs.writeFileSync(
path.join(channelsRoot(), 'support-bot-pairing.json'),
JSON.stringify([
{
senderId: 'pending-sender',
senderName: 'Pending',
code: 'ABCDEFGH',
createdAt: Date.now(),
},
]),
);
const store = new PairingStore('support-bot', workspaceA);
expect(store.listPending().map((r) => r.senderId)).toEqual([
'pending-sender',
]);
fs.writeFileSync(
path.join(channelsRoot(), 'support-bot-allowlist.json'),
JSON.stringify(['late-legacy-sender']),
);
const reopened = new PairingStore('support-bot', workspaceA);
expect(reopened.isApproved('late-legacy-sender')).toBe(false);
});
it('closes the migration gate even when no legacy files existed at first startup', () => {
// Rolling upgrade: this workspace first runs on new code before any
// legacy state exists; an older version writes the global files later.
// The first construction must still mark the migration as done so the
// late legacy allowlist is not absorbed afterwards.
const first = new PairingStore('support-bot', workspaceA);
expect(first.isApproved('legacy-sender')).toBe(false);
seedLegacy();
const reopened = new PairingStore('support-bot', workspaceA);
expect(reopened.isApproved('legacy-sender')).toBe(false);
expect(reopened.listPending()).toEqual([]);
});
it('starts empty and does not throw when a legacy file is unreadable', () => {
// A directory masquerading as the legacy allowlist file makes
// copyFileSync throw — the constructor must stay best-effort.
fs.mkdirSync(path.join(channelsRoot(), 'support-bot-allowlist.json'), {
recursive: true,
});
const store = new PairingStore('support-bot', workspaceA);
expect(store.isApproved('anyone')).toBe(false);
const code = store.createRequest('new-sender', 'New')!;
expect(typeof code).toBe('string');
});
it('migrates every channel of a workspace, not only the first one constructed', () => {
// One process starts several channels in turn (channel start supports
// this); a directory-level gate would let only the first migrate.
fs.mkdirSync(channelsRoot(), { recursive: true });
fs.writeFileSync(
path.join(channelsRoot(), 'chan-a-allowlist.json'),
JSON.stringify(['sender-a']),
);
fs.writeFileSync(
path.join(channelsRoot(), 'chan-b-allowlist.json'),
JSON.stringify(['sender-b']),
);
const storeA = new PairingStore('chan-a', workspaceA);
const storeB = new PairingStore('chan-b', workspaceA);
expect(storeA.isApproved('sender-a')).toBe(true);
expect(storeB.isApproved('sender-b')).toBe(true);
});
it('migrates a channel whose legacy file appears after another channel initialized the scope', () => {
// chan-a runs first with no legacy state; chan-b's legacy allowlist
// exists when chan-b starts later in the same (now existing) scope dir.
const storeA = new PairingStore('chan-a', workspaceA);
expect(storeA.isApproved('anyone')).toBe(false);
fs.mkdirSync(channelsRoot(), { recursive: true });
fs.writeFileSync(
path.join(channelsRoot(), 'chan-b-allowlist.json'),
JSON.stringify(['sender-b']),
);
const storeB = new PairingStore('chan-b', workspaceA);
expect(storeB.isApproved('sender-b')).toBe(true);
});
it('still migrates the allowlist when the legacy pairing file is unreadable', () => {
fs.mkdirSync(channelsRoot(), { recursive: true });
// Directory masquerading as the pairing file makes its copy throw.
fs.mkdirSync(path.join(channelsRoot(), 'support-bot-pairing.json'), {
recursive: true,
});
fs.writeFileSync(
path.join(channelsRoot(), 'support-bot-allowlist.json'),
JSON.stringify(['legacy-sender']),
);
const store = new PairingStore('support-bot', workspaceA);
expect(store.isApproved('legacy-sender')).toBe(true);
});
it('migrates legacy files written under a raw channel name that encodes differently', () => {
// Pre-scoping code wrote legacy files under the RAW name; the encoded
// name is only for scoped destinations. A name with a space must still
// find its legacy state.
fs.mkdirSync(channelsRoot(), { recursive: true });
fs.writeFileSync(
path.join(channelsRoot(), 'my channel-allowlist.json'),
JSON.stringify(['spaced-sender']),
);
const store = new PairingStore('my channel', workspaceA);
expect(store.isApproved('spaced-sender')).toBe(true);
});
it('retries a partially failed migration instead of locking in incomplete state', () => {
// First run: pairing file copies, allowlist copy fails (directory
// masquerading as the file). The sentinel must NOT be written, so a
// later construction — after the operator fixes the file — completes
// the migration instead of silently dropping approved senders.
fs.mkdirSync(channelsRoot(), { recursive: true });
fs.writeFileSync(
path.join(channelsRoot(), 'support-bot-pairing.json'),
JSON.stringify([
{
senderId: 'pending-sender',
senderName: 'Pending',
code: 'ABCDEFGH',
createdAt: Date.now(),
},
]),
);
const badAllowlist = path.join(
channelsRoot(),
'support-bot-allowlist.json',
);
fs.mkdirSync(badAllowlist, { recursive: true });
const first = new PairingStore('support-bot', workspaceA);
expect(first.listPending().map((r) => r.senderId)).toEqual([
'pending-sender',
]);
expect(first.isApproved('legacy-sender')).toBe(false);
// Operator repairs the legacy allowlist; the next construction picks
// it up because the gate never closed.
fs.rmdirSync(badAllowlist);
fs.writeFileSync(badAllowlist, JSON.stringify(['legacy-sender']));
const second = new PairingStore('support-bot', workspaceA);
expect(second.isApproved('legacy-sender')).toBe(true);
});
it('never overwrites existing scoped state with legacy content', () => {
const store = new PairingStore('support-bot', workspaceA);
const code = store.createRequest('scoped-sender', 'Scoped')!;
store.approve(code);
seedLegacy();
const reopened = new PairingStore('support-bot', workspaceA);
expect(reopened.isApproved('scoped-sender')).toBe(true);
expect(reopened.isApproved('legacy-sender')).toBe(false);
});
});
});
describe('getWorkspaceScopeDirName', () => {
it('is stable for a given path and unique across paths', () => {
const a = getWorkspaceScopeDirName('/projects/app');
expect(getWorkspaceScopeDirName('/projects/app')).toBe(a);
expect(getWorkspaceScopeDirName('/other/app')).not.toBe(a);
});
it('keeps a recognizable basename and sanitizes unsafe characters', () => {
const scope = getWorkspaceScopeDirName('/projects/my app!');
expect(scope.startsWith('my_app_-')).toBe(true);
expect(scope).toMatch(/^[a-zA-Z0-9._-]+$/);
});
});

View file

@ -1,7 +1,7 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { getGlobalQwenDir } from './paths.js';
import { getGlobalQwenDir, getWorkspaceScopeDirName } from './paths.js';
// Alphabet without ambiguous chars: 0/O, 1/I
const SAFE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
@ -20,11 +20,130 @@ export class PairingStore {
private dir: string;
private pendingPath: string;
private allowlistPath: string;
private migratedSentinelPath: string;
constructor(channelName: string) {
this.dir = path.join(getGlobalQwenDir(), 'channels');
this.pendingPath = path.join(this.dir, `${channelName}-pairing.json`);
this.allowlistPath = path.join(this.dir, `${channelName}-allowlist.json`);
/**
* @param channelName Channel name the state is keyed by.
* @param workspaceCwd Workspace working directory to scope the state to.
* When provided, files live under
* `<qwen-home>/channels/<workspace-scope>/` so two workspaces using the
* same channel name never share pairing requests or allowlist entries
* (see #7017 sharing them is an authorization-boundary violation in
* multi-workspace daemon deployments). Omitting it preserves the legacy
* global layout (`<qwen-home>/channels/`).
*/
constructor(channelName: string, workspaceCwd?: string) {
const channelsRoot = path.join(getGlobalQwenDir(), 'channels');
this.dir = workspaceCwd
? path.join(channelsRoot, getWorkspaceScopeDirName(workspaceCwd))
: channelsRoot;
// Channel names come from user configuration keys and are not otherwise
// restricted; encode them so a name like `../support` cannot climb out
// of the scope directory and land both workspaces on one shared file —
// that would silently undo the workspace isolation this store exists
// for. Mirrors the GroupHistoryStore file-name encoding. Common names
// (letters, digits, `-`, `_`, `.`) encode to themselves, so existing
// layouts are unaffected.
const safeChannelName = encodeURIComponent(channelName);
this.pendingPath = path.join(this.dir, `${safeChannelName}-pairing.json`);
this.allowlistPath = path.join(
this.dir,
`${safeChannelName}-allowlist.json`,
);
this.migratedSentinelPath = path.join(
this.dir,
`${safeChannelName}.migrated`,
);
if (workspaceCwd) {
this.migrateLegacyState(channelsRoot, channelName);
}
}
/**
* One-time grandfathering of pre-scoping state: the first time this
* (workspace, channel) pair is constructed, copy the legacy GLOBAL files in
* so senders that were already approved stay approved after upgrading.
*
* Gated by a per-channel sentinel file inside the scope directory NOT by
* the directory itself: one workspace can start several channels in turn,
* and a directory-level gate would let only the first channel ever migrate.
* The sentinel is written even when there was nothing to copy, so a legacy
* file written later (e.g. by an older version still running concurrently)
* is never absorbed into a scope that already went through this decision.
*
* Each file is copied independently and best-effort (an unreadable pairing
* file must not block the allowlist, and vice versa), via a
* uniquely-named temp file + atomic rename so a crash mid-copy cannot
* leave a truncated scoped file behind the closed gate. A file the scoped
* store already has is never overwritten.
*
* Copy, not move: another workspace upgrading later must be able to
* grandfather the same baseline, and an older qwen version running
* concurrently still reads the global files.
*
* Revocation therefore means REMOVING ENTRIES from the scoped allowlist
* (and from the legacy global file, while it exists) not deleting files.
*/
private migrateLegacyState(channelsRoot: string, channelName: string): void {
try {
if (fs.existsSync(this.migratedSentinelPath)) {
return;
}
// Legacy files were written by pre-scoping code under the RAW channel
// name; the encoded name is only used for the scoped destinations. The
// containment check keeps a traversal-style raw name (e.g. `../x`)
// from reading files outside the channels root.
const legacyPairs: Array<[string, string]> = [
[
path.join(channelsRoot, `${channelName}-pairing.json`),
this.pendingPath,
],
[
path.join(channelsRoot, `${channelName}-allowlist.json`),
this.allowlistPath,
],
];
this.ensureDir();
let allSucceeded = true;
for (const [legacyPath, scopedPath] of legacyPairs) {
try {
if (path.dirname(path.resolve(legacyPath)) !== channelsRoot) {
continue;
}
if (fs.existsSync(scopedPath) || !fs.existsSync(legacyPath)) {
continue;
}
const tmpPath = `${scopedPath}.${process.pid}.migrating`;
fs.copyFileSync(legacyPath, tmpPath);
fs.renameSync(tmpPath, scopedPath);
} catch (err) {
// Best-effort per file: an unreadable legacy file must not block
// the other file or prevent the channel from starting. Leave the
// sentinel unwritten so the next construction retries this file.
allSucceeded = false;
process.stderr.write(
`[PairingStore] legacy migration of ${path.basename(legacyPath)} ` +
`failed for channel "${channelName}": ` +
`${(err as Error)?.message}; will retry on next start\n`,
);
}
}
// The sentinel closes the gate permanently, so it is only written once
// every present legacy file has been copied (or was already there): a
// partial failure (ENOSPC, transient I/O) must not lock in incomplete
// state and silently drop previously-approved senders.
if (allSucceeded) {
fs.writeFileSync(this.migratedSentinelPath, '');
}
} catch (err) {
// Best-effort: migration problems must not prevent the channel from
// starting; the scoped store just starts empty and the migration is
// retried on the next construction.
process.stderr.write(
`[PairingStore] legacy migration failed for channel ` +
`"${channelName}": ${(err as Error)?.message}; scoped store starts empty\n`,
);
}
}
isApproved(senderId: string): boolean {

View file

@ -1,7 +1,13 @@
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { getGlobalQwenDir, resolvePath } from './paths.js';
import {
canonicalizeWorkspacePath,
getGlobalQwenDir,
getWorkspaceScopeDirName,
resolvePath,
} from './paths.js';
describe('channels/base paths getGlobalQwenDir', () => {
const originalEnv = process.env['QWEN_HOME'];
@ -68,3 +74,50 @@ describe('channels/base paths resolvePath', () => {
expect(resolvePath('relative/dir')).toBe(path.resolve('relative/dir'));
});
});
describe('canonicalizeWorkspacePath', () => {
// Regression for the #7065 review finding: scope identity must follow the
// repo's workspace-canonicalization contract (realpath after resolve), so
// symlinked spellings of the same directory — e.g. macOS `/tmp/ws` vs
// `/private/tmp/ws` — address the same store from the worker and the CLI.
it('collapses a symlinked spelling to the same scope as the real path', () => {
const real = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'ws-'));
const link = `${real}-link`;
fs.symlinkSync(real, link);
try {
expect(canonicalizeWorkspacePath(link)).toBe(
canonicalizeWorkspacePath(real),
);
expect(getWorkspaceScopeDirName(link)).toBe(
getWorkspaceScopeDirName(real),
);
} finally {
fs.unlinkSync(link);
fs.rmSync(real, { recursive: true, force: true });
}
});
it('collapses trailing-separator and dot-dot spellings of a nonexistent path', () => {
// The realpath step cannot help for paths that do not exist on disk, so
// the resolved fallback itself must canonicalize equivalent spellings.
const missing = path.join(os.tmpdir(), 'qwen-scope-missing-norm');
expect(getWorkspaceScopeDirName(`${missing}${path.sep}`)).toBe(
getWorkspaceScopeDirName(missing),
);
expect(
getWorkspaceScopeDirName(
path.join(missing, '..', 'qwen-scope-missing-norm'),
),
).toBe(getWorkspaceScopeDirName(missing));
});
it('keeps the resolved spelling for a path that does not exist (ENOENT fallback)', () => {
const missing = path.join(os.tmpdir(), 'qwen-scope-missing', 'nested');
expect(canonicalizeWorkspacePath(missing)).toBe(resolvePath(missing));
// The scope name of a nonexistent path is exactly the one computed from
// its resolved spelling — the realpath step degrades to a no-op.
expect(getWorkspaceScopeDirName(missing)).toBe(
getWorkspaceScopeDirName(resolvePath(missing)),
);
});
});

View file

@ -1,3 +1,5 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
@ -21,10 +23,11 @@ export function resolvePath(dir: string): string {
.filter(Boolean);
resolved = path.join(os.homedir(), ...relativeSegments);
}
if (!path.isAbsolute(resolved)) {
resolved = path.resolve(resolved);
}
return resolved;
// Always run through path.resolve: it is a no-op for an already-normal
// absolute path but strips trailing separators and collapses `..`/`.`
// segments, so equivalent spellings of a path that does not exist on disk
// (where the realpath step cannot help) still canonicalize identically.
return path.resolve(resolved);
}
/**
@ -45,3 +48,55 @@ export function getGlobalQwenDir(): string {
? path.join(homeDir, '.qwen')
: path.join(os.tmpdir(), '.qwen');
}
/**
* Canonicalizes a workspace path for identity purposes: tilde-expand and
* resolve, then realpath so symlinked and platform-case-variant spellings of
* the same directory collapse to one identity (e.g. macOS `/tmp/ws` vs
* `/private/tmp/ws`). This locally mirrors the repo's cross-module workspace
* identity contract, `canonicalizeWorkspace` in
* `packages/acp-bridge/src/workspacePaths.ts` channel-base intentionally
* avoids cross-package imports, the same way this file mirrors core's
* `Storage`. A path that does not exist keeps its resolved spelling, matching
* the acp-bridge ENOENT fallback.
*
* Deliberately broader than acp-bridge on OTHER realpath errors
* (EACCES/EIO/ELOOP): acp-bridge propagates those because workspace
* registration must fail loudly, but pairing storage is a best-effort
* subsystem a transient FS error must not prevent the channel from
* starting, so every failure falls back to the resolved spelling.
*/
export function canonicalizeWorkspacePath(workspaceCwd: string): string {
const resolved = resolvePath(workspaceCwd);
try {
return fs.realpathSync.native(resolved);
} catch {
return resolved;
}
}
/**
* Directory name for a workspace-scoped slice of channel state, derived from
* the workspace's working directory.
*
* `<sanitized-basename>-<sha256[:12]>`: the basename keeps the directory
* human-recognizable; the hash of the FULL canonicalized path makes it
* unique, so two workspaces named `app` in different parents never collide.
* The input is canonicalized (resolve + realpath) so `/a/b`, `/a/b/`, `~/…`,
* and symlinked spellings of the same directory all map to the same scope
* keeping the CLI's `--cwd .` and a daemon worker's settings-provided cwd in
* agreement about which store they address.
*/
export function getWorkspaceScopeDirName(workspaceCwd: string): string {
const resolved = canonicalizeWorkspacePath(workspaceCwd);
const hash = crypto
.createHash('sha256')
.update(resolved)
.digest('hex')
.slice(0, 12);
const base = path
.basename(resolved)
.replace(/[^a-zA-Z0-9._-]/g, '_')
.slice(0, 32);
return base ? `${base}-${hash}` : hash;
}

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-dingtalk",
"version": "0.19.11",
"version": "0.19.12",
"description": "DingTalk channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-feishu",
"version": "0.19.11",
"version": "0.19.12",
"description": "Feishu (Lark) channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-plugin-example",
"version": "0.19.11",
"version": "0.19.12",
"private": true,
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-qqbot",
"version": "0.19.11",
"version": "0.19.12",
"description": "QQ Bot (QQ机器人) channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-telegram",
"version": "0.19.11",
"version": "0.19.12",
"description": "Telegram channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-wecom",
"version": "0.19.11",
"version": "0.19.12",
"description": "WeCom channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/channel-weixin",
"version": "0.19.11",
"version": "0.19.12",
"description": "WeChat (Weixin) channel adapter for Qwen Code",
"type": "module",
"main": "dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/chrome-bridge",
"version": "0.19.11",
"version": "0.19.12",
"description": "Chrome extension bridge for Qwen CLI - enables AI-powered browser interactions",
"private": true,
"repository": {

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.19.11",
"version": "0.19.12",
"description": "Qwen Code",
"repository": {
"type": "git",
@ -37,7 +37,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.19.11"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.19.12"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",

View file

@ -0,0 +1,145 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PairingStore } from '@qwen-code/channel-base';
import { pairingListCommand, pairingApproveCommand } from './pairing.js';
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
}));
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
type ListArgs = Parameters<
NonNullable<typeof pairingListCommand.handler>
>[0] extends infer T
? T
: never;
// The CLI must address the same workspace-scoped store the channel worker
// uses (#7017): `--cwd` selects the scope, and two workspaces sharing a
// channel name never see each other's requests through the CLI.
describe('channel pairing CLI (--cwd scoping)', () => {
let qwenHome: string;
let wsA: string;
let wsB: string;
const originalQwenHome = process.env['QWEN_HOME'];
beforeEach(() => {
qwenHome = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-pairing-cli-'));
process.env['QWEN_HOME'] = qwenHome;
wsA = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-a-'));
wsB = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-b-'));
vi.mocked(writeStdoutLine).mockClear();
vi.mocked(writeStderrLine).mockClear();
});
afterEach(() => {
if (originalQwenHome !== undefined) {
process.env['QWEN_HOME'] = originalQwenHome;
} else {
delete process.env['QWEN_HOME'];
}
for (const dir of [qwenHome, wsA, wsB]) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
const stdoutText = () =>
vi
.mocked(writeStdoutLine)
.mock.calls.map((call) => String(call[0]))
.join('\n');
it('lists only the requests of the workspace given via --cwd', () => {
// Seed through the same PairingStore call the channel worker makes.
new PairingStore('support-bot', wsA).createRequest('user-alice', 'Alice');
new PairingStore('support-bot', wsB).createRequest('user-bob', 'Bob');
pairingListCommand.handler!({
name: 'support-bot',
cwd: wsA,
_: [],
$0: '',
} as unknown as ListArgs);
const out = stdoutText();
expect(out).toContain('Alice');
expect(out).not.toContain('Bob');
});
it('prints the workspace-aware hint when the scoped store is empty', () => {
new PairingStore('support-bot', wsB).createRequest('user-bob', 'Bob');
pairingListCommand.handler!({
name: 'support-bot',
cwd: wsA,
_: [],
$0: '',
} as unknown as ListArgs);
expect(stdoutText()).toContain(
'No pending pairing requests in this workspace',
);
});
it('approve acts on the --cwd workspace and leaves the other untouched', () => {
const storeA = new PairingStore('support-bot', wsA);
const code = storeA.createRequest('user-alice', 'Alice')!;
new PairingStore('support-bot', wsB).createRequest('user-bob', 'Bob');
pairingApproveCommand.handler!({
name: 'support-bot',
code,
cwd: wsA,
_: [],
$0: '',
} as unknown as Parameters<
NonNullable<typeof pairingApproveCommand.handler>
>[0]);
expect(new PairingStore('support-bot', wsA).isApproved('user-alice')).toBe(
true,
);
expect(new PairingStore('support-bot', wsB).isApproved('user-alice')).toBe(
false,
);
});
it('approve with a code from another workspace fails with the scoped error', () => {
const codeB = new PairingStore('support-bot', wsB).createRequest(
'user-bob',
'Bob',
)!;
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as unknown as typeof process.exit);
try {
pairingApproveCommand.handler!({
name: 'support-bot',
code: codeB,
cwd: wsA,
_: [],
$0: '',
} as unknown as Parameters<
NonNullable<typeof pairingApproveCommand.handler>
>[0]);
expect(exitSpy).toHaveBeenCalledWith(1);
expect(new PairingStore('support-bot', wsB).isApproved('user-bob')).toBe(
false,
);
} finally {
exitSpy.mockRestore();
}
});
});

View file

@ -2,21 +2,38 @@ import type { CommandModule } from 'yargs';
import { PairingStore } from '@qwen-code/channel-base';
import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js';
export const pairingListCommand: CommandModule<object, { name: string }> = {
// Pairing state is scoped by the channel's workspace (#7017), so the CLI has
// to address the same scope the channel worker uses. Default to the current
// directory — running the command from the workspace selects its store.
const cwdOption = {
type: 'string',
describe:
'Workspace directory the channel runs in (defaults to the current directory)',
default: '.',
} as const;
export const pairingListCommand: CommandModule<
object,
{ name: string; cwd: string }
> = {
command: 'list <name>',
describe: 'List pending pairing requests for a channel',
builder: (yargs) =>
yargs.positional('name', {
type: 'string',
describe: 'Channel name',
demandOption: true,
}),
yargs
.positional('name', {
type: 'string',
describe: 'Channel name',
demandOption: true,
})
.option('cwd', cwdOption),
handler: (argv) => {
const store = new PairingStore(argv.name);
const store = new PairingStore(argv.name, argv.cwd);
const pending = store.listPending();
if (pending.length === 0) {
writeStdoutLine('No pending pairing requests.');
writeStdoutLine(
'No pending pairing requests in this workspace (pass --cwd <dir> if the channel runs elsewhere).',
);
return;
}
@ -32,7 +49,7 @@ export const pairingListCommand: CommandModule<object, { name: string }> = {
export const pairingApproveCommand: CommandModule<
object,
{ name: string; code: string }
{ name: string; code: string; cwd: string }
> = {
command: 'approve <name> <code>',
describe: 'Approve a pending pairing request',
@ -47,16 +64,18 @@ export const pairingApproveCommand: CommandModule<
type: 'string',
describe: 'Pairing code',
demandOption: true,
}),
})
.option('cwd', cwdOption),
handler: (argv) => {
const store = new PairingStore(argv.name);
const store = new PairingStore(argv.name, argv.cwd);
const request = store.approve(argv.code);
if (!request) {
writeStderrLine(
`No pending request found for code "${argv.code.toUpperCase()}". It may have expired.`,
`No pending request found for code "${argv.code.toUpperCase()}" in this workspace. It may have expired, or the channel may run in a different workspace (pass --cwd <dir>).`,
);
process.exit(1);
return; // process.exit is mocked in tests; never fall through
}
writeStdoutLine(

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code-core",
"version": "0.19.11",
"version": "0.19.12",
"description": "Qwen Code Core",
"repository": {
"type": "git",

View file

@ -12082,8 +12082,8 @@ describe('Fire hook functions integration', () => {
onToolCallsUpdate,
);
// "git log" and "ls" are read-only → concurrent
// "npm install" is not read-only → sequential, breaks the batch
// "git log" and "ls" are read-only → concurrent.
// Wrappers, output-writing sort, and npm install are sequential.
const requests = [
{
callId: '1',
@ -12102,6 +12102,20 @@ describe('Fire hook functions integration', () => {
{
callId: '3',
name: 'run_shell_command',
args: { command: "bash -c 'git status'" },
isClientInitiated: false,
prompt_id: 'p1',
},
{
callId: '4',
name: 'run_shell_command',
args: { command: 'sort -o output input' },
isClientInitiated: false,
prompt_id: 'p1',
},
{
callId: '5',
name: 'run_shell_command',
args: { command: 'npm install' },
isClientInitiated: false,
prompt_id: 'p1',
@ -12125,14 +12139,32 @@ describe('Fire hook functions integration', () => {
expect(gitStart).toBeLessThan(firstReadOnlyEnd);
expect(lsStart).toBeLessThan(firstReadOnlyEnd);
// "npm install" should start after both read-only commands complete
// The unknown wrapper should start after both reads complete.
const lastReadOnlyEnd = Math.max(
executionLog.indexOf('shell:end:git log'),
executionLog.indexOf('shell:end:ls'),
);
const wrapperStart = executionLog.indexOf(
"shell:start:bash -c 'git status'",
);
expect(wrapperStart).not.toBe(-1);
expect(wrapperStart).toBeGreaterThan(lastReadOnlyEnd);
// The output-writing sort should not overlap the wrapper batch.
const wrapperEnd = executionLog.indexOf("shell:end:bash -c 'git status'");
const sortStart = executionLog.indexOf(
'shell:start:sort -o output input',
);
expect(wrapperEnd).not.toBe(-1);
expect(sortStart).not.toBe(-1);
expect(sortStart).toBeGreaterThan(wrapperEnd);
// npm install should not overlap the sequential sort batch.
const sortEnd = executionLog.indexOf('shell:end:sort -o output input');
const npmStart = executionLog.indexOf('shell:start:npm install');
expect(sortEnd).not.toBe(-1);
expect(npmStart).not.toBe(-1);
expect(npmStart).toBeGreaterThan(lastReadOnlyEnd);
expect(npmStart).toBeGreaterThan(sortEnd);
});
});
});

View file

@ -66,7 +66,6 @@ import { unescapePath, PATH_ARG_KEYS } from '../utils/paths.js';
import type { MemoryPressureMonitor } from '../services/memoryPressureMonitor.js';
import { CONCURRENCY_SAFE_KINDS, isShellProgressData } from '../tools/tools.js';
import { isShellCommandReadOnly } from '../utils/shellReadOnlyChecker.js';
import { stripShellWrapper } from '../utils/shell-utils.js';
import { parsePositiveIntegerEnv } from '../utils/env.js';
import {
isAlreadyTruncated,
@ -1202,14 +1201,13 @@ export function isToolCallConcurrencySafe(
if (canonicalToolName(name) === ToolNames.AGENT) return true;
// Shell commands: check if the command is read-only (e.g., git log, cat).
// Uses the synchronous regex+shell-quote checker (not the async AST-based
// one) because partitioning runs synchronously. The sync checker covers
// the same command whitelist and is fail-closed — unknown commands remain
// sequential. The AST version is used separately for permission decisions.
// one) because partitioning runs synchronously. It is deliberately more
// conservative than the AST version used for permission decisions.
if (kind === Kind.Execute) {
const command = (args as { command?: string } | undefined)?.command;
if (typeof command !== 'string') return false;
try {
return isShellCommandReadOnly(stripShellWrapper(command));
return isShellCommandReadOnly(command);
} catch {
return false; // fail-closed
}

View file

@ -1693,19 +1693,15 @@ describe('PermissionManager', () => {
).toBe('allow');
});
it('exact Monitor(...) allow rule matches wrapped fallback commands', async () => {
const pm2 = new PermissionManager(
makeConfig({
permissionsAllow: ['Monitor(FOO="bar baz" tail -f /var/log/app.log)'],
}),
);
it('asks by default for wrapped commands with environment prefixes', async () => {
const pm2 = new PermissionManager(makeConfig({}));
pm2.initialize();
expect(
await pm2.evaluate({
toolName: 'monitor',
command: String.raw`FOO="bar baz" /bin/bash --noprofile -c 'tail -f /var/log/app.log &'`,
}),
).toBe('allow');
).toBe('ask');
});
it('Monitor(...) deny rule sees shell wrapper suffix commands', async () => {

View file

@ -487,14 +487,6 @@ export class PermissionManager {
private async resolveDefaultPermission(
command: string,
): Promise<'allow' | 'ask'> {
// AST-based read-only detection. Commands containing command
// substitution are never read-only — `evaluateStatementReadOnly`
// (shellAstParser.ts) guards on `containsCommandSubstitutionAST` at
// the top so every node type inherits the check, including
// `variable_assignment` (`FOO=$(curl ...)`) and `redirected_statement`
// (`cat < $(curl ...)`) where earlier versions had blind spots. See
// PR #4386 round 4. So substitution-bearing commands fall through
// to 'ask' on the line below.
try {
const isReadOnly = await isShellCommandReadOnlyAST(command);
if (isReadOnly) {

View file

@ -86,10 +86,23 @@ describe('shellAstParser lazy runtime', () => {
it('loads web-tree-sitter on first use and deduplicates initialization', async () => {
const runtimeLoaded = vi.fn();
const init = vi.fn(async () => undefined);
let releaseLanguage!: () => void;
const languageReady = new Promise<void>((resolve) => {
releaseLanguage = resolve;
});
const constructed = vi.fn();
const loadLanguage = vi.fn(async () => {
await languageReady;
return {};
});
class ParserMock {
static init = init;
static Language = { load: vi.fn(async () => ({})) };
static Language = { load: loadLanguage };
constructor() {
constructed();
}
setLanguage = vi.fn();
}
@ -101,27 +114,129 @@ describe('shellAstParser lazy runtime', () => {
const parser = await import('./shellAstParser.js');
expect(runtimeLoaded).not.toHaveBeenCalled();
const first = parser.initParser();
await vi.waitFor(() => expect(loadLanguage).toHaveBeenCalledTimes(1));
expect(constructed).not.toHaveBeenCalled();
await Promise.all([parser.initParser(), parser.initParser()]);
let secondResolved = false;
const second = parser.initParser().then(() => {
secondResolved = true;
});
await Promise.resolve();
expect(secondResolved).toBe(false);
releaseLanguage();
await Promise.all([first, second]);
expect(runtimeLoaded).toHaveBeenCalledTimes(1);
expect(init).toHaveBeenCalledTimes(1);
expect(loadLanguage).toHaveBeenCalledTimes(1);
expect(constructed).toHaveBeenCalledTimes(1);
});
it('latches a runtime import failure and falls back without retrying', async () => {
const runtimeLoads = vi.fn();
vi.doMock('web-tree-sitter', () => {
runtimeLoads();
throw new Error('runtime chunk unavailable');
it('latches a language load failure', async () => {
const languageLoads = vi.fn(async () => {
throw new Error('bash language unavailable');
});
class ParserMock {
static init = vi.fn(async () => undefined);
static Language = { load: languageLoads };
setLanguage = vi.fn();
}
vi.doMock('web-tree-sitter', () => ({ default: ParserMock }));
const parser = await import('./shellAstParser.js');
expect(await parser.classifyShellCommandSafety('git status')).toBe(
'unknown',
);
expect(await parser.isShellCommandReadOnlyAST('git status')).toBe(true);
expect(await parser.isShellCommandReadOnlyAST('rm -rf temp')).toBe(false);
await expect(parser.initParser()).rejects.toThrow(
'tree-sitter WASM failed to initialise',
);
expect(runtimeLoads).toHaveBeenCalledTimes(1);
expect(languageLoads).toHaveBeenCalledTimes(1);
});
it('maps parser runtime exceptions without changing the legacy fallback', async () => {
const init = vi.fn(async () => undefined);
const deleteParser = vi.fn();
const parse = vi.fn(() => {
throw new Error('parser runtime failure');
});
const setLanguage = vi
.fn()
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
throw new Error('cannot configure replacement');
});
const constructed = vi.fn();
class ParserMock {
static init = init;
static Language = { load: vi.fn(async () => ({})) };
constructor() {
constructed();
}
delete = deleteParser;
parse = parse;
setLanguage = setLanguage;
}
vi.doMock('web-tree-sitter', () => ({ default: ParserMock }));
const parser = await import('./shellAstParser.js');
expect(await parser.classifyShellCommandSafety('git status')).toBe(
'unknown',
);
expect(await parser.isShellCommandReadOnlyAST('git status')).toBe(true);
await expect(parser.initParser()).rejects.toThrow(
'tree-sitter WASM failed to initialise',
);
expect(init).toHaveBeenCalledTimes(1);
expect(parse).toHaveBeenCalledTimes(2);
expect(constructed).toHaveBeenCalledTimes(3);
expect(setLanguage).toHaveBeenCalledTimes(3);
expect(deleteParser).toHaveBeenCalledTimes(3);
});
it('releases each parsed tree exactly once', async () => {
const deleteTree = vi.fn();
const parse = vi
.fn()
.mockReturnValueOnce({
rootNode: { namedChildCount: 0, hasError: false, namedChildren: [] },
delete: deleteTree,
})
.mockReturnValueOnce({
get rootNode() {
throw new Error('tree evaluation failure');
},
delete: deleteTree,
});
class ParserMock {
static init = vi.fn(async () => undefined);
static Language = { load: vi.fn(async () => ({})) };
parse = parse;
setLanguage = vi.fn();
}
vi.doMock('web-tree-sitter', () => ({ default: ParserMock }));
const parser = await import('./shellAstParser.js');
expect(await parser.classifyShellCommandSafety('git status')).toBe(
'unknown',
);
expect(await parser.classifyShellCommandSafety('git status')).toBe(
'unknown',
);
expect(deleteTree).toHaveBeenCalledTimes(2);
});
it('keeps the packaged runtime deferred and parses from emitted chunks', async () => {
@ -130,7 +245,7 @@ describe('shellAstParser lazy runtime', () => {
const entryPath = path.join(tempDir, 'entry.ts');
writeFileSync(
entryPath,
`export { _resetParser, isShellCommandReadOnlyAST, parseShellCommand } from ${JSON.stringify(
`export { _resetParser, classifyShellCommandSafety, isShellCommandReadOnlyAST, parseShellCommand } from ${JSON.stringify(
path.join(repoRoot, 'packages/core/src/utils/shellAstParser.ts'),
)};\n`,
);
@ -176,6 +291,9 @@ describe('shellAstParser lazy runtime', () => {
}?test=${Date.now()}`
)) as {
_resetParser(): void;
classifyShellCommandSafety(
command: string,
): Promise<'read-only' | 'write' | 'unknown'>;
isShellCommandReadOnlyAST(command: string): Promise<boolean>;
parseShellCommand(command: string): Promise<{
rootNode: { type: string };
@ -196,6 +314,15 @@ describe('shellAstParser lazy runtime', () => {
expect(await packagedParser.isShellCommandReadOnlyAST('rm -rf temp')).toBe(
false,
);
expect(await packagedParser.classifyShellCommandSafety('rm -rf temp')).toBe(
'write',
);
await packagedParser.classifyShellCommandSafety(
'case x in x) rm target;; esac',
);
expect(await packagedParser.classifyShellCommandSafety('git status')).toBe(
'read-only',
);
packagedParser._resetParser();
const recoveredTree = await packagedParser.parseShellCommand('pwd');

View file

@ -0,0 +1,339 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
export type SedScriptSafety = 'read-only' | 'write' | 'unknown';
export type AwkScriptSafety = SedScriptSafety;
const SED_ADDRESS =
/^\s*(?:(?:\d+|\$)(?:\s*,\s*(?:\d+|\$))?|\/(?:\\[\s\S]|[^/\\])*\/)?\s*/;
const SED_ADDRESS_AT =
/\s*(?:(?:\d+|\$)(?:\s*,\s*(?:\d+|\$))?|\/(?:\\[\s\S]|[^/\\])*\/)?\s*/y;
const SAFE_SED_COMMAND = /^[dDgGhHlnNpPqQxz=]$/;
const SAFE_SUBSTITUTION_FLAGS = /^[0-9gIpM]*$/;
const SAFE_SED_OPTION =
/^(?:-[nElrsuz]|--(?:quiet|silent|line-length(?:=.*)?))$/;
const SED_VALUE_OPTIONS = '-f --file -e --expression -l --line-length'.split(
' ',
);
const AWK_STATIC_WRITE =
/^\s*(?:print|printf)\b(?!\s*\()(?:(?:"(?:\\[\s\S]|[^"\\])*")|[^">|])*>>?\s*"[^"]*"\s*$/;
const AWK_UNKNOWN_OPERATION = /(?:system|close)\s*\(|getline\b/;
const AWK_PRINT = /\b(?:print|printf)\b/;
function scanDelimitedSection(
script: string,
start: number,
delimiter: string,
): number {
let escaped = false;
for (let i = start; i < script.length; i++) {
const char = script[i]!;
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === delimiter) {
return i + 1;
}
}
return -1;
}
function classifySingleSedCommandSafety(script: string): SedScriptSafety {
const compatibilityUnknown = /(?:^|[^\\])[ewr]\s/.test(script);
const commandOffset = SED_ADDRESS.exec(script)?.[0].length ?? 0;
if (commandOffset === script.length) return 'read-only';
const command = script[commandOffset]!;
if (command === 'w' || command === 'W')
return script.slice(commandOffset + 1).trim() ? 'write' : 'unknown';
if (/[eErR]/.test(command)) return 'unknown';
if (command === 's') {
const delimiter = script[commandOffset + 1];
if (!delimiter || delimiter === '\\' || /\s/.test(delimiter))
return 'unknown';
const replacementStart = scanDelimitedSection(
script,
commandOffset + 2,
delimiter,
);
if (replacementStart < 0) return 'unknown';
const flagsStart = scanDelimitedSection(
script,
replacementStart,
delimiter,
);
if (flagsStart < 0) return 'unknown';
const flags = script.slice(flagsStart).trim();
if (/[;\n{}]/.test(flags)) return 'unknown';
const writeFlag = flags.indexOf('w');
if (writeFlag >= 0)
return flags.slice(writeFlag + 1).trim() ? 'write' : 'unknown';
if (/[eErRwW]/.test(flags)) return 'unknown';
if (!SAFE_SUBSTITUTION_FLAGS.test(flags)) return 'unknown';
return compatibilityUnknown ? 'unknown' : 'read-only';
}
if (/[;\n{}]/.test(script.slice(commandOffset + 1))) return 'unknown';
if (!SAFE_SED_COMMAND.test(command)) return 'unknown';
return compatibilityUnknown ? 'unknown' : 'read-only';
}
function nextSedSeparator(script: string, start: number): number {
for (let i = start; i < script.length; i++) {
if (script[i] === ';' || script[i] === '\n') return i;
}
return script.length;
}
export function classifySedScriptSafety(script: string): SedScriptSafety {
let result: SedScriptSafety = 'read-only';
let start = 0;
while (start < script.length) {
SED_ADDRESS_AT.lastIndex = start;
const address = SED_ADDRESS_AT.exec(script);
if (!address) return 'unknown';
const commandOffset = SED_ADDRESS_AT.lastIndex;
if (commandOffset === script.length) return result;
const command = script[commandOffset]!;
if (command === 'w' || command === 'W') {
const writer = classifySingleSedCommandSafety(script.slice(start));
return writer === 'write' ? 'write' : 'unknown';
}
if (/[eErR]/.test(command)) return 'unknown';
if (command !== 's' && !SAFE_SED_COMMAND.test(command)) return 'unknown';
let separator: number;
if (command === 's') {
const delimiter = script[commandOffset + 1];
if (!delimiter || delimiter === '\\' || /\s/.test(delimiter))
return 'unknown';
const replacementStart = scanDelimitedSection(
script,
commandOffset + 2,
delimiter,
);
if (replacementStart < 0) return 'unknown';
const flagsStart = scanDelimitedSection(
script,
replacementStart,
delimiter,
);
if (flagsStart < 0) return 'unknown';
separator = nextSedSeparator(script, flagsStart);
} else {
separator = nextSedSeparator(script, commandOffset + 1);
}
const current = classifySingleSedCommandSafety(
script.slice(start, separator),
);
if (current === 'write') return 'write';
if (current === 'unknown') result = 'unknown';
if (separator === script.length) return result;
start = separator + 1;
}
return result;
}
export function classifySedCommandSafety(args: string[]): SedScriptSafety {
const terminator = args.indexOf('--');
const options = args.slice(0, terminator < 0 ? args.length : terminator);
if (
options.some(
(arg, index) =>
/^(?:--help|--version)$/i.test(arg) &&
!SED_VALUE_OPTIONS.includes(options[index - 1]!),
)
)
return 'unknown';
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--') break;
if (SED_VALUE_OPTIONS.includes(arg)) i++;
else if (/^-[nErsuz]*e.+/.test(arg)) continue;
else if (/^(?:-[nErsuz]*[iI]|--in-place(?:=|$))/.test(arg)) return 'write';
}
const scripts: string[] = [];
const scriptArguments = new Set<number>();
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--') {
if (scripts.length === 0) {
const script = args[i + 1];
if (script === undefined || script.startsWith('-')) return 'unknown';
scripts.push(script);
scriptArguments.add(i + 1);
}
break;
}
if (/^(?:-l|--line-length)$/.test(arg) && !args[++i]) return 'unknown';
if (/^(?:-f|--file(?:=|$))/.test(arg)) return 'unknown';
if (arg === '-e' || arg === '--expression') {
const script = args[++i];
if (!script || script.startsWith('-')) return 'unknown';
scripts.push(script);
scriptArguments.add(i);
} else if (/^(?:-e.+|--expression=)/.test(arg)) {
const script = arg.slice(arg.startsWith('-e') ? 2 : 13);
if (script.startsWith('-')) return 'unknown';
scripts.push(script);
scriptArguments.add(i);
} else if (/^--(?!line-length(?:=|$))/.test(arg)) {
return 'unknown';
} else if (arg.startsWith('-') && !SAFE_SED_OPTION.test(arg)) {
return 'unknown';
} else if (!arg.startsWith('-') && scripts.length === 0) {
scripts.push(arg);
scriptArguments.add(i);
}
}
let result: SedScriptSafety = 'read-only';
for (const script of scripts) {
const current = classifySedScriptSafety(script);
if (current === 'write') return 'write';
if (current === 'unknown') result = 'unknown';
}
const remainingArgs = args.filter((_, index) => !scriptArguments.has(index));
return /(?:^|[^\\])[ewr]\s/.test(remainingArgs.join(' '))
? 'unknown'
: result;
}
function splitAwkStatements(script: string): {
statements: string[];
ambiguousSlash: boolean;
unsupportedAt: boolean;
} {
const statements: string[] = [];
let ambiguousSlash = false;
let unsupportedAt = false;
let start = 0;
let escaped = false;
let inString = false;
let inRegex = false;
let previousSignificant = '';
for (let i = 0; i < script.length; i++) {
const char = script[i]!;
if (escaped) {
escaped = false;
continue;
}
if ((inString || inRegex) && char === '\\') {
escaped = true;
continue;
}
if (inString) {
if (char === '"') inString = false;
continue;
}
if (inRegex) {
if (char === '/') inRegex = false;
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (
char === '/' &&
(!previousSignificant || '({[=,:;!~?&|'.includes(previousSignificant))
) {
inRegex = true;
continue;
}
if (char === '/') ambiguousSlash = true;
if (char === '@') unsupportedAt = true;
if (char === '#') {
statements.push(script.slice(start, i));
const newline = script.indexOf('\n', i + 1);
if (newline < 0) return { statements, ambiguousSlash, unsupportedAt };
start = newline + 1;
i = newline;
previousSignificant = '\n';
continue;
}
if (/[;{}\n]/.test(char)) {
statements.push(script.slice(start, i));
start = i + 1;
previousSignificant = char;
continue;
}
if (!/\s/.test(char)) previousSignificant = char;
}
statements.push(script.slice(start));
return { statements, ambiguousSlash, unsupportedAt };
}
export function classifyAwkScriptSafety(script: string): AwkScriptSafety {
const { statements, ambiguousSlash, unsupportedAt } =
splitAwkStatements(script);
if (
!ambiguousSlash &&
statements.some((statement) => AWK_STATIC_WRITE.test(statement))
)
return 'write';
if (unsupportedAt || AWK_UNKNOWN_OPERATION.test(script)) return 'unknown';
return AWK_PRINT.test(script) && /[>|]/.test(script)
? 'unknown'
: 'read-only';
}
export function classifyAwkCommandSafety(args: string[]): AwkScriptSafety {
let programIndex = -1;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--') {
programIndex = i + 1;
break;
}
if (arg === '-F' || arg === '-v') {
if (!args[++i]) return 'unknown';
continue;
}
if (/^-[Fv].+/.test(arg)) continue;
if (arg.startsWith('-')) return 'unknown';
programIndex = i;
break;
}
if (programIndex < 0) return 'read-only';
const program = args[programIndex];
if (program === undefined) return 'unknown';
const result = classifyAwkScriptSafety(program);
if (result !== 'read-only') return result;
return classifyAwkScriptSafety(args.join(' ')) === 'read-only'
? 'read-only'
: 'unknown';
}
export function hasShellBraceExpansion(text: string): boolean {
let braceDepth = 0;
let previousDot = false;
for (const char of text) {
if (char === '{') {
braceDepth++;
previousDot = false;
} else if (char === '}') {
braceDepth = Math.max(0, braceDepth - 1);
previousDot = false;
} else if (braceDepth > 0) {
if (char === ',' || (char === '.' && previousDot)) return true;
previousDot = char === '.';
}
}
return false;
}
export function hasShellPatternExpansion(text: string): boolean {
return /[[*?]/.test(text) || hasShellBraceExpansion(text);
}

View file

@ -6,6 +6,7 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
import {
classifyShellCommandSafety,
initParser,
isShellCommandReadOnlyAST,
extractCommandRules,
@ -100,8 +101,8 @@ describe('isShellCommandReadOnlyAST', () => {
expect(await isShellCommandReadOnlyAST(' ')).toBe(false);
});
it('respects environment prefix followed by allowed command', async () => {
expect(await isShellCommandReadOnlyAST('FOO=bar ls')).toBe(true);
it('rejects environment prefix followed by allowed command', async () => {
expect(await isShellCommandReadOnlyAST('FOO=bar ls')).toBe(false);
});
describe('multi-command security', () => {
@ -200,6 +201,15 @@ describe('isShellCommandReadOnlyAST', () => {
).toBe(false);
});
it('rejects gawk indirect function calls', async () => {
for (const command of [
'awk \'BEGIN { fn = "system"; @fn("touch /tmp/pwned") }\'',
'awk \'BEGIN { fn = "system"; @ fn("touch /tmp/pwned") }\'',
]) {
expect(await isShellCommandReadOnlyAST(command)).toBe(false);
}
});
it('rejects awk with file output redirection', async () => {
expect(
await isShellCommandReadOnlyAST(
@ -248,9 +258,6 @@ describe('isShellCommandReadOnlyAST', () => {
expect(await isShellCommandReadOnlyAST("sed -n '1,5p' file.txt")).toBe(
true,
);
expect(await isShellCommandReadOnlyAST("sed '/pattern/d' file.txt")).toBe(
true,
);
});
it('rejects sed with execute command', async () => {
@ -303,8 +310,8 @@ describe('isShellCommandReadOnlyAST', () => {
expect(await isShellCommandReadOnlyAST('FOO=bar')).toBe(true);
});
it('allows multiple env vars before command', async () => {
expect(await isShellCommandReadOnlyAST('A=1 B=2 ls -la')).toBe(true);
it('rejects multiple env vars before command', async () => {
expect(await isShellCommandReadOnlyAST('A=1 B=2 ls -la')).toBe(false);
});
it('rejects function definitions', async () => {
@ -395,6 +402,451 @@ describe('isShellCommandReadOnlyAST', () => {
});
});
// =========================================================================
// classifyShellCommandSafety
// =========================================================================
describe('classifyShellCommandSafety', () => {
it.each([
'ls -la',
'git status --short',
'ls | cat && pwd',
'FOO=bar',
'cd /tmp',
'(git status)',
'{ ls; pwd; }',
'cat < input.txt',
'cat <<EOF\nhello\nEOF',
'echo 2>&1',
'echo >&-',
'uniq input.txt',
'uniq -- -f',
'git branch --list --color=always topic',
'git diff -o patch',
'git diff -Oorderfile',
'git log -p',
'git show -p HEAD',
'git blame -p file',
'git log -- --output=log.out',
'sort -- -o output',
'tree -- -o output',
'rg -- -z file',
'sort -- -roout input',
'sort -- --output=out',
"sed -- 's/a/b/' input",
"sed 's/a/*/' file",
"sed 's/old/new/' file",
"sed 's/hello/world/' file",
"sed 's/error/warning/g' file",
"sed -n '/needle/p' file",
"sed '/pattern/d' file",
"sed 's/a/woutput/' file",
"sed 's#x#s/a/b/woutput#' file",
"sed 's#x#foo;woutput#' file",
"sed 'p;d' file",
"awk '{ print*2 }' file",
"awk -- '{ print }' input",
"awk -F : '{ print $1 }' input",
'awk \'BEGIN { print "user@example.com" }\'',
"printf '%s' value",
])('classifies %j as read-only', async (command) => {
expect(await classifyShellCommandSafety(command)).toBe('read-only');
});
it.each([
...[
'chgrp',
'chmod',
'chown',
'cp',
'install',
'ln',
'mkdir',
'mkfifo',
'mknod',
'mv',
'rename',
'rm',
'rmdir',
'shred',
'touch',
'truncate',
'unlink',
].map((root) => `${root} target`),
...'add am checkout cherry-pick clean clone commit fetch gc init merge mv pull push rebase reset restore revert rm stash switch'
.split(' ')
.map((subcommand) => `git ${subcommand} target`),
'kill 123',
'kill -- -0',
'kill "$PID"',
'pkill -n 0',
'pkill -n0 process',
'pkill -s0 process',
'pkill -s 0 process',
'pkill -s "$SESSION" process',
'killall -n0 process',
'echo > out',
'> out',
'export FOO=bar > out',
'echo >> out',
'echo >| out',
'echo &> out',
'echo &>> out',
'echo >& out',
'> out echo',
'git commit -m message',
'git commit -m --help',
'git commit -F --help',
'git commit -C --help',
'git commit -c --help',
'git commit --reuse-message --help',
'git commit --fixup --help',
'git commit -m --dry-run',
'git commit -n -m message',
"git commit -m '%G?'",
'git add -- --help',
'git add -- --dry-run',
'touch -- --help',
'git fetch -n origin',
'git branch topic',
'git branch -- topic',
'git branch --color=always color-topic',
'git branch --column column-topic',
'git branch --sort=refname sort-topic',
"git branch --format='%(refname)' format-topic",
'git branch -v verbose-topic',
'git branch --delete topic',
'git branch -uorigin/main topic',
'git branch --format --help -d topic',
'git branch --sort --version --delete topic',
'git remote set-url origin url',
'git remote rm origin',
'git remote prune origin',
'git diff --output=patch',
'git log --output=log.out',
'git show --output=show.out HEAD',
'git log --output --help',
'find . -delete',
'find . -fprint matches',
'find . -fprint --help',
'find . -fls --help',
'find . -fprintf --help format',
'find . -exec rm {} \\;',
'find . -exec echo --help {} \\; -delete',
'find . -exec echo --version {} \\; -delete',
"sed -i 's/a/b/' file",
'sed -f script.sed -i file',
'sed --file=script.sed --in-place=.bak file',
"sed -- 'wout' input",
"sed -- 's/a/b/wout' input",
"sed -I .bak 's/a/b/' file",
"sed -I.bak 's/a/b/' file",
"sed -ni.bak 's/a/b/' file",
"sed -nI.bak 's/a/b/' file",
"sed 's/a/b/w output' file",
"sed -e 's/a/b/' -e 'woutput' file",
"sed 's/a/b/woutput' file",
"sed 'woutput' file",
"sed '1woutput' file",
"sed '/pattern/woutput' file",
"sed 'W output' file",
"sed '1W output' file",
"sed 'p;w output' file",
"sed 's/a/b/;w output' file",
"sed 's/a/;/;w output' file",
"sed -l 80 'w output' file",
"sed --line-length 80 'w output' file",
'awk \'{ print > "output" }\' file',
'awk -- \'BEGIN { print > "out" }\'',
'awk \'BEGIN { print "x" > "out" }\'',
'awk \'BEGIN { printf "%s", "x" > "out" }\'',
'awk \'BEGIN { print a[x] > "out" }\'',
'awk \'{ print>"output" }\' file',
'awk -v mode=1 \'BEGIN { print > "out" }\' input',
'awk \'/pattern/ { print > "out" }\' input',
'sort -o output input',
'sort -o --help input',
'tree -o tree.txt',
'tree -o --help .',
'uniq input output',
'uniq - output',
'uniq -- -f output',
'uniq input -- -f',
'tee output',
'tee -- -output',
'tee -a -- -output',
'dd if=input of=output',
'echo $(rm target)',
'FOO=$(rm target)',
'cat <(rm target)',
'cat < <(rm target)',
'< <(rm target) cat',
'! rm target',
'cat <<EOF\n$(rm target)\nEOF',
'FOO=bar rm target',
'python -c pass; touch target',
'if true; then rm target; fi',
'while false; do rm target; done',
'for item in value; do rm target; done',
])('classifies %j as write', async (command) => {
expect(await classifyShellCommandSafety(command)).toBe('write');
});
it.each([
'',
'python -c pass',
'node -e pass',
'LS -la',
'printf -v PATH /tmp',
'printf -xv PATH /tmp',
'printf "$OPTIONS" value',
'printf -v PATH /tmp; ls',
'sudo ls',
'bash -c ls',
'/bin/rm target',
'rm --help',
'kill -0 123',
'kill -n 0 123',
'kill -n 00 123',
'kill -n0 123',
'kill -s0 123',
'kill --signal 0 123',
'kill -SIG0 123',
'kill -s SIG0 123',
'kill --signal=SIG0 123',
'kill -l',
'kill --list=TERM',
'kill --table',
'kill -V',
'killall -help',
'killall -s0 process',
'killall -sSIG0 process',
'pkill -0 process',
'pkill -SIG0 process',
'pkill --signal 0 process',
'pkill --signal SIG0 process',
'kill -s "$SIGNAL" 123',
'kill -n "$SIGNAL" 123',
'kill --signal="$SIGNAL" 123',
'git clean --dry-run',
'git commit -m -F --help',
'git commit -m -F --dry-run',
'git commit --message --file --help',
'git commit --untracked-files --help',
'git --config-env=diff.external=HELPER diff',
'git --paginate log',
'git -p log',
'git --unknown-option status',
'git -- status',
'git --help commit',
'git status --help',
'git log --help',
'git diff --help',
'git log --show-signature -1',
'git show --format=%G? HEAD',
'GIT_EXTERNAL_DIFF=/tmp/helper git diff',
'FOO=bar GIT_EXTERNAL_DIFF=/tmp/helper git diff',
"GIT_EXTERNAL_DIFF='touch /tmp/pwned'; git diff",
'FOO=bar; ls',
'FOO=bar ls',
'LD_PRELOAD=/tmp/evil.so ls',
'RIPGREP_CONFIG_PATH=/tmp/config rg pattern',
'PAGER=helper git log',
'git add -n target',
'git branch -d topic --help',
'git branch --list -- -d',
'git branch -- --list',
'git branch --sort refname',
"git branch --format '%(refname)'",
'git branch --sort refname topic',
'git branch --format --delete',
'git branch --sort -d',
'git diff --output=',
'git blame --output=blame.out file',
'git diff --ext-diff',
'git show --textconv HEAD:file',
'git grep --open-files-in-pager=less needle',
'git grep -Ovim needle',
'git cat-file --filters HEAD:file',
'git remote prune --dry-run origin',
'git remote prune -n origin',
'git remote show remove',
'git remote get-url prune',
'find . -exec echo {} \\;',
'find . -exec echo -delete \\;',
'find . -fprint --help --help',
'find . -name -delete',
'find . -printf -delete',
'find . -newermt -delete',
'find . -samefile -delete',
'find . -mtime -delete',
'find . -used -delete',
'find . -- -delete',
'find . -exec rm --help \\;',
'sed -f script.sed file',
'sed -fscript.sed file',
"sed --in-pl=.bak 's/a/b/' file",
'sed --f script.sed file',
'sed -newout input',
'sed -nEewout input',
'sed "$SCRIPT" file',
'sed -e "$SCRIPT" file',
'sed s/a/*/ file',
'sed \'s/a/b/\' "$FILE"',
"sed -i 's/a/b/' --help",
'sed -e -i file',
'sed -einstall file',
'sed -neinstall file',
"sed -e '' file",
'sed -f -i file',
'sed -e-i file',
'sed -- -i file',
"sed 's/a/b/e' file",
"sed 's/a/printf hacked > marker/ep' file",
"sed 's#a#printf hacked > marker#pe' file",
"sed 'etouch marker' file",
"sed '1etouch marker' file",
"sed 's/a/b/w' file",
"sed 'w' file",
"sed '1w' file",
"sed 'R input' file",
"sed 's/a/b/' 'w file'",
"sed 's/a/new value/' file",
"sed 's/a/blue sky/' file",
"sed 's/a/car value/' file",
"sed 's/w /x/' file",
"sed '/p;w output/p' file",
"sed 's/a/;w output/' file",
'awk \'{ system("date") }\'',
"awk '{ print > output }' file",
'awk \'BEGIN { print("x")|"cat > output" }\'',
'awk \'BEGIN { print(1 > "0") }\'',
'awk \'BEGIN { printf("%d", 1 > "0") }\'',
'awk \'BEGIN { print "print > " "output" }\'',
'awk \'BEGIN { print (x) > "out" }\'',
'awk \'BEGIN { print +(x > "0") }\'',
'awk \'BEGIN { print a[x > "0"] }\'',
'awk \'BEGIN { # print > "out"\nprint }\'',
'awk \'BEGIN { print /x; print y > "out";/ }\'',
'awk \'BEGIN { print x / 2 > "out" }\'',
"awk '{ print }' 'print > \"out\"'",
'awk -fscript.awk file',
'awk -W exec=script.awk file',
'awk -Wexec=script.awk file',
'awk "$PROGRAM" file',
'awk \'@include "library.awk"\' file',
'awk \'@namespace "safe"\' file',
'awk \'BEGIN { fn = "system"; @fn("touch /tmp/pwned") }\'',
'awk \'BEGIN { fn = "system"; @ fn("touch /tmp/pwned") }\'',
"awk -e '{ print }' file",
"awk --load extension '{ print }' file",
"awk --profile=report '{ print }' file",
'awk {print*2} file',
'awk -v x="$VALUE" \'{ print x }\' file',
'awk \'{ print $NF }\' "$FILE"',
'uniq *',
'uniq "$FILES"',
'sort "$OPTIONS" input',
'sort {-o,output} input',
'sort --out=output input',
'sort -roout input',
'tree -Cofile .',
'sort --co=cat input',
'tree --output=tree.txt',
'find . "$EXPRESSION"',
'rg "$OPTIONS" pattern',
'git status "$OPTIONS"',
'sort --compress-program gzip input',
'sort --output=',
'sort -o output --help',
'rg --pre cat pattern',
'rg --hostname-bin=hostname pattern',
'rg -z pattern archive.gz',
'ripgrep -iz pattern archive.gz',
'rg --search-zip pattern archive.gz',
'less file',
'more file',
'tee',
'dd if=input',
'echo >& "$target"',
'cat <> file',
'echo >',
'FOO=bar > out',
'echo $(git status)',
'FOO=$(git status)',
'cat <(git status)',
'if true; then git status; fi',
'fn() { rm target; }',
])('classifies %j as unknown', async (command) => {
expect(await classifyShellCommandSafety(command)).toBe('unknown');
});
it.each([
'rm target',
'python -c pass',
'echo $(git status)',
'if true; then git status; fi',
'fn() { rm target; }',
'git push origin main',
'git branch --list -- -d',
'find . -exec echo {} \\;',
"sed 's/a/b/e' file",
"sed 's/a/b/' 'w file'",
"sed 's/w /x/' file",
'awk \'{ system("date") }\'',
'git remote show remove',
])('does not widen the compatibility boolean for %j', async (command) => {
expect(await isShellCommandReadOnlyAST(command)).toBe(false);
});
it('classifies deeply nested substitutions without repeated traversal', async () => {
let command = 'git status';
for (let depth = 0; depth < 30; depth++) command = `echo $(${command})`;
expect(await classifyShellCommandSafety(command)).toBe('unknown');
});
it('classifies deeply nested redirected substitutions without repeated traversal', async () => {
const commands = ['git status', 'git status'];
for (let depth = 0; depth < 20; depth++) {
commands[0] = `echo $(${commands[0]}) < /dev/null`;
commands[1] = `< <(${commands[1]}) cat`;
}
const startedAt = performance.now();
await expect(
Promise.all(commands.map(classifyShellCommandSafety)),
).resolves.toEqual(['unknown', 'unknown']);
expect(performance.now() - startedAt).toBeLessThan(1000);
});
it('classifies adversarial rule inputs in bounded time', async () => {
const backslashes = '\\'.repeat(10_000);
const repeatedSed = 'p;'.repeat(10_000);
const repeatedPrint = 'print value; '.repeat(10_000);
const repeatedFindExec = '-exec echo \\; '.repeat(10_000);
const unmatchedBraces = '\\{'.repeat(10_000);
const commands = [
`sed 's/${backslashes}a' file`,
`sed '${repeatedSed}' file`,
`awk 'BEGIN { print "${backslashes} > output }'`,
`awk 'BEGIN { ${repeatedPrint} }'`,
`find . ${repeatedFindExec}`,
`git status ${unmatchedBraces}`,
];
const startedAt = performance.now();
await expect(
Promise.all(commands.map(classifyShellCommandSafety)),
).resolves.toEqual([
'unknown',
'read-only',
'unknown',
'read-only',
'unknown',
'read-only',
]);
expect(performance.now() - startedAt).toBeLessThan(1000);
});
});
// =========================================================================
// extractCommandRules
// =========================================================================
@ -561,6 +1013,18 @@ describe('isShellCommandReadOnlyAST fallback to regex-based checker', () => {
expect(await isShellCommandReadOnlyAST('ls -la')).toBe(true);
});
it('maps parser unavailability to unknown in the classification API', async () => {
_setParserFailedForTesting();
expect(await classifyShellCommandSafety('git status')).toBe('unknown');
expect(await isShellCommandReadOnlyAST('git status')).toBe(true);
});
it('treats syntax errors as unknown without widening the boolean API', async () => {
expect(isShellCommandReadOnly('ls |')).toBe(false);
expect(await classifyShellCommandSafety('ls |')).toBe('unknown');
expect(await isShellCommandReadOnlyAST('ls |')).toBe(false);
});
it('returns the regex-based result for a mutating command when parser is marked failed', async () => {
_setParserFailedForTesting();
expect(await isShellCommandReadOnlyAST('rm -rf /')).toBe(false);
@ -689,8 +1153,8 @@ describe('consistency: isShellCommandReadOnly (regex) vs isShellCommandReadOnlyA
['ls\n\ngrep foo', true, 'consecutive newlines, all read-only'],
// --- env prefix ---
['FOO=bar ls', true],
['A=1 B=2 ls -la', true],
['FOO=bar ls', false],
['A=1 B=2 ls -la', false],
// --- whitespace ---
[' ', false, 'whitespace-only returns false'],

View file

@ -20,7 +20,14 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isShellCommandReadOnly } from './shellReadOnlyChecker.js';
import {
classifyAwkCommandSafety,
classifySedCommandSafety,
hasShellPatternExpansion,
} from './shell-safety-rules.js';
export type ShellCommandSafety = 'read-only' | 'write' | 'unknown';
type Safety = ShellCommandSafety;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@ -108,6 +115,8 @@ const READ_ONLY_ROOT_COMMANDS = new Set([
'whoami',
]);
const WRITE_ROOT_COMMAND =
/^(chgrp|chmod|chown|cp|install|ln|mkdir|mkfifo|mknod|mv|rename|rm|rmdir|shred|touch|truncate|unlink)$/;
/** Git sub-commands considered read-only. */
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
'blame',
@ -123,64 +132,28 @@ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
'status',
'describe',
]);
const WRITE_GIT_SUBCOMMAND =
/^(add|am|checkout|cherry-pick|clean|clone|commit|fetch|gc|init|merge|mv|pull|push|rebase|reset|restore|revert|rm|stash|switch)$/;
/** git remote actions that mutate state. */
const BLOCKED_GIT_REMOTE_ACTIONS = new Set([
'add',
'remove',
'rename',
'set-url',
'prune',
'update',
]);
const WRITE_GIT_REMOTE_ACTION =
/^(add|remove|rm|rename|set-branches|set-head|set-url|update)$/;
const GIT_EXTERNAL_HELPER_OPTION =
/^--(?:ext-diff|filters|show-signature|textconv|open-files-in-pager)(?:=|$)/;
const GIT_COMMIT_VALUE_OPTION =
/^(?:-[CcFmt]|--(?:author|cleanup|date|file|fixup|message|pathspec-from-file|reedit-message|reuse-message|squash|template|trailer))$/;
/** git branch flags that mutate state. */
const BLOCKED_GIT_BRANCH_FLAGS = new Set([
'-d',
'-D',
'--delete',
'--move',
'-m',
]);
const WRITE_GIT_BRANCH_FLAG =
/^(?:-[cCdDmMu](?:.|$)|--(?:delete|move|copy|set-upstream(?:-to)?|unset-upstream|create-reflog|edit-description)(?:=|$))/;
const GIT_BRANCH_LIST_FLAG =
/^(?:-[alr]|--(?:all|list|remotes|show-current|contains|no-contains|merged|no-merged|points-at))(?:=|$)/;
/** find flags that have side-effects. */
const BLOCKED_FIND_FLAGS = new Set([
'-delete',
'-exec',
'-execdir',
'-ok',
'-okdir',
]);
const BLOCKED_FIND_PREFIXES = ['-fprint', '-fprintf'];
/** sed flags that cause in-place editing. */
const BLOCKED_SED_PREFIXES = ['-i'];
/** AWK side-effect patterns that can execute commands or write files. */
const AWK_SIDE_EFFECT_PATTERNS = [
/system\s*\(/,
/print\s+[^>|]*>\s*"[^"]*"/,
/printf\s+[^>|]*>\s*"[^"]*"/,
/print\s+[^>|]*>>\s*"[^"]*"/,
/printf\s+[^>|]*>>\s*"[^"]*"/,
/print\s+[^|]*\|\s*"[^"]*"/,
/printf\s+[^|]*\|\s*"[^"]*"/,
/getline\s*<\s*"[^"]*"/,
/"[^"]*"\s*\|\s*getline/,
/close\s*\(/,
];
/** SED side-effect patterns. */
const SED_SIDE_EFFECT_PATTERNS = [
/[^\\]e\s/,
/^e\s/,
/[^\\]w\s/,
/^w\s/,
/[^\\]r\s/,
/^r\s/,
];
const BLOCKED_FIND_PREFIXES = ['-fls', '-fprint', '-fprintf'];
const FIND_VALUE_PREDICATE =
/^-(?:[ac]?newer|newer[a-z]{2}|[acm](?:min|time)|context|fstype|gid|group|i?(?:lname|name|path|regex)|inum|links|maxdepth|mindepth|path|perm|printf|regextype|samefile|size|type|uid|used|user|wholename|xtype)$/;
const UNIQ_VALUE_OPTIONS = new Set(
'-f --skip-fields -s --skip-chars -w --check-chars'.split(' '),
);
/**
* Write-redirection operators in file_redirect nodes.
* Input-only redirections (`<`, `<<`, `<<<`) are safe.
@ -601,6 +574,7 @@ const DOCKER_COMPOSE_SUBCOMMANDS = new Set([
let parserInstance: Parser | null = null;
let bashLanguage: Parser.Language | null = null;
let parserClass: typeof Parser;
let initPromise: Promise<void> | null = null;
/** Set to true permanently once WASM initialisation fails. */
let parserInitFailed = false;
@ -629,19 +603,28 @@ export async function initParser(): Promise<void> {
'web-tree-sitter/tree-sitter.wasm',
);
await ParserClass.init({ wasmBinary: treeSitterWasm });
parserInstance = new ParserClass();
const bashWasm = await loadWasmBinary(
() =>
import('tree-sitter-wasms/out/tree-sitter-bash.wasm?binary' as string),
'tree-sitter-wasms/out/tree-sitter-bash.wasm',
);
bashLanguage = await ParserClass.Language.load(bashWasm);
parserClass = ParserClass;
parserInstance = new ParserClass();
parserInstance.setLanguage(bashLanguage);
})().catch((err: unknown) => {
const failedParser = parserInstance;
parserInstance = null;
bashLanguage = null;
// Mark as permanently failed so callers can use the regex fallback
// instead of retrying (which could cause the agent to hang).
parserInitFailed = true;
initPromise = null;
try {
failedParser?.delete();
} catch {
// Preserve the initialization error.
}
throw err;
});
@ -654,7 +637,30 @@ export async function initParser(): Promise<void> {
*/
export async function parseShellCommand(command: string): Promise<Parser.Tree> {
await initParser();
return parserInstance!.parse(command);
const parser = parserInstance!;
try {
return parser.parse(command);
} catch (error) {
parserInstance = null;
let replacement: Parser | null = null;
try {
replacement = new parserClass();
replacement.setLanguage(bashLanguage);
parserInstance = replacement;
} catch {
try {
replacement?.delete();
} catch {
// Preserve the parse error.
}
bashLanguage = null;
parserInitFailed = true;
initPromise = null;
} finally {
parser.delete();
}
throw error;
}
}
// ---------------------------------------------------------------------------
@ -663,10 +669,16 @@ export async function parseShellCommand(command: string): Promise<Parser.Tree> {
type SyntaxNode = Parser.SyntaxNode;
const SHELL_EXPANSION_TYPES = new Set(
'simple_expansion expansion arithmetic_expansion'.split(' '),
);
const CHILD_STATEMENT =
/^(?:pipeline|list|subshell|compound_statement|negated_command)$/;
/** Collect all descendant nodes of given types. */
function collectDescendants(
node: SyntaxNode,
types: Set<string>,
outermostOnly = false,
): SyntaxNode[] {
const result: SyntaxNode[] = [];
const stack: SyntaxNode[] = [node];
@ -674,6 +686,7 @@ function collectDescendants(
const current = stack.pop()!;
if (types.has(current.type)) {
result.push(current);
if (outermostOnly) continue;
}
for (let i = current.childCount - 1; i >= 0; i--) {
stack.push(current.child(i)!);
@ -682,35 +695,6 @@ function collectDescendants(
return result;
}
/** Check if a tree contains any command_substitution or process_substitution node. */
function containsCommandSubstitutionAST(node: SyntaxNode): boolean {
return (
collectDescendants(
node,
new Set(['command_substitution', 'process_substitution']),
).length > 0
);
}
/** Check if a redirected_statement contains a write-redirection. */
function hasWriteRedirection(node: SyntaxNode): boolean {
if (node.type !== 'redirected_statement') return false;
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)!;
if (child.type === 'file_redirect') {
// The operator is the first non-descriptor child
for (let j = 0; j < child.childCount; j++) {
const op = child.child(j)!;
if (op.type === 'file_descriptor') continue;
// operator token
if (WRITE_REDIRECT_OPERATORS.has(op.type)) return true;
break; // only check the operator position
}
}
}
return false;
}
/**
* Extract the command_name text from a `command` node.
* Handles leading variable_assignment(s) gracefully.
@ -752,180 +736,384 @@ function stripOuterQuotes(text: string): string {
return text;
}
// ---------------------------------------------------------------------------
// Read-Only Analysis (per-command)
// ---------------------------------------------------------------------------
/**
* Evaluate whether a single `command` node (simple command) is read-only.
*/
function evaluateCommandReadOnly(commandNode: SyntaxNode): boolean {
const root = getCommandName(commandNode);
if (!root) return true; // pure variable assignment
const argNodes = getArgumentNodes(commandNode);
const argTexts = argNodes.map((n) => stripOuterQuotes(n.text));
if (!READ_ONLY_ROOT_COMMANDS.has(root)) return false;
// Command-specific analysis
if (root === 'git') return evaluateGitReadOnly(argTexts);
if (root === 'find') return evaluateFindReadOnly(argTexts);
if (root === 'sed') return evaluateSedReadOnly(argTexts);
if (root === 'awk') return evaluateAwkReadOnly(argTexts);
return true;
function hasShellExpansion(node: SyntaxNode): boolean {
return (
collectDescendants(node, SHELL_EXPANSION_TYPES).length > 0 ||
(['word', 'concatenation'].includes(node.type) &&
hasShellPatternExpansion(node.text))
);
}
function evaluateGitReadOnly(args: string[]): boolean {
// Skip global flags to find subcommand
let idx = 0;
while (idx < args.length && args[idx]!.startsWith('-')) {
const flag = args[idx]!.toLowerCase();
if (flag === '--version' || flag === '--help') return true;
idx++;
function mergeSafety(...results: ShellCommandSafety[]): ShellCommandSafety {
if (results.includes('write')) return 'write';
if (results.includes('unknown')) return 'unknown';
return 'read-only';
}
function beforeTerminator(args: string[]): string[] {
const end = args.indexOf('--');
return args.slice(0, end < 0 ? args.length : end);
}
function hasHelp(args: string[], valueOptions: string[] = []): boolean {
return beforeTerminator(args).some(
(arg, index, options) =>
/^(?:--help|--version)$/i.test(arg) &&
!valueOptions.includes(options[index - 1]!),
);
}
function withoutOptionValues(args: string[], valueOption: RegExp): string[] {
const result: string[] = [];
for (let i = 0; i < args.length; i++) {
result.push(args[i]!);
if (valueOption.test(args[i]!)) i++;
}
if (idx >= args.length) return true; // `git` with only flags
return result;
}
const subcommand = args[idx]!.toLowerCase();
if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) return false;
function evaluateOutputOption(args: string[], long = true, short = true) {
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--') break;
if ((short && arg === '-o') || (long && arg === '--output')) {
return args[i + 1] ? 'write' : 'unknown';
}
if (short && arg.startsWith('-o') && arg.length > 2) return 'write';
if (long && arg.startsWith('--output=')) {
return arg.length > 9 ? 'write' : 'unknown';
}
}
return null;
}
const rest = args.slice(idx + 1);
function evaluateGitSafety(args: string[]): ShellCommandSafety {
const first = args[0];
if (!first || first === '--version') return 'read-only';
if (first === '--help') return args.length === 1 ? 'read-only' : 'unknown';
if (first.startsWith('-')) return 'unknown';
const subcommand = first.toLowerCase();
const rest = args.slice(1);
const options = beforeTerminator(rest);
const invokesHelper =
options.some((arg) => GIT_EXTERNAL_HELPER_OPTION.test(arg)) ||
(subcommand === 'grep' && options.some((arg) => arg.startsWith('-O'))) ||
(['log', 'show'].includes(subcommand) &&
options.some((arg) => /%G[?GKFPST]/.test(arg)));
if (WRITE_GIT_SUBCOMMAND.test(subcommand)) {
const effectiveArgs =
subcommand === 'commit'
? withoutOptionValues(rest, GIT_COMMIT_VALUE_OPTION)
: rest;
const effectiveOptions = beforeTerminator(effectiveArgs);
const help = hasHelp(effectiveArgs);
const dryRun =
effectiveOptions.includes('--dry-run') ||
(effectiveOptions.includes('-n') &&
['add', 'clean', 'mv', 'push', 'rm'].includes(subcommand));
return help || dryRun ? 'unknown' : 'write';
}
if (!READ_ONLY_GIT_SUBCOMMANDS.has(subcommand)) return 'unknown';
if (['diff', 'log', 'show'].includes(subcommand)) {
const output = evaluateOutputOption(rest, true, false);
if (output) return output;
}
if (
subcommand === 'blame' &&
beforeTerminator(rest).some((arg) => /^--output(?:=|$)/.test(arg))
)
return 'unknown';
if (subcommand !== 'branch' && hasHelp(rest)) return 'unknown';
if (subcommand === 'remote') {
return !rest.some((a) => BLOCKED_GIT_REMOTE_ACTIONS.has(a.toLowerCase()));
const action = rest.find((arg) => !arg.startsWith('-'))?.toLowerCase();
if (!action) return invokesHelper ? 'unknown' : 'read-only';
if (['show', 'get-url'].includes(action))
return rest.some((arg) =>
/^(?:add|remove|rm|rename|set-branches|set-head|set-url|update|prune)$/i.test(
arg,
),
) || invokesHelper
? 'unknown'
: 'read-only';
if (WRITE_GIT_REMOTE_ACTION.test(action)) return 'write';
if (action === 'prune')
return rest.some((arg) => ['-n', '--dry-run'].includes(arg))
? 'unknown'
: 'write';
return 'unknown';
}
if (subcommand === 'branch') {
return !rest.some((a) => BLOCKED_GIT_BRANCH_FLAGS.has(a));
const actions = withoutOptionValues(rest, /^--(?:format|sort)$/);
const actionOptions = beforeTerminator(actions);
if (hasHelp(actions)) return 'unknown';
if (actions.some((arg) => WRITE_GIT_BRANCH_FLAG.test(arg)))
return actionOptions.some((arg) => WRITE_GIT_BRANCH_FLAG.test(arg))
? 'write'
: 'unknown';
if (actions.length !== rest.length) return 'unknown';
const lists = actionOptions.some((arg) => GIT_BRANCH_LIST_FLAG.test(arg));
if (lists) return 'read-only';
if (rest.some((arg) => !arg.startsWith('-'))) return 'write';
if (rest.includes('--')) return 'unknown';
if (invokesHelper) return 'unknown';
return rest.length === 0 ? 'read-only' : 'unknown';
}
return true;
if (invokesHelper) return 'unknown';
return 'read-only';
}
function evaluateFindReadOnly(args: string[]): boolean {
for (const arg of args) {
const lower = arg.toLowerCase();
if (BLOCKED_FIND_FLAGS.has(lower)) return false;
if (BLOCKED_FIND_PREFIXES.some((p) => lower.startsWith(p))) return false;
function evaluateFindSafety(args: string[]): ShellCommandSafety {
let result: ShellCommandSafety = 'read-only';
for (let i = 0; i < args.length; i++) {
const lower = args[i]!.toLowerCase();
if (lower === '--') return mergeSafety(result, 'unknown');
if (/^--(?:help|version)$/.test(lower)) return 'unknown';
if (FIND_VALUE_PREDICATE.test(lower)) {
if (!args[++i]?.match(/^[^-]/)) result = mergeSafety(result, 'unknown');
continue;
}
if (lower === '-delete') {
result = 'write';
continue;
}
if (BLOCKED_FIND_PREFIXES.some((prefix) => lower.startsWith(prefix))) {
result = 'write';
i += lower.startsWith('-fprintf') ? 2 : 1;
continue;
}
if (['-exec', '-execdir', '-ok', '-okdir'].includes(lower)) {
const invoked = args[i + 1]?.toLowerCase();
let end = -1;
for (let index = i + 2; index < args.length; index++) {
if ([';', '\\;', '+'].includes(args[index]!)) {
end = index;
break;
}
}
const invokedArgs = args.slice(i + 2, end < 0 ? undefined : end);
let nested: Safety = 'unknown';
if (invoked && WRITE_ROOT_COMMAND.test(invoked))
nested = hasHelp(invokedArgs) ? 'unknown' : 'write';
else if (invoked && /^(kill|killall|pkill)$/.test(invoked))
nested = processSafety(invoked, invokedArgs);
result = mergeSafety(result, nested);
i = end < 0 ? args.length : end;
}
}
return true;
return result;
}
function evaluateSedReadOnly(args: string[]): boolean {
for (const arg of args) {
function evaluateSedSafety(args: string[]): ShellCommandSafety {
return classifySedCommandSafety(args);
}
function evaluateAwkSafety(args: string[]): ShellCommandSafety {
return classifyAwkCommandSafety(args);
}
function evaluateUniqSafety(args: string[]): ShellCommandSafety {
let positional = 0;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === '--') {
return args.length - i + positional > 2 ? 'write' : 'read-only';
} else if (UNIQ_VALUE_OPTIONS.has(arg)) {
if (!args[++i]) return 'unknown';
} else if (arg === '-' || !arg.startsWith('-')) positional++;
}
return positional >= 2 ? 'write' : 'read-only';
}
function processSafety(root: string, args: string[]): Safety {
const options = beforeTerminator(args);
const signalZero = /^(?:SIG)?0+$/i;
const signalValueOptions = [
'--signal',
...(root === 'pkill' ? [] : ['-s']),
...(root === 'kill' ? ['-n'] : []),
];
if (
args.length === 0 ||
hasHelp(args) ||
options.some((arg) => ['-h', '-V', '-help', '-version'].includes(arg))
)
return 'unknown';
if (
options.some(
(arg, index) =>
(/[$`*?()[\]{}]/.test(arg) &&
(arg.startsWith('-') ||
signalValueOptions.includes(options[index - 1]!))) ||
/^-(?:[lL0]|-(?:.*list|table)(?:=|$))/.test(arg) ||
(/^--signal=/.test(arg) && signalZero.test(arg.slice(9))) ||
/^-(?:SIG)?0+$/i.test(arg) ||
(root === 'kill' && /^-[sn](?:SIG)?0+$/i.test(arg)) ||
(root === 'killall' && /^-s(?:SIG)?0+$/i.test(arg)) ||
(index > 0 &&
signalValueOptions.includes(options[index - 1]!) &&
signalZero.test(arg)),
)
)
return 'unknown';
return 'write';
}
function evaluateSubstitutions(node: SyntaxNode): ShellCommandSafety {
const substitutions = collectDescendants(
node,
new Set(['command_substitution', 'process_substitution']),
true,
);
if (substitutions.length === 0) return 'read-only';
return mergeSafety(
'unknown',
...substitutions
.flatMap((substitution) => substitution.namedChildren)
.map(evaluateStatementSafety),
);
}
function evaluateCommandSafety(commandNode: SyntaxNode): ShellCommandSafety {
const rawRoot = commandNode.childForFieldName('name')?.text;
const root = getCommandName(commandNode);
const argNodes = getArgumentNodes(commandNode);
const args = argNodes.map((node) => stripOuterQuotes(node.text));
let result: ShellCommandSafety;
if (!root) result = 'read-only';
else if (rawRoot !== root) result = 'unknown';
else if (WRITE_ROOT_COMMAND.test(root)) {
result = hasHelp(args) ? 'unknown' : 'write';
} else if (/^(kill|killall|pkill)$/.test(root)) {
result = processSafety(root, args);
} else if (root === 'git') result = evaluateGitSafety(args);
else if (root === 'find') result = evaluateFindSafety(args);
else if (root === 'sed') result = evaluateSedSafety(args);
else if (root === 'awk') result = evaluateAwkSafety(args);
else if (root === 'sort' || root === 'tree') {
result = evaluateOutputOption(args, root === 'sort') ?? 'read-only';
if (hasHelp(args, ['-o', '--output'])) result = 'unknown';
if (
BLOCKED_SED_PREFIXES.some((p) => arg.startsWith(p)) ||
arg === '--in-place'
beforeTerminator(args).some(
(arg) =>
/^(?:--o|-[^-]+o)/.test(arg) ||
(root === 'sort' && arg.startsWith('--co')),
)
) {
return false;
result = mergeSafety(result, 'unknown');
}
} else if (root === 'uniq') {
result = hasHelp(args) ? 'unknown' : evaluateUniqSafety(args);
} else if (root === 'tee') {
const writesFile = args.some(
(arg, index) => !arg.startsWith('-') || args[index - 1] === '--',
);
result = writesFile ? 'write' : 'unknown';
} else if (root === 'dd') {
result = args.some((arg) => arg.startsWith('of=')) ? 'write' : 'unknown';
} else if (
(root === 'printf' &&
beforeTerminator(args).some((arg) => /^-[^-]*v/.test(arg))) ||
['less', 'more'].includes(root) ||
(['rg', 'ripgrep'].includes(root) &&
beforeTerminator(args).some((arg) =>
/^(?:--(?:hostname-bin|pre)(?:=|$)|--search-zip$|-[^-]*z)/.test(arg),
))
) {
result = 'unknown';
} else {
result = READ_ONLY_ROOT_COMMANDS.has(root) ? 'read-only' : 'unknown';
}
if (
result === 'read-only' &&
root &&
/^(awk|find|git|printf|rg|ripgrep|sed|sort|tree|uniq)$/.test(root) &&
argNodes.some((node) => hasShellExpansion(node))
) {
result = 'unknown';
}
if (
result === 'write' &&
!['find', 'git', 'sed', 'sort', 'tree'].includes(root ?? '') &&
hasHelp(args)
)
result = 'unknown';
const hasEnvironment = commandNode.namedChildren.some(
(child) => child.type === 'variable_assignment',
);
if (root && hasEnvironment) result = mergeSafety(result, 'unknown');
return mergeSafety(
result,
evaluateRedirectionSafety(commandNode),
...commandNode.namedChildren
.filter((child) => !child.type.endsWith('_redirect'))
.map(evaluateSubstitutions),
);
}
function evaluateRedirectionSafety(node: SyntaxNode): ShellCommandSafety {
let result: ShellCommandSafety = 'read-only';
for (const redirect of node.namedChildren) {
if (!redirect.type.endsWith('_redirect')) continue;
result = mergeSafety(result, evaluateSubstitutions(redirect));
if (redirect.type !== 'file_redirect') continue;
const operator = redirect.children.find(
(child) => child.type !== 'file_descriptor',
);
if (!operator) return 'unknown';
if (WRITE_REDIRECT_OPERATORS.has(operator.type)) return 'write';
if (operator.type === '>&') {
const destination = redirect.childForFieldName('destination');
if (!destination) return 'unknown';
const target = stripOuterQuotes(destination.text);
if (/^(?:\d+|-)$/.test(target)) continue;
result = mergeSafety(
result,
/[$`*?()[\]{}]/.test(target) ? 'unknown' : 'write',
);
}
}
const scriptContent = args.join(' ');
return !SED_SIDE_EFFECT_PATTERNS.some((p) => p.test(scriptContent));
return result;
}
function evaluateAwkReadOnly(args: string[]): boolean {
const scriptContent = args.join(' ');
return !AWK_SIDE_EFFECT_PATTERNS.some((p) => p.test(scriptContent));
function childrenSafety(node: SyntaxNode, floor: Safety = 'read-only'): Safety {
return mergeSafety(floor, ...node.namedChildren.map(evaluateStatementSafety));
}
// ---------------------------------------------------------------------------
// Statement-level read-only analysis
// ---------------------------------------------------------------------------
function evaluateStatementSafety(node: SyntaxNode): ShellCommandSafety {
if (node.type === 'command') return evaluateCommandSafety(node);
if (CHILD_STATEMENT.test(node.type)) return childrenSafety(node);
if (node.type === 'redirected_statement')
return mergeSafety(
...node.namedChildren
.filter((child) => !child.type.endsWith('_redirect'))
.map((child) => evaluateStatementSafety(child)),
evaluateRedirectionSafety(node),
);
if (/^variable_assignments?$/.test(node.type))
return mergeSafety(
node.parent?.namedChildCount === 1 ? 'read-only' : 'unknown',
evaluateSubstitutions(node),
);
if (node.type === 'function_definition') return 'unknown';
return childrenSafety(node, 'unknown');
}
/**
* Recursively evaluate whether a statement AST node is read-only.
*
* Handles: command, pipeline, list, redirected_statement, subshell,
* variable_assignment, negated_command, and compound statements.
*
* Command substitution (`$(...)`, `` `...` ``) and process substitution
* (`<(...)`, `>(...)`) anywhere in the subtree mark the whole node as
* NOT read-only checked once at the top so every case below inherits
* the guard. This matters for non-`command` node types like
* `variable_assignment` (`FOO=$(curl evil)`) and `redirected_statement`
* (`cat < $(curl evil)`) where the substitution sits outside any
* `command` child. See PR #4386 round 4.
*/
function evaluateStatementReadOnly(node: SyntaxNode): boolean {
if (containsCommandSubstitutionAST(node)) return false;
switch (node.type) {
case 'command':
return evaluateCommandReadOnly(node);
case 'pipeline': {
// All commands in the pipeline must be read-only
for (const child of node.namedChildren) {
if (!evaluateStatementReadOnly(child)) return false;
}
return true;
}
case 'list': {
// All commands joined by && / || must be read-only
for (const child of node.namedChildren) {
if (!evaluateStatementReadOnly(child)) return false;
}
return true;
}
case 'redirected_statement': {
// Write redirections make it non-read-only
if (hasWriteRedirection(node)) return false;
// Evaluate the body statement
const body = node.namedChildren[0];
return body ? evaluateStatementReadOnly(body) : true;
}
case 'subshell': {
// Evaluate all statements inside the subshell
for (const child of node.namedChildren) {
if (!evaluateStatementReadOnly(child)) return false;
}
return true;
}
case 'compound_statement': {
// { cmd1; cmd2; } evaluate each inner statement
for (const child of node.namedChildren) {
if (!evaluateStatementReadOnly(child)) return false;
}
return true;
}
case 'variable_assignment':
case 'variable_assignments':
// Pure assignments without a command read-only (just sets env)
return true;
case 'negated_command': {
const inner = node.namedChildren[0];
return inner ? evaluateStatementReadOnly(inner) : true;
}
case 'function_definition':
// Function definitions are not read-only operations per se
return false;
case 'if_statement':
case 'while_statement':
case 'for_statement':
case 'case_statement':
case 'c_style_for_statement':
// Control flow constructs conservatively non-read-only
return false;
case 'declaration_command':
// export/declare/local/readonly/typeset can modify env
return false;
default:
// Unknown node types conservatively non-read-only
return false;
async function classifyInternal(command: string): Promise<Safety> {
const tree = await parseShellCommand(command);
try {
const root = tree.rootNode;
if (root.namedChildCount === 0 || root.hasError) return 'unknown';
return mergeSafety(...root.namedChildren.map(evaluateStatementSafety));
} finally {
tree.delete();
}
}
// ---------------------------------------------------------------------------
// Public API: isShellCommandReadOnlyAST
// ---------------------------------------------------------------------------
export async function classifyShellCommandSafety(
command: string,
): Promise<ShellCommandSafety> {
if (typeof command !== 'string' || !command.trim()) return 'unknown';
return classifyInternal(command).catch(() => 'unknown');
}
/**
* AST-based check whether a shell command is read-only.
@ -953,22 +1141,7 @@ export async function isShellCommandReadOnlyAST(
}
try {
const tree = await parseShellCommand(command);
const root = tree.rootNode;
// Empty program
if (root.namedChildCount === 0) return false;
// Evaluate every top-level statement
for (const stmt of root.namedChildren) {
if (!evaluateStatementReadOnly(stmt)) {
tree.delete();
return false;
}
}
tree.delete();
return true;
return (await classifyInternal(command)) === 'read-only';
} catch {
// Unexpected runtime failure (e.g. WASM init error on first call)
// fall back to the regex-based checker rather than propagating the error.

View file

@ -18,6 +18,10 @@ describe('evaluateShellCommandReadOnly', () => {
expect(result).toBe(false);
});
it('rejects differently-cased command names', () => {
expect(isShellCommandReadOnly('LS -la')).toBe(false);
});
it('rejects redirection output', () => {
const result = isShellCommandReadOnly('ls > out.txt');
expect(result).toBe(false);
@ -49,9 +53,9 @@ describe('evaluateShellCommandReadOnly', () => {
expect(result).toBe(false);
});
it('respects environment prefix followed by allowed command', () => {
it('rejects environment prefix followed by allowed command', () => {
const result = isShellCommandReadOnly('FOO=bar ls');
expect(result).toBe(true);
expect(result).toBe(false);
});
describe('multi-command security', () => {
@ -153,6 +157,15 @@ describe('evaluateShellCommandReadOnly', () => {
);
});
it('rejects gawk indirect function calls', () => {
for (const command of [
'awk \'BEGIN { fn = "system"; @fn("touch /tmp/pwned") }\'',
'awk \'BEGIN { fn = "system"; @ fn("touch /tmp/pwned") }\'',
]) {
expect(isShellCommandReadOnly(command)).toBe(false);
}
});
it('rejects awk with file output redirection', () => {
expect(
isShellCommandReadOnly('awk \'{print > "output.txt"}\' input.txt'),
@ -200,7 +213,6 @@ describe('evaluateShellCommandReadOnly', () => {
it('allows safe sed commands', () => {
expect(isShellCommandReadOnly("sed 's/foo/bar/' file.txt")).toBe(true);
expect(isShellCommandReadOnly("sed -n '1,5p' file.txt")).toBe(true);
expect(isShellCommandReadOnly("sed '/pattern/d' file.txt")).toBe(true);
});
it('rejects sed with execute command', () => {
@ -231,4 +243,227 @@ describe('evaluateShellCommandReadOnly', () => {
).toBe(false);
});
});
describe('tri-state classifier mirrors', () => {
it.each([
'sort -o output input',
'sort input',
'sort --help',
'sort --compress-program gzip input',
'sort --out=output input',
'sort -roout input',
'tree -Cofile .',
'sort --co=cat input',
'tree --output=tree.txt',
'tree directory',
'tree --help',
'uniq input output',
'uniq - output',
'uniq -- -f output',
'uniq input -- -f',
'uniq input',
'uniq -f',
'tee output',
'dd of=output',
'less file',
'more file',
'printf -v PATH /tmp',
'printf -v PATH /tmp; ls',
'rg --pre cat pattern',
'rg --hostname-bin=hostname pattern',
'rg -z pattern archive.gz',
'ripgrep -iz pattern archive.gz',
'rg --search-zip pattern archive.gz',
'rg -- -z file',
'git branch topic',
'git branch --color=always color-topic',
'git branch --column column-topic',
'git branch --sort=refname sort-topic',
"git branch --format='%(refname)' format-topic",
'git branch -v verbose-topic',
'git branch --edit-description',
'git branch -- --list',
'git branch --list -- -d',
'git branch --list --color=always topic',
'git branch --list topic',
'git remote set-url origin url',
'git remote unknown-action',
'git remote show remove',
'git remote get-url prune',
'git diff --output=patch',
'git log --output=log.out',
'git show --output=show.out HEAD',
'git blame --output=blame.out file',
'git diff --ext-diff',
'git --config-env=diff.external=HELPER diff',
'git --paginate log',
'git -p log',
'git --unknown-option status',
'git -- status',
'git --help commit',
'git status --help',
'git log --help',
'git diff --help',
'git log --show-signature -1',
'git show --format=%G? HEAD',
'GIT_EXTERNAL_DIFF=/tmp/helper git diff',
'FOO=bar GIT_EXTERNAL_DIFF=/tmp/helper git diff',
'FOO=bar ls',
'LD_PRELOAD=/tmp/evil.so ls',
'RIPGREP_CONFIG_PATH=/tmp/config rg pattern',
'PAGER=helper git log',
'git show --textconv HEAD:file',
'git grep --open-files-in-pager=less needle',
'git grep -Ovim needle',
'git cat-file --filters HEAD:file',
'git commit -m --ext-diff',
"git commit -m '%G?'",
'git add -- --help',
'git add -- --dry-run',
'touch -- --help',
'bash -c ls',
'find . -fls output',
'find --help',
'find . -name',
'find . -name -delete',
'find . -printf -delete',
'find . -newermt -delete',
'find . -samefile -delete',
'sed -f script.sed file',
'sed -e -i file',
'sed -f -i file',
'sed -e-i file',
'sed -- -i file',
"sed -I .bak 's/a/b/' file",
"sed -I.bak 's/a/b/' file",
"sed -ni.bak 's/a/b/' file",
"sed -nI.bak 's/a/b/' file",
'sed -fscript.sed file',
"sed --in-pl=.bak 's/a/b/' file",
'sed --f script.sed file',
'sed -newout input',
'sed -nEewout input',
'sed -es/a/b/e',
'sed -einstall file',
'sed -neinstall file',
"sed -e '' file",
"sed 's/a/printf hacked > marker/ep' file",
"sed 's#a#printf hacked > marker#pe' file",
"sed 'etouch marker' file",
"sed '1etouch marker' file",
"sed 's/a/b/woutput' file",
"sed 'woutput' file",
"sed '1woutput' file",
"sed '/pattern/woutput' file",
"sed 'W output' file",
"sed '1W output' file",
"sed 'p;w output' file",
"sed 's/a/b/;w output' file",
"sed 's/a/;/;w output' file",
"sed -e 's/a/b/' -e 'woutput' file",
'sed "$SCRIPT" file',
'sed s/a/*/ file',
"sed 's/a/b/w' file",
"sed 'w' file",
"sed '1w' file",
"sed 'R input' file",
"sed 's/a/b/' 'w file'",
"sed 's/a/new value/' file",
"sed 's/a/blue sky/' file",
"sed 's/a/car value/' file",
"sed 's/w /x/' file",
"sed '/p;w output/p' file",
"sed 's/a/;w output/' file",
"awk '{ print > output }' file",
'awk \'{ print>"output" }\' file',
'awk \'BEGIN { print("x")|"cat > output" }\'',
'awk \'BEGIN { print(1 > "0") }\'',
'awk \'BEGIN { printf("%d", 1 > "0") }\'',
'awk \'BEGIN { print "print > " "output" }\'',
'awk \'BEGIN { # print > "out"\nprint }\'',
'awk \'BEGIN { print /x; print y > "out";/ }\'',
'awk \'BEGIN { print x / 2 > "out" }\'',
"awk '{ print }' 'print > \"out\"'",
'awk -v mode=1 \'BEGIN { print > "out" }\' input',
'awk \'/pattern/ { print > "out" }\' input',
'awk -fscript.awk file',
'awk -W exec=script.awk file',
'awk -Wexec=script.awk file',
'awk "$PROGRAM" file',
'awk "$PROGRAM{ print }" file',
'sed "s/a/$SCRIPT" file',
'awk -v x="$VALUE" \'{ print x }\' file',
'awk \'@include "library.awk"\' file',
"awk -e '{ print }' file",
"awk --load extension '{ print }' file",
"awk --profile=report '{ print }' file",
'awk {print*2} file',
'uniq *',
'uniq "$FILES"',
'sort "$OPTIONS" input',
'sort {-o,output} input',
'find . "$EXPRESSION"',
'rg "$OPTIONS" pattern',
'git status "$OPTIONS"',
"awk '{ getline }' file",
"GIT_EXTERNAL_DIFF='touch /tmp/pwned'; git diff",
'(PATH=/tmp; ls)',
'ls |',
'ls | | cat',
'ls && && cat',
'ls || || cat',
'ls ; ; cat',
'ls |& | cat',
'ls & & cat',
'(; ls)',
'{ && ls; }',
'(ls |)',
'(ls | | cat)',
'(ls &&)',
'(ls ||)',
])('does not schedule %j as read-only', (command) => {
expect(isShellCommandReadOnly(command)).toBe(false);
});
it.each([
'git remote get-url origin',
'git diff -- file',
'git diff -Oorderfile',
'git log -p',
'git show -p HEAD',
'git blame -p file',
'git log -- --output=log.out',
"sed 's/a/*/' file",
"sed 's/old/new/' file",
"sed 's/hello/world/' file",
"sed 's/error/warning/g' file",
"sed -n '/needle/p' file",
"sed '/pattern/d' file",
"sed 's/a/woutput/' file",
"sed 's#x#s/a/b/woutput#' file",
"sed 's#x#foo;woutput#' file",
"sed 'p;d' file",
"awk '{ print*2 }' file",
"awk -F : '{ print $1 }' input",
])('preserves read-only classification for %j', (command) => {
expect(isShellCommandReadOnly(command)).toBe(true);
});
it('handles adversarial rule inputs without regex backtracking', () => {
const commands = [
`sed 's/${'\\'.repeat(10_000)}a' file`,
`sed '${'p;'.repeat(10_000)}' file`,
`awk 'BEGIN { ${'print value; '.repeat(10_000)} }'`,
`git status ${'\\{'.repeat(10_000)}`,
];
const startedAt = performance.now();
expect(commands.map(isShellCommandReadOnly)).toEqual([
false,
true,
true,
true,
]);
expect(performance.now() - startedAt).toBeLessThan(1000);
});
});
});

View file

@ -16,6 +16,11 @@ import {
splitCommands,
stripShellWrapper,
} from './shell-utils.js';
import {
classifyAwkCommandSafety,
classifySedCommandSafety,
hasShellBraceExpansion,
} from './shell-safety-rules.js';
const READ_ONLY_ROOT_COMMANDS = new Set([
'awk',
@ -32,21 +37,13 @@ const READ_ONLY_ROOT_COMMANDS = new Set([
'git',
'grep',
'head',
'less',
'ls',
'more',
'printenv',
'printf',
'ps',
'pwd',
'rg',
'ripgrep',
'sed',
'sort',
'stat',
'tail',
'tree',
'uniq',
'wc',
'which',
'where',
@ -61,7 +58,7 @@ const BLOCKED_FIND_FLAGS = new Set([
'-okdir',
]);
const BLOCKED_FIND_PREFIXES = ['-fprint', '-fprintf'];
const BLOCKED_FIND_PREFIXES = ['-fls', '-fprint', '-fprintf'];
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
'blame',
@ -81,47 +78,22 @@ const READ_ONLY_GIT_SUBCOMMANDS = new Set([
const BLOCKED_GIT_REMOTE_ACTIONS = new Set([
'add',
'remove',
'rm',
'rename',
'set-branches',
'set-head',
'set-url',
'prune',
'update',
]);
const GIT_EXTERNAL_HELPER_OPTION =
/(?:^--(?:ext-diff|filters|show-signature|textconv|open-files-in-pager)(?:=|$)|%G[?GKFPST])/;
const BLOCKED_GIT_BRANCH_FLAGS = new Set([
'-d',
'-D',
'--delete',
'--move',
'-m',
]);
const BLOCKED_SED_PREFIXES = ['-i'];
// AWK side-effect patterns that can execute commands or write files
const AWK_SIDE_EFFECT_PATTERNS = [
/system\s*\(/, // system() function calls
/print\s+[^>|]*>\s*"[^"]*"/, // print > "file"
/printf\s+[^>|]*>\s*"[^"]*"/, // printf > "file"
/print\s+[^>|]*>>\s*"[^"]*"/, // print >> "file"
/printf\s+[^>|]*>>\s*"[^"]*"/, // printf >> "file"
/print\s+[^|]*\|\s*"[^"]*"/, // print | "command"
/printf\s+[^|]*\|\s*"[^"]*"/, // printf | "command"
/getline\s*<\s*"[^"]*"/, // getline < "command"
/"[^"]*"\s*\|\s*getline/, // "command" | getline
/close\s*\(/, // close() can trigger command execution
];
// SED side-effect patterns
const SED_SIDE_EFFECT_PATTERNS = [
/[^\\]e\s/, // e command (execute)
/^e\s/, // e command at start
/[^\\]w\s/, // w command (write)
/^w\s/, // w command at start
/[^\\]r\s/, // r command (read file)
/^r\s/, // r command at start
];
const SAFE_SED_OPTION = /^(?:-[nErsuz]|--(?:quiet|silent))$/;
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;
const MALFORMED_CONTROL_OPERATOR =
/(?:^|[({])\s*(?:&&|\|\||\|&|[|;&])|(?:&&|\|\||\|&|[|;&])\s+(?:&&|\|\||\|&|[|;&])|(?!(?:&&|\|\||\|&))[|;&]{2}|[|;&]{3,}|(?:\|&?|&&|\|\|)\s*[)}]*\s*$/;
function containsWriteRedirection(command: string): boolean {
let inSingleQuotes = false;
@ -158,11 +130,13 @@ function containsWriteRedirection(command: string): boolean {
}
function normalizeTokens(segment: string): string[] {
const parsed = parse(segment);
const parsed = parse(segment, (key) => `\0${key}`);
const tokens: string[] = [];
for (const token of parsed) {
if (typeof token === 'string') {
tokens.push(token);
} else if ('op' in token && token.op === 'glob') {
tokens.push(`\0${token.pattern}`);
}
}
return tokens;
@ -189,6 +163,7 @@ function skipEnvironmentAssignments(tokens: string[]): {
function evaluateFindCommand(tokens: string[]): boolean {
const [, ...rest] = tokens;
if (rest.at(-1)?.startsWith('-')) return false;
for (const token of rest) {
const lower = token.toLowerCase();
if (BLOCKED_FIND_FLAGS.has(lower)) {
@ -205,66 +180,47 @@ function evaluateSedCommand(tokens: string[]): boolean {
const [, ...rest] = tokens;
for (const token of rest) {
if (
BLOCKED_SED_PREFIXES.some((prefix) => token.startsWith(prefix)) ||
token === '--in-place'
['-i', '-I'].some((prefix) => token.startsWith(prefix)) ||
token === '--in-place' ||
token.startsWith('--in-place=') ||
token === '-f' ||
token === '--file' ||
(token.startsWith('-f') && token.length > 2) ||
token.startsWith('--file=') ||
(token.startsWith('-') && !SAFE_SED_OPTION.test(token))
) {
return false;
}
}
// Check for side-effect patterns in sed script
const scriptContent = rest.join(' ');
for (const pattern of SED_SIDE_EFFECT_PATTERNS) {
if (pattern.test(scriptContent)) {
return false;
}
}
return true;
return classifySedCommandSafety(rest) === 'read-only';
}
function evaluateAwkCommand(tokens: string[]): boolean {
const [, ...rest] = tokens;
// Join all arguments to check for awk script content
const scriptContent = rest.join(' ');
// Check for dangerous side-effect patterns
for (const pattern of AWK_SIDE_EFFECT_PATTERNS) {
if (pattern.test(scriptContent)) {
return false;
}
}
return true;
return classifyAwkCommandSafety(rest) === 'read-only';
}
function evaluateGitRemoteArgs(args: string[]): boolean {
const action = args.find((arg) => !arg.startsWith('-'))?.toLowerCase();
if (action && !['show', 'get-url'].includes(action)) return false;
for (const arg of args) {
if (BLOCKED_GIT_REMOTE_ACTIONS.has(arg.toLowerCase())) {
return false;
}
if (BLOCKED_GIT_REMOTE_ACTIONS.has(arg.toLowerCase())) return false;
}
return true;
}
function evaluateGitBranchArgs(args: string[]): boolean {
for (const arg of args) {
if (BLOCKED_GIT_BRANCH_FLAGS.has(arg)) {
return false;
}
}
return true;
return args.length === 0 || (args.length === 1 && args[0] === '--list');
}
function evaluateGitCommand(tokens: string[]): boolean {
let index = 1;
while (index < tokens.length && tokens[index]!.startsWith('-')) {
const flag = tokens[index]!.toLowerCase();
if (flag === '--version' || flag === '--help') {
return true;
}
index++;
const flag = tokens[index++]!.toLowerCase();
if (flag === '--version') return true;
if (flag === '--help') return tokens.length === 2;
return false;
}
if (index >= tokens.length) {
@ -277,6 +233,14 @@ function evaluateGitCommand(tokens: string[]): boolean {
}
const args = tokens.slice(index + 1);
const end = args.indexOf('--');
const options = args.slice(0, end < 0 ? args.length : end);
if (
options.some((arg) => GIT_EXTERNAL_HELPER_OPTION.test(arg)) ||
(subcommand === 'grep' && options.some((arg) => arg.startsWith('-O')))
)
return false;
if (options.some((arg) => /^(?:--help|--version)$/i.test(arg))) return false;
if (subcommand === 'remote') {
return evaluateGitRemoteArgs(args);
@ -286,6 +250,9 @@ function evaluateGitCommand(tokens: string[]): boolean {
return evaluateGitBranchArgs(args);
}
if (['blame', 'diff', 'log', 'show'].includes(subcommand)) {
return !options.some((arg) => /^--output(?:=|$)/.test(arg));
}
return true;
}
@ -300,7 +267,7 @@ function evaluateShellSegment(segment: string): boolean {
// `stripShellWrapper`, leaving a substitution-free `echo ok` that
// this fallback would then classify as read-only. Checking the raw
// segment first keeps the regex-fallback path in lockstep with the
// AST path (`evaluateStatementReadOnly`) and the L3 gates added in
// AST classifier and the L3 gates added in
// PR #4386 R6 (cid 3298521039).
if (detectCommandSubstitution(segment)) {
return false;
@ -310,6 +277,7 @@ function evaluateShellSegment(segment: string): boolean {
if (!stripped) {
return true;
}
if (stripped !== segment.trim()) return false;
if (detectCommandSubstitution(stripped)) {
return false;
@ -328,8 +296,17 @@ function evaluateShellSegment(segment: string): boolean {
if (!root) {
return true;
}
if (root !== tokens[0]) return false;
const normalizedRoot = root.toLowerCase();
if (root !== normalizedRoot) return false;
if (
/^(awk|find|git|sed)$/.test(normalizedRoot) &&
args.some(
(arg) => !arg || arg.includes('\0') || hasShellBraceExpansion(arg),
)
)
return false;
if (!READ_ONLY_ROOT_COMMANDS.has(normalizedRoot)) {
return false;
}
@ -362,6 +339,12 @@ export function isShellCommandReadOnly(command: string): boolean {
if (typeof command !== 'string' || !command.trim()) {
return false;
}
if (MALFORMED_CONTROL_OPERATOR.test(command)) return false;
if (
/[({;&|]\s*[A-Za-z_][A-Za-z0-9_]*=/.test(command) ||
/^[A-Za-z_][A-Za-z0-9_]*=.*[;&|]/s.test(command)
)
return false;
const segments = splitCommands(command);

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/mobile-mcp",
"version": "0.19.11",
"version": "0.19.12",
"description": "Mobile MCP with opt-in relative coordinate support",
"repository": {
"type": "git",

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,7 @@
"name": "qwen-code-vscode-ide-companion",
"displayName": "Qwen Code Companion",
"description": "Enable Qwen Code with direct access to your VS Code workspace.",
"version": "0.19.11",
"version": "0.19.12",
"publisher": "qwenlm",
"icon": "assets/icon.png",
"repository": {
@ -264,8 +264,7 @@
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
"package": "vsce package --no-dependencies",
"test": "vitest run",
"test:ci": "vitest run --coverage",
"validate:notices": "node ./scripts/validate-notices.js"
"test:ci": "vitest run --coverage"
},
"devDependencies": {
"@types/cors": "^2.8.19",

View file

@ -43,24 +43,10 @@ async function getDependencyLicense(depName, depVersion, resolvedKey) {
repositoryUrl = depPackageJson.repository?.url || repositoryUrl;
const packageDir = path.dirname(depPackageJsonPath);
const licenseFileCandidates = [
const licenseFile = await findLicenseFile(
packageDir,
depPackageJson.licenseFile,
'LICENSE',
'LICENSE.md',
'LICENSE.txt',
'LICENSE-MIT.txt',
'license.md',
'license',
].filter(Boolean);
let licenseFile;
for (const candidate of licenseFileCandidates) {
const potentialFile = path.join(packageDir, candidate);
if (await fs.stat(potentialFile).catch(() => false)) {
licenseFile = potentialFile;
break;
}
}
);
if (licenseFile) {
try {
@ -87,6 +73,50 @@ async function getDependencyLicense(depName, depVersion, resolvedKey) {
};
}
/**
* Resolve a dependency's license file case-insensitively. The default macOS
* filesystem is case-insensitive while Linux (CI) is case-sensitive, so a
* fixed-case candidate list finds a `License` file on macOS but misses it on
* Linux, making the generated notices platform-dependent. Scan the directory
* and compare lowercased names so the result is identical on both.
*
* @param {string} packageDir - Directory containing the dependency's package.json
* @param {string} [licenseFileHint] - License file name declared in package.json, if any
* @returns {Promise<string | undefined>} Absolute path to the license file, or undefined
*/
export async function findLicenseFile(packageDir, licenseFileHint) {
const candidates = [
licenseFileHint,
'LICENSE',
'LICENSE.md',
'LICENSE.txt',
'LICENSE-MIT.txt',
'LICENSE-MIT',
'LICENCE.md',
'license.md',
'license',
]
.filter(Boolean)
.map((candidate) => candidate.toLowerCase());
const dirEntries = await fs.readdir(packageDir).catch(() => []);
const entriesByLowerName = new Map();
for (const entry of dirEntries) {
const lower = entry.toLowerCase();
if (!entriesByLowerName.has(lower)) {
entriesByLowerName.set(lower, entry);
}
}
for (const candidate of candidates) {
const match = entriesByLowerName.get(candidate);
if (match) {
return path.join(packageDir, match);
}
}
return undefined;
}
/**
* Resolve a package in the lockfile by walking up the node_modules chain,
* mirroring Node.js module resolution algorithm.
@ -235,4 +265,11 @@ async function main() {
}
}
main().catch(console.error);
// Only run when executed directly (e.g. `npm run generate:notices`), not when
// imported by tests.
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
main().catch(console.error);
}

View file

@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { findLicenseFile } from './generate-notices.js';
describe('findLicenseFile', () => {
let packageDir;
beforeEach(async () => {
packageDir = await fs.mkdtemp(path.join(os.tmpdir(), 'notices-test-'));
});
afterEach(async () => {
await fs.rm(packageDir, { recursive: true, force: true });
});
// Regression guard: the Linux CI drift check runs generation and comparison
// on the same case-sensitive filesystem, so a revert to case-sensitive
// matching would produce consistent-but-wrong output and pass the check.
// This asserts the lookup resolves a mixed-case file regardless of platform.
it('resolves a mixed-case license file', async () => {
await fs.writeFile(path.join(packageDir, 'License'), 'MIT');
const resolved = await findLicenseFile(packageDir);
expect(resolved).toBe(path.join(packageDir, 'License'));
});
it('prefers LICENSE over other variants', async () => {
await fs.writeFile(path.join(packageDir, 'LICENSE'), 'Apache-2.0');
await fs.writeFile(path.join(packageDir, 'LICENSE.md'), 'MIT');
const resolved = await findLicenseFile(packageDir);
expect(resolved).toBe(path.join(packageDir, 'LICENSE'));
});
it('honors the package.json licenseFile hint', async () => {
await fs.writeFile(path.join(packageDir, 'COPYING'), 'GPL');
const resolved = await findLicenseFile(packageDir, 'COPYING');
expect(resolved).toBe(path.join(packageDir, 'COPYING'));
});
it('returns undefined when no license file exists', async () => {
const resolved = await findLicenseFile(packageDir);
expect(resolved).toBeUndefined();
});
});

View file

@ -13,7 +13,11 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
'scripts/**/*.test.js',
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'clover'],

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/web-shell",
"version": "0.19.11",
"version": "0.19.12",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/web-templates",
"version": "0.19.11",
"version": "0.19.12",
"description": "Web templates bundled as embeddable JS/CSS strings",
"repository": {
"type": "git",

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/webui",
"version": "0.19.11",
"version": "0.19.12",
"description": "Shared UI components for Qwen Code packages",
"type": "module",
"main": "./dist/index.cjs",