feat(ipc): hold peer messages across review classes, let repositories only tighten inbound settings - #11026
feat(ipc): hold peer messages across review classes, let repositories only tighten inbound settings#11026qqqys wants to merge 2 commits into
Conversation
… only tighten inbound settings Auto-deliver only between sessions of the same review class, hold frames that assert no mode, and move the two agents.* keys from restricted to tighten-only so a repository can refuse peers but never open a session. The hold notice names the scope that holds the message.
|
Thanks for the PR — this one is unusually well documented, and the design doc plus the before/after evidence made it easy to follow. Template ✓ — every required heading is present, Problem: real, not theoretical — and the strongest evidence for it is already in Direction: sound. Both halves move fail-closed: the gate holds strictly more than it did, and a repository gains the ability to tighten but never to loosen. It sits inside the multi-agent safety work the roadmap already carries, and it follows #10925 parts 1–2 with part 3 explicitly deferred rather than smuggled in. One honest flag: this is a Size: Stage 0 applies — this touches Approach: the gate half is genuinely minimal — delete the shortcut, compare classes, export Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk paths. The behaviour change is user-visible by design and the PR names its cost plainly (one Moving on to code review. 🔍 中文说明感谢贡献!这个 PR 的文档质量相当高,设计文档加上 before/after 证据让它很容易看懂。 模板 ✓ —— 所有必需标题齐全, 问题: 真实存在,不是理论性加固 —— 最有力的证据已经在 方向: 合理。两半都是 fail-closed 方向移动:网关比原来留置更多,仓库获得收紧能力但永远无法放宽。它属于路线图已经在推进的多智能体安全工作,并且遵循 #10925 的第 1、2 部分,第 3 部分明确推迟而不是夹带进来。一个诚实的提醒:这是一个触及进程间信任边界的 规模: Stage 0 适用 —— 触及 方案: 网关那一半确实是最小改动 —— 删掉捷径、比较类别、导出 风险: 无升级风险信号 —— 改动的文件都不匹配与回滚相关的高风险路径。行为变化是设计上就用户可见的,PR 也直接说明了代价(每条不匹配消息一次 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
Code reviewI read the diff against the base tree rather than just the hunks, and traced every consumer of the symbols this PR adds or removes. One blocking problem, and it is not in the TypeScript. 🚫 Blocker: unresolved merge-conflict markers shipped in user docs
The same file at
No CI check catches this — the markdown is not linted for conflict markers, and What I verified as correctThe gate change is small and I could not break it:
Non-blocking
The flow under reviewsequenceDiagram
participant P1 as send_message tool
participant P2 as peer frame over UDS
participant P3 as InboundGate
participant P4 as held buffer
participant P5 as receiver UI
P1->>P1: read approval mode, null when the getter throws
P1->>P2: fromMode is modeClass of the mode, omitted when null
P2->>P3: admit frame with origin from the transport
P3->>P3: explicit setting wins, scope only decorates the cause
P3->>P3: own process via child token still accepts
P3->>P3: unknown receiver mode still holds
P3->>P3: compare sender class with modeClass of receiver mode
alt same class
P3->>P5: deliver
else different class, or sender asserted nothing
P3->>P4: park with cause and policyScope
P4->>P5: hold notice and /peers name the cause and the scope
end
P5->>P3: reevaluate when the approval mode changes
P3->>P4: classes now agree, release with no manual accept
Test evidenceThis is an unattended CI run, so per the gate's rules I did not build, run, or execute anything from this branch — no At the time of writing there are 0 failed checks on Worth saying plainly: no check in this table would catch the conflict markers above. Markdown is not linted for VCS markers, so a green run here does not mean the docs are shippable. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 Sandboxed verification would settle the part I cannot: Real-scenario testing: N/A for this run — unattended CI never drives the product, and I will not substitute the PR description's transcripts for a capture of my own. 中文说明代码审查我把 diff 对照 base 树完整读了一遍,而不只看 hunk,并追踪了这个 PR 新增或删除的每个符号的所有使用方。有一个阻塞问题,而且它不在 TypeScript 里。 🚫 阻塞项:用户文档中残留未解决的合并冲突标记
同一文件在
没有任何 CI 检查会捕获这一点 —— markdown 不做冲突标记检查, 我验证为正确的部分网关改动很小,我没能把它弄坏:
非阻塞
测试证据这是一次无人值守的 CI 运行,因此按照关卡规则我没有构建、运行或执行本分支的任何东西 —— 没有 撰写本文时, 需要直说:这张表里没有任何检查会捕获上面的冲突标记。 Markdown 不做版本控制标记检查,所以这里全绿并不意味着文档可以发布。 沙箱化验证可以解决我无法解决的那部分: 真实场景测试:本次运行 N/A —— 无人值守 CI 从不驱动产品,我也不会用 PR 描述里的记录来替代我自己的采集。 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 2/5 — the TypeScript is good and I could not break it, but the branch ships an unresolved rebase conflict in user-facing docs for a security-relevant setting, and that is not a nit. Stepping back: the engineering here is well above the bar. The gate change is the minimal version of itself, the fail-closed reasoning is consistent at every layer I checked (wire parse, strictness ranking, throwing getters, stale scope on re-evaluation), and the tests would actually fail if someone reverted the production change — which is the property most PRs in this area only claim. The design doc is committed rather than pasted into the PR body, and it names its own trade-offs including the one I would have pushed on. So this is not a "rethink the approach" request. It is one concrete defect and one question. The defect is the conflict markers in The question is about ownership, not code. This changes who a session will listen to and what a repository may decide about that, which is a security-policy call, and #10925 is your own issue and still open. I think the direction is right — both halves move fail-closed, and the send-side comment in Requesting changes on the strength of the docs conflict alone. 🙏 中文说明信心:2/5 —— TypeScript 部分很好,我没能把它弄坏,但分支在面向用户的文档里带进了一个未解决的 rebase 冲突,而且是一个安全相关设置的文档,这不是小问题。 退一步看:这里的工程质量高于门槛。网关改动是它自己的最小版本,fail-closed 的推理在我检查的每一层都一致(线上解析、严格度排序、抛异常的 getter、重新评估时的过期作用域),而且如果有人回滚生产代码改动,测试确实会失败 —— 这是这个领域大多数 PR 只是声称具备的性质。设计文档是提交进仓库的而不是粘贴在 PR 正文里,并且它自己点名了取舍,包括我本来会质疑的那一条。 所以这不是一个"重新考虑方案"的请求。它是一个具体缺陷加一个问题。 缺陷是 问题是关于归属,不是关于代码。这改变了会话愿意听谁的,以及仓库对此能决定什么,这是一个安全策略判断,而 #10925 是你自己的 issue 且仍然 open。我认为方向是对的 —— 两半都朝 fail-closed 移动,而且 仅凭文档冲突这一项请求修改。 🙏 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
@qqqys Requesting changes on one concrete blocker — see the stage 2 and stage 3 notes above for the detail. 🙏
docs/users/features/commands.md ships an unresolved rebase conflict at the reviewed commit (markers at lines 827, 852, 873; the same file on main has none). The page therefore renders literal VCS markers and documents both the old and the new gate rule at once — and the old one is exactly the behaviour this PR removes, so the docs for a security-relevant setting are wrong on the point the change turns on.
Please resolve it as both sides, not "take theirs": keep the new class-parity and repository-tightening paragraphs, then re-append the three paragraphs that exist only on the HEAD side (agents.crossSessionHeldExpiry expiry, the inbox-bind-failure path, and the duplicate-inbox-address behaviour). Taking only the PR side would silently delete documentation added by the reachability and receipt work this PR builds on. Worth grepping the branch for leftover markers before re-pushing, and re-confirming the tmux evidence at the new head since the branch was rebased after it was captured.
No CI check catches this, so it would have merged green.
The TypeScript itself needs nothing further from my side — the gate change is minimal, fails closed at every layer I traced, and the tests genuinely pin it. The remaining open question is a policy one for a maintainer: this decides who a session will listen to, and #10925 is still open.
中文说明
@qqqys 因一个具体阻塞项请求修改 —— 详见上面的 stage 2 与 stage 3 说明。🙏
docs/users/features/commands.md 在被审查的 commit 上带着一个未解决的 rebase 冲突(标记位于第 827、852、873 行;同一文件在 main 上一个都没有)。因此该页面会渲染出字面的版本控制标记,并且同时记录了旧的和新的网关规则 —— 而旧的那条正是本 PR 要移除的行为,所以一个安全相关设置的文档在这个改动所围绕的关键点上是错的。
请把它解成两侧都保留,而不是"取 theirs":保留新的类别对等段落和仓库收紧段落,然后重新接回只存在于 HEAD 一侧的三段(agents.crossSessionHeldExpiry 过期、inbox 绑定失败路径、inbox 地址重复行为)。只取 PR 那一侧会静默删掉本 PR 所依托的可达性与回执工作添加的文档。重新推送前值得 grep 一下分支里是否还有残留标记,并在新 head 上重新确认 tmux 证据,因为分支在那次采集之后经历过 rebase。
没有任何 CI 检查会捕获这一点,所以它本来会以全绿状态被合并。
TypeScript 本身我这边不需要再改什么 —— 网关改动是最小的,在我追踪的每一层都 fail closed,测试也确实固定住了它。剩下的开放问题是给 maintainer 的策略判断:这决定了会话愿意听谁的,而 #10925 仍然 open。
🩺 serve daemon A/BBuilt the PR base vs this PR head ✅ No response changes against the PR base across 12 scenario(s). — Qwen Code · serve A/B |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.
— qwen3.8-max via Qwen Code /review (v0.23.0)
What this PR does
With no explicit
agents.crossSessionInbound, the inbound gate decides from approval-mode parity, and one row of that table let a receiver that reviews every action accept anything at all: a bypassing sender, or a sender that asserted no mode at all. This PR replaces the table with one rule, lets a repository make its sessions more cautious but never less, and makes a hold notice say which scope is holding the message.One rule. Every approval mode is in one of two review classes,
prompting(default, plan) orbypass(auto-edit, auto, yolo), and a message auto-delivers only between sessions of the same class. A prompting receiver now holds messages from a bypassing sender, and every receiver holds a message whose sender asserted no class. The classification is a single exported function that the send side uses too, so two sessions in the same mode always agree on their class. A message held on a class mismatch is still released the moment the receiver's mode moves into the sender's class, without the user approving it by hand. The explicit setting wins in both directions, own-process frames are still accepted, and the wire protocol does not change.A repository may only tighten. Both
agents.*keys used to sit in the restricted list, so a workspace value was dropped whole, with a warning that read as a bug. They move to a new tighten-only list: at merge time a trusted workspace's value is kept only when it is strictly stricter than what User or SystemDefaults set (accept< unset <hold<refuse;true<false= unset), or than the feature's default when neither does. Equal is dropped silently. Looser is dropped with a warning that names the scope it lost to. System still overrides outright. An unrecognized workspace value ranks strictest, so it is kept and reaches the readers, which already fail closed: the gate holds on it and the switch is off for anything buttrue. The serve routes' workspace write guard refuses the loosest value of such a key up front rather than persisting an entry the merge would always drop.The hold cause names the scope. The gate takes an optional scope getter beside the policy getter; the CLI answers it from the loaded settings. A held message carries the scope for the two setting-driven causes, and the notice and
/peersword it as "this repository's settings hold messages from other sessions" or "a system setting holds …" instead of "your setting". The scope is decoration: a throwing scope getter is ignored, never turned into a verdict.The trusted-controller layer proposed in the same issue is deliberately not here; it is a separate change.
Why it's needed
The per-action backstop guards single actions. It does not guard what the session is being talked into. A message from a session that runs unreviewed is model-authored input that no person has looked at; delivered straight into a session whose user chose "review everything", it can steer that user's model one benign-looking step at a time, and the per-action prompts are exactly the surface that fatigue turns into rubber stamps. The user chose a mode in which they see everything, and that choice did not include hearing from agents nobody is watching. The old row also made the table hard to hold in one's head: five rows, with the receiver's class deciding whether the sender's class mattered at all.
Dropping a workspace value in the loosening direction is right — a cloned repository must not open the user's session to peers or force
accept— but droppingrefuseandfalseas well took away the one direction a repository has a reason to set: a monorepo whose automation agents must not be able to reach a person's session, say. And a workspace value the gate could not read was stripped before the merge, so the user's looser value took its place and the unreadable value never reached the code that fails closed on it.Reviewer Test Plan
How to verify
Unit level:
cd packages/core && npx vitest run src/ipc/inbound-gate.test.ts src/ipc/peer-send.test.ts src/ipc/peer-frames.test.ts src/ipc/uds-inbox.test.ts(261 tests; the gate suite now includes the full ten-row receiver-class × sender-class table, the unasserted sender held for every mode, release on a class change, the scope carried on held entries and dropped when the setting goes away, a throwing scope getter ignored, and the scope-aware wording).cd packages/cli && npx vitest run src/config/settings.test.ts src/config/settingsUtils.test.ts src/peerMessaging src/ui/commands/peers-command.test.ts src/serve/routes/workspace-settings.test.ts src/ui/startInteractiveUI.test.tsx src/ui/AppContainer.test.tsx(589 tests, including a loosening workspace value dropped with its warning, a tightening one honored, a repeat dropped silently, System overriding with its own warning, an unrecognized value kept, an untrusted workspace dropped, which scope the merged value came from, and an end-to-end hold of a bypassing sender over a real socket that releases when the receiver switches class).tsc --buildon the cli package is clean, as areeslintandprettier --checkon the changed files.End to end, with
{ "agents": { "crossSessionMessaging": true } }on both sides:crossSessionInbound. A sender that assertsbypassis held; the notice says the two are in different review modes;/peerslists it with that cause; the sender gets aheldreceipt./peers accept, and the sender gets adeliveredreceipt after itsheldone.promptingis delivered straight away.crossSessionInbound: "accept", workspace"refuse": no warning at startup, the sender getsrefused, nothing is held or delivered."hold", workspace"accept": a startup warning says the workspace value would loosen the User value, and the message is held with a notice naming your setting."hold": held, and the notice and/peerssay the repository's settings hold messages from other sessions."accept", workspace"maybe": held with "could not be read … workspace settings", no loosening warning.Evidence (Before & After)
Before: a receiver in default mode delivered a frame from a yolo session, or one with no
fromMode, straight to its model. A workspacecrossSessionInbound: "refuse"was dropped withWarning: … is ignored because it is restricted in workspace settings, and a workspace"maybe"was dropped the same way, leaving the user's"accept"in force.After — Linux, bundled build of this branch rebased onto current
main, under tmux with an isolatedQWEN_HOME, dummy OpenAI-compatible provider. The sender is a small script that binds a socket, sends one frame with thefromModethe step names, and prints the receipts that come back over the same wire a real session uses.1–2. Prompting receiver, bypassing sender; release on class switch
Receiver:
A second bypassing sender stayed connected; the receiver ran
/approval-mode yolo. Both held messages were delivered with no/peers accept, and the live sender saw:3–4. Unasserted sender held, prompting sender delivered
5. Workspace refuse over user accept
No warning at startup.
6. Workspace accept under user hold is ignored
Startup:
7. Workspace hold names the repository
No warning at startup.
8. Unrecognized workspace value fails closed
No loosening warning at startup.
Tested on
Windows is N/A because the inbox is POSIX-only today; macOS runs the same code path but was not exercised locally.
Environment (optional)
Bundled build of this branch, run under tmux with a dummy OpenAI-compatible provider.
Risk & Scope
/peers acceptper message, or switching the receiver into the sender's class, which releases the backlog. Two sessions a user runs in the same mode notice nothing. Messages from senders that assert no class are held for prompting receivers too, which affects hand-written scripts that reach a session over its published token rather than its child token; such a script can assertfromModehonestly, and the child-token path is unaffected. Ranking unset for the inbound key means a workspaceholdis honored when the user set nothing, because parity delivers some messages andholddelivers none; a workspace that wants exactly the parity default has no value to write for it, and unset is that value.fromMode's vocabulary, receipts, and every explicit setting path are unchanged. A workspacecrossSessionInboundofholdorrefuse, orcrossSessionMessaging: false, that was previously dropped is now honored; a workspace value that would loosen the user's is still dropped, with a warning that now says why.Linked Issues
Implements the first two parts of #10925 (the third part is a separate change). Continues #10800 and #10809. Related to #10118, whose trusted-tier dependency this line answers.
中文说明
本 PR 做了什么
在没有显式
agents.crossSessionInbound的情况下,入站闸门按审批模式的对等关系判定,而那张表里有一行让"逐项审阅每个动作"的接收方接受任何东西:绕过审阅的发送方,或者根本没有声明模式的发送方。本 PR 把那张表换成一条规则,让仓库可以让它的会话更谨慎但绝不能更宽松,并让留置提示说明是哪个作用域在留置这条消息。一条规则。 每种审批模式属于两个审阅类别之一:
prompting(default、plan)或bypass(auto-edit、auto、yolo),消息只在同一类别的会话之间自动投递。逐项审阅的接收方现在会留置来自绕过类发送方的消息,而所有接收方都会留置没有声明类别的消息。分类是一个导出的函数,发送端也用它,所以处于同一模式的两个会话对自己的类别永远一致。因类别不匹配被留置的消息,在接收方的模式切换到发送方的类别时仍会立即释放,无需用户手动批准。显式设置在两个方向上都优先,本进程的帧仍被接受,线协议不变。仓库只能收紧。 两个
agents.*键此前都在受限列表中,工作区的值会被整个丢弃,警告读起来像是个 bug。它们移到一个新的"仅收紧"列表:合并时,受信工作区的值只有在严格严于 User 或 SystemDefaults 所设的值(accept< 未设置 <hold<refuse;true<false= 未设置)、或两者都未设置时严于功能默认值,才会被保留。相等的静默丢弃。更宽松的丢弃并给出警告,点名它输给了哪个作用域。System 仍然直接覆盖。无法识别的工作区值排在最严,因此会被保留并到达读取方,而读取方本就 fail-closed:闸门据此留置,开关对true以外的任何值都视为关闭。serve 路由的工作区写入守卫会在前端就拒绝这类键的最宽松值,而不是持久化一条合并时总会被丢弃的条目。留置原因点名作用域。 闸门在策略读取器旁增加一个可选的作用域读取器;CLI 从已加载的设置中回答它。被留置的消息对两种由设置驱动的原因携带作用域,提示与
/peers的措辞变为"this repository's settings hold messages from other sessions"或"a system setting holds …",而不是"your setting"。作用域只是修饰:作用域读取器抛错会被忽略,绝不会变成判定。同一 issue 中提出的受信 controller 层刻意不在本 PR 中,它是单独的改动。
为什么需要
逐项审阅的后盾守的是单个动作,守不住会话正在被说服去做什么。来自无人审阅会话的消息是没有任何人看过的、由模型撰写的输入;直接投递到一个用户选择了"审阅一切"的会话里,它可以一步一步、每步看起来都无害地引导那个用户的模型,而逐项审阅的提示正是审阅疲劳变成盖章的地方。用户选择了一种能看到一切的模式,这个选择并不包括听取没人盯着的 agent 的话。旧的那一行还让表难以记住:五行,且由接收方的类别决定发送方的类别是否重要。
在放宽方向上丢弃工作区的值是对的——克隆下来的仓库不能把用户的会话向对等方打开,或强制
accept——但连refuse和false也一起丢掉,就拿走了仓库唯一有理由设置的方向:比如一个 monorepo 的自动化 agent 不得触达人的会话。而闸门无法读取的工作区值在合并前就被剥掉,用户更宽松的值取而代之,那个无法读取的值从未到达本会 fail-closed 的代码。审阅者测试计划
如何验证
单元层面:
cd packages/core && npx vitest run src/ipc/inbound-gate.test.ts src/ipc/peer-send.test.ts src/ipc/peer-frames.test.ts src/ipc/uds-inbox.test.ts(261 个测试;闸门套件现在包含完整的十行"接收方类别 × 发送方类别"表、未声明的发送方在每种模式下都被留置、类别切换时释放、留置条目携带作用域并在设置消失时丢弃、抛错的作用域读取器被忽略、以及按作用域的措辞)。cd packages/cli && npx vitest run src/config/settings.test.ts src/config/settingsUtils.test.ts src/peerMessaging src/ui/commands/peers-command.test.ts src/serve/routes/workspace-settings.test.ts src/ui/startInteractiveUI.test.tsx src/ui/AppContainer.test.tsx(589 个测试,包括放宽的工作区值被丢弃并给出警告、收紧的被采纳、重复的静默丢弃、System 覆盖并给出自己的警告、无法识别的值被保留、不受信工作区被丢弃、合并值来自哪个作用域、以及一条经真实 socket 的端到端用例:绕过类发送方被留置,接收方切换类别后释放)。cli 包的tsc --build干净,改动文件的eslint和prettier --check也干净。端到端,双方都开启
{ "agents": { "crossSessionMessaging": true } }:crossSessionInbound。声明bypass的发送方被留置;提示说两者处于不同的审阅模式;/peers以该原因列出它;发送方收到held回执。/peers accept即投递,发送方在held之后收到delivered回执。prompting的发送方立即投递。crossSessionInbound: "accept",工作区"refuse":启动无警告,发送方收到refused,无留置无投递。"hold",工作区"accept":启动警告说工作区的值会放宽 User 的值,消息被留置且提示点名的是你的设置。"hold":被留置,提示和/peers说是仓库的设置在留置来自其他会话的消息。"accept",工作区"maybe":被留置,原因为"could not be read … workspace settings",无放宽警告。证据(之前与之后)
之前:default 模式的接收方会把来自 yolo 会话的帧、或没有
fromMode的帧直接投递给它的模型。工作区的crossSessionInbound: "refuse"被丢弃并提示Warning: … is ignored because it is restricted in workspace settings,工作区的"maybe"也以同样方式被丢弃,留下用户的"accept"生效。之后——Linux,本分支 rebase 到当前
main后的打包构建,在 tmux 下使用隔离的QWEN_HOME与 dummy 的 OpenAI 兼容 provider。发送方是一个小脚本:绑定一个 socket、按步骤指定的fromMode发一帧、打印回来的回执,走的是与真实会话相同的线协议。英文部分的折叠块是八个步骤的实机记录。测试平台
见英文表格:Linux 已测;inbox 目前仅支持 POSIX,因此 Windows 为 N/A;macOS 走同一条代码路径但本地未实测。
运行环境(可选)
本分支的打包构建,在 tmux 下配合一个 dummy 的 OpenAI 兼容 provider 运行。
风险与范围
/peers accept,或者把接收方切换到发送方的类别以释放积压。用户以同一模式运行的两个会话不会察觉任何变化。未声明类别的发送方的消息对逐项审阅的接收方也会被留置,这会影响通过已发布令牌(而非子令牌)触达会话的手写脚本;这类脚本可以如实声明fromMode,子令牌路径不受影响。对入站键把"未设置"纳入排序意味着用户什么都没设时工作区的hold会被采纳,因为对等判定会投递一部分消息而hold一条都不投递;想要恰好等于对等默认值的工作区没有可写的值,未设置就是那个值。fromMode的词汇、回执以及所有显式设置路径均未改变。此前被丢弃的工作区crossSessionInbound为hold或refuse、或crossSessionMessaging: false,现在会被采纳;会放宽用户值的工作区值仍被丢弃,警告现在会说明原因。关联 Issue
实现 #10925 的前两部分(第三部分是单独的改动)。接续 #10800 与 #10809。与 #10118 相关,本线回答其受信分层的依赖。