Skip to content

feat(acp-bridge): session/new 透传 toolAllowlist/toolDenylist - #53

Merged
tizerluo merged 3 commits into
tizerluo:mainfrom
jasonQin6:feat/acp-session-toollists
Oct 3, 2026
Merged

tizerluo merged 3 commits into
tizerluo:mainfrom
jasonQin6:feat/acp-session-toollists

Conversation

@jasonQin6

Copy link
Copy Markdown
Contributor

基于 #52(session/new model 钉定)之上的增量提交,只评审最后一个 commit(20fa2b0)。

动机

0.16 stdio 下 mode=plan 是 advisory——工具照常自动执行,调用方只能在事后(快照 diff)发现越界。会话级前置只读目前没有手段:引擎 session/create 的 toolAllowlist/toolDenylist 映射 PermissionService 的硬 deny/allow,不经过权限模式判断、hook 也不能覆盖,正是这个缺口需要的原语。

变更

session/new 新增可选 toolAllowlist / toolDenylist(非空字符串数组,空条目剔除)原样并入 session/create 参数。缺省、空数组或非数组时 create 参数与旧版逐字一致(向后兼容:不传名单的调用方行为不变;传了名单而后端拒收未知字段时,create 的报错如实透出)。

测试

  • 新增回归用例:C5(透传 + 空条目剔除)、C5a(缺省 / 空数组 / 非数组 → create 参数不变)
  • 全套 pytest tests/ -q → 549 passed + 34 subtests

… 实测)

问题 (0.16.1-0.16.9 实测):
1. setModel 透传形态与 0.16 后端 schema 不匹配: 桥发 {sessionId, modelId}
   字符串, 后端要 model 对象 → 必报 -32602 "expected object, received
   undefined; Unrecognized key: modelId"。即 0.16 时代 setModel 从未真正
   可用, ACP client 无法切换会话模型。
2. headless 场景模型解析跟随 personal provider registry / App「上次使用」,
   client 无法按会话指定模型: App 里选了什么, spawn 出的引擎就默认用什么
   (provider 目录不含 config.json 静态 provider, env 三件套在 personal
   registry 存在时被无视)。

修复/新增:
- setModel: 改为 {sessionId, model} 原样透传 ModelSelection 对象
  {providerId, modelId, options?: {reasoningLevel}}。旧 modelId 形态移除
  (在 0.16 后端从未通过 schema, 保留只会让调用方误以为已生效)。
- session/new 新增可选 model:
  * 字符串形态 ("GLM-5.3"): 按 create 快照 settings.model.available 的
    catalog 解析, 补 reasoningLevel=reasoning.defaultLevel;
  * 对象形态: 原样透传;
  * create 成功后经 session/setModel 应用, 失败则 session/new 整体报错。
  刻意不透传给 create 本身: 0.16.9 实测 create 的初始 model 形参接受对象
  但丢 options, turn 阶段报 ModelProtocolError "Reasoning level is
  required for <provider>/<model>"; setModel 路径无此问题。

实测 (ZCode App 3.14.0 / CLI 0.16.9, personal provider bigmodel-api):
- session/new {model: "GLM-5.3"} → session/prompt → stopReason=end_turn;
- setModel {model: {providerId, modelId, options:{reasoningLevel:max}}} →
  ok → prompt → end_turn;
- setModel 缺 options (无 reasoning 的模型除外) → turn 失败, 与 catalog
  defaultLevel 补齐后通过。

测试: M5 改写 (对象透传/缺参 -32602/旧形态拒绝) + C4 系列 7 例 (字符串
catalog 解析/无 reasoning 条目/对象透传/未知 modelId/setModel 失败连带
new 失败/不带 model 向后兼容/非法形态)。547 passed + 34 subtests。
@tizerluo

tizerluo commented Oct 3, 2026

Copy link
Copy Markdown
Owner

感谢这个增量——动机踩得很准(plan 模式是 advisory 的痛点本仓在 README「重要限制」里也有记录)。结论:修后可合并(两条 P1 是同一处校验语义的正面缺口,改动量小)。以下是维护者侧独立审查;独立代码审查 agent 的意见见下一评论。

核心声明活体验证(本机 0.16.9 官方 app-server)

声明 验证 结果
create 接受 toolDenylist 活体 session/create 带 toolDenylist:["Write","Edit"] ✅ 接受,会话正常创建
allow+deny 同传 活体同传两键 ✅ 后端不拒(优先级见下)
测试声明 本地 worktree 实跑(含叠层的 #52) ✅ 549 passed,ruff 干净
与 main 兼容 GitHub mergeability ✅ MERGEABLE(基于 #52)

意见

应修(合并前):

  1. 非法输入静默忽略 = 对安全原语 fail-open(与下一评论 P1-1 一致,我读 diff 时的同款担忧):"toolDenylist": "Write"(常见手滑:字符串而非数组)会被静默跳过、不报错无日志——调用方以为只读已生效,实际全量放行。C5a 还把这个行为钉成了契约。旧客户端从不发这两个键,改成「键存在但值非法 → -32602」不会破坏任何现有调用方;且同一作者在 feat(acp-bridge): session/new 可选 model 锁定会话模型 + setModel 对象形态修复 (0.16 实测) #52 的 setModel 就是严格校验风格,两 PR 应一致。元素校验同理:非字符串元素([123]/[null])当前被 str() 强转成无效名后静默不生效。
  2. 空名单语义需要定契约:当前实现把空数组/全空条目当「未提供」丢弃。而独立审查从引擎 bundle 找到的语义是:空 allowlist = 一个工具都不放行(deny-all)、空 denylist 才是无限制。也就是说显式请求「锁死」的调用方(toolAllowlist: [])拿到的是全量访问——方向恰好反了。建议:键存在即原样透传 [](保留引擎 deny-all 语义,也符合 PR 标题「透传」),或显式 -32602 拒绝空名单,并加 C5b 钉死所选语义。

建议:

  1. README/SKILL.md 未同步(增量只改了桥和测试):session/new 的 README 扩展说明只有 feat(acp-bridge): session/new 可选 model 锁定会话模型 + setModel 对象形态修复 (0.16 实测) #52 的 model;且 README「重要限制」第 7 条(yolo 权限风险)正好应该交叉引用本 PR 的 toolDenylist 作为缓解手段——这是本 PR 对仓库安全叙事的真正价值,不写出来读者发现不了。
  2. 同传优先级写进文档:引擎语义是「交集、deny 优先」(内置与 MCP 两条注册路径一致)——桥不需要拒绝同传,但调用方不该靠猜。
  3. Node REPL 绕过提示:README 第 122 行记录过「只禁 Write/Edit/Bash 可被 Node REPL 族 execSync 打穿」。docstring 把 toolDenylist 称为「唯一会话级前置只读手段」时,应附建议名单(含 js/mcp__node_repl__js* 族),并请作者核实 ACP 会话的实际注册工具集。
  4. 机制归属小误:docstring 写「映射 PermissionService」,实际(bundle 证据)是工具集注册级物理过滤(先于权限层、与 mode 无关);PermissionService 是 settings.permission.* 驱动的另一条路径。行为断言对,心智模型建议改准。

合并顺序

本 PR 叠在 #52 上:先合 #52,本 PR rebase 后再走(若 #52 按审查意见修改,本 PR 需要跟着动 _on_session_new 区域)。

@tizerluo

tizerluo commented Oct 3, 2026

Copy link
Copy Markdown
Owner

独立代码审查(code-reviewer agent)意见——由维护者代发,与上一条维护者审查互不参考。

结论:修后可合并(无 P0;2 条 P1 校验语义 + 5 条 P2 文档/验证)

一、对作者声明的引擎侧核实(bundle 证据)

作者声明 核实结果
session/create 接受两名单且为严格 schema ✅ create 入参 zod schema 含 toolAllowlist/toolDenylist: array(string).optional(),整对象 .strict()——未知字段会被拒,「报错如实透出」在 0.16.9 成立
映射 PermissionService 硬 allow/deny 效果成立、归属不准确:实际路径是工具集级注册过滤(内置工具与 MCP 工具注册均按 allowedTools/disallowedTools 物理过滤,先于权限层、与 mode 无关);PermissionService 是 settings.permission.* 驱动的另一条路径,create 字段并不流入它
mode=plan 是 advisory ✅ 与仓库既有文档(upgrade-0.16.1-spec / README)记录一致
allow+deny 同传谁优先(作者未说明) 引擎有确定语义:交集、deny 优先(两条路径一致)——桥无需拒绝同传,但应写进文档
空数组语义(作者未说明) ⚠️ 与桥的处理相反:引擎对 toolAllowlist: [] 按 new Set([])(truthy)处理,过滤条件对所有工具成立——空 allowlist = 禁用全部工具;空 denylist 才等于无限制

二、问题清单

P1(应修)

  1. zcode-acp-bridge:1358-1364(测试同见 test_app_server_methods.py:446-459)— 输入校验对安全原语是 fail-open:① 非列表值(常见误写 "toolDenylist": "Write")被静默忽略,不报错无日志,调用方以为只读已生效实际全量放行,C5a 还钉死为契约;② str(item) 强转:[123]→"123"、[null]→"None",引擎只做字符串集合匹配,不认识的名字静默不生效(denylist 即无声放行);③ 只对 str(item).strip() 判空、存回未 strip 原串:" Write" 在引擎 allowlist 侧(不 trim)静默失效。为什么必须修:旧客户端从不发送这两个键,「键存在但值非法 → -32602」不破坏任何既有调用方,向后兼容论据不成立;同 PR 内 setModel(feat(acp-bridge): session/new 可选 model 锁定会话模型 + setModel 对象形态修复 (0.16 实测) #52)已是严格风格,此处不一致。修法:key not in params 才跳过;否则要求非空字符串数组(元素 isinstance str 且 strip 非空),非法即 -32602;C5a 字符串用例改期望;docstring 补齐契约。
  2. zcode-acp-bridge:1360-1363 — 显式空名单的语义反转:toolAllowlist: [](及 [""])被桥当作「未提供」,create 参数里没有该键——而引擎把「存在空 allowlist」解释为一个工具都不放行。显式请求锁死的调用方拿到的是全量工具访问,只有一行未打印的 log 作信号。修法:键存在时原样透传 [](保住引擎 deny-all 语义,也符合「透传」标题),或产品上不接受空名单则显式 -32602;补 C5b 钉死。与 P1-1 可合并为一次契约修但语义不同,故分列。

P2(建议)

  1. 文档未同步:README 的 session/new 扩展说明只写了 feat(acp-bridge): session/new 可选 model 锁定会话模型 + setModel 对象形态修复 (0.16 实测) #52 的 model;README「重要限制 docs: 同步 skill 到 App 3.3.0 (补 prompt/enhance) #7」(yolo 权限风险)未指向本 PR 的 toolDenylist 缓解手段;SKILL.md 同步缺失。
  2. precedence 未文档化:引擎证据是交集/deny 优先——桥不需要拒绝同传,但 docstring/README 应写明(并按仓库惯例附实测出处)。
  3. 旧版 fail-open 前提未验证:「后端拒收未知字段」目前只有 0.16.9 的 .strict() 证据;仓库支持 0.16.1/0.16.5 且有「同号构建漂移」先例——若某构建既不认识字段又不 strict,名单会被静默丢弃且桥无从感知。建议按 docs/recheck-*.md 惯例补一次 0.16.1 活体验证,或在校验失败路径给出可诊断文案。
  4. Node REPL 绕过风险未提示:README 第 122 行记录过 Node REPL 族可打穿「只禁 Write/Edit/Bash」的名单;docstring 把 toolDenylist 称为「唯一会话级前置只读手段」时应附建议名单(含 js/mcp__node_repl__js*),并核实 ACP 会话的实际工具集(未作为已证漏洞,但需作者确认)。
  5. 测试覆盖:缺非字符串元素([null]/[123]/嵌套)、带空白字符串、全空条目列表 [""]、仅传单边名单;P1-2 契约定了以后补空数组用例;C5a 字符串用例在修法落地后改期望。

P3(提示)

  1. create 失败统一映射 -32603 "zcode create failed: ..."(既有行为,D7 有意钉死),后端因未知字段返的 -32602 丢码只留文案——调用方想程序化识别「后端不支持该字段」只能字符串匹配;可在 message 带后端原始 code。
  2. docstring「映射 PermissionService」与 bundle 证据不符,建议改准心智模型。
  3. str(item) 在列表推导里求值两次,纯风格,随 P1-1 重写消失。
  4. 流程:20fa2b0 父提交是 feat(acp-bridge): session/new 可选 model 锁定会话模型 + setModel 对象形态修复 (0.16 实测) #52 的 head,两 PR 改同一函数/测试区域——需先合 feat(acp-bridge): session/new 可选 model 锁定会话模型 + setModel 对象形态修复 (0.16 实测) #52 再 rebase 本 PR;feat(acp-bridge): session/new 可选 model 锁定会话模型 + setModel 对象形态修复 (0.16 实测) #52 若按评审修改则本 PR 跟着动。

未发现问题的维度:性能(O(n) 单遍归一);注入(工具名作数据下发,日志只记数量不泄内容);崩溃/边界(非 dict/缺键/空列表不抛异常);向后兼容的「缺省」分支经 C5a 与代码核实成立。

亮点:增量 commit 隔离干净;「缺省绝不改变旧行为」用 C5a 多形态断言钉死,超出常见测试质量;日志只输出条目数;对引擎行为的调研基本可被 bundle 证实(strict schema、工具集级硬过滤)——对首个外部 PR 而言证据意识很强。


给贡献者的一句话:这是一份调研扎实、测试自觉的高质量 PR——增量隔离清楚、兼容性有断言护住,感谢把引擎行为吃透后再动手;需要补齐的是把「名单是安全原语」贯彻到非法输入与空输入的处理(fail-closed 而非静默放行),并把 README/SKILL.md 与 precedence、Node REPL 提示一起补上,改完即可合。

tizerluo and others added 2 commits October 3, 2026 15:25
在 jasonQin6 PR tizerluo#52 基础上按 R1 审查清单修复, 保持原方案 (setModel 对象
形态 + session/new 可选 model) 不变, 只修审查指出的问题。

A (P1 孤儿会话):
- model 形状校验前移到 session/create 之前 — 非法形态本地 -32602 且不
  触碰后端, 不再先真实建会话再报错;
- create 后失败 (catalog 未命中/不可用、setModel 失败、setModel 超时)
  的错误文案带已创建的 sessionId (「已创建会话 <sid> 但 model 未生效:
  <原因>」) 供客户端收编; 后端无会话删除方法, 不尝试回收;
- session_map 注册后移到 model 应用成功之后, 失败会话不登记、不能被
  session/prompt 使用。消费方确认: session/prompt/session/cancel 用
  .get(acp_sid) 严格取, 扩展 handler 用 .get(acp_sid) or acp_sid 兜底;
  acp_sid 恒等于 zcode_sid 且登记先于响应返回, 移位对成功路径无行为差异;
- _on_session_new docstring 同步失败语义。

B (P3):
- 对象形态 providerId/modelId 须为非空字符串 (本地 -32602, 消除透传后端
  -32602 被 _passthrough_error 改写成 -32603 的不一致);
- catalog 条目/ref/reasoning 加 isinstance 守卫 (畸形条目不抛 AttributeError);
- 「catalog 不可用」与「未命中」文案区分;
- _passthrough_error 加 prefix 参数 + docstring 注记 session/new 嵌套
  setModel 是核心 handler 走本 helper 的例外场景;
- _on_session_set_model docstring 改写 (需要按 catalog 解析的调用方应在
  session/new 指定; 本方法要求调用方提供完整对象)。

C (文档):
- README 版本兼容性加注: setModel 对象形态仅 0.16.x 验证 (0.16.9 实测),
  ≤0.15 未验证;
- README/SKILL 的 session/new 说明: 「补默认 reasoningLevel」条件化为
  「catalog 条目带 reasoning.defaultLevel 时补」, 补 model:null 语义;
- agent-help key_methods: setModel 参数改对象形态 + 0.16 注记;
- docs/recheck-3.14.0.md 追加补遗 (旧形态 -32602 原文/新对象形态实测/
  create 快照 catalog 形态/空参探存活的方法论教训)。

D (测试 547→551):
- C4/C4a/C4b 补 sessionId 断言; C4d 补 setModel 文案 + 超时分支 + 失败
  不登记; C4e 锁定 create 参数逐字段不变;
- 新增 C4g (嵌套 setModel -32601 → 「已移除 (session/setModel)」文案)、
  C4h (对象缺字段 → 本地 -32602 且不发起后端调用)、C4i (catalog 不可用
  文案)、C4j (畸形 catalog 条目不抛);
- C4c/C4f 补「不发起 create」「不登记 session_map」断言。

验证: python3 -m unittest discover -s tests → 551 passed; ruff check 干净。
0.16 stdio 下 mode=plan 是 advisory (工具照常自动执行, 实测), 快照 diff 只是
事后发现; 引擎 session/create 的 toolAllowlist/toolDenylist 映射
引擎的工具集注册级硬 allow/deny (先于权限层、与 mode 无关), 是会话级前置只读手段。非空字符串数组
原样透传 (空条目剔除), 缺省/空数组/非数组时 create 参数与旧版逐字一致。
C5/C5a 回归用例; 全套 549 passed + 34 subtests。

R2 审查修复: 严格校验 fail-closed / 空数组透传 / 文档与测试同步 —
- 键存在 → 必须为字符串数组且每个元素 strip 后非空, 非法 (含显式 null/
  非数组/元素非字符串/空串) 本地 -32602 且不触碰后端; 删除 str() 强转与
  非数组静默忽略路径; 键缺省 → 不写入 create 参数 (与旧行为逐字节一致);
- 字面空数组 [] 原样透传 (引擎语义: 空 allowlist = 全部禁用, 空 denylist
  = 无限制), 不再静默丢弃;
- docstring/README/SKILL 同步: 机制归属修正为工具集注册级物理过滤 (先于
  权限层、与 mode 无关; 非 PermissionService 映射), allow+deny 同传 =
  交集且 deny 优先, 只读监督建议同时 deny Node REPL 族;
- 测试改写为 C5/C5a-C5e (归一/缺省兼容/空数组透传/非法 fail-closed/单边/
  与 model 并存), 全套 557 passed; ruff check 干净。
@tizerluo
tizerluo force-pushed the feat/acp-session-toollists branch from 20fa2b0 to 446737c Compare October 3, 2026 19:36
@tizerluo
tizerluo merged commit a26f8be into tizerluo:main Oct 3, 2026
@tizerluo

tizerluo commented Oct 3, 2026

Copy link
Copy Markdown
Owner

感谢这个增量,同样先道个歉:搁了近两周才被看到是我们的问题;为不再耽误,我们在你的分支上直接代为修复后合并了(rebase 到 #52 的修复版之上,你的提交经 amend 保留了你的作者署名,446737c)。

处理经构

  1. 两轮独立审查:核心机制正确、「缺省不改变旧行为」的纪律被测试钉得很扎实;两条 P1 集中在校验语义——非法输入静默忽略(对安全原语 fail-open)与空名单语义反转(桥丢弃空数组,引擎语义是空 allowlist=全部禁用);
  2. 代修内容(amend 进你的提交):严格校验 fail-closed(键存在必须是字符串数组、元素 strip 后非空,非法本地 -32602 且零后端调用、连子进程都不拉起)+ 字面空数组原样透传(保留引擎 deny-all 语义)+ 文档同步(README/SKILL:交集 deny 优先、空数组语义、Node REPL 族建议名单、限制docs: 同步 skill 到 App 3.3.0 (补 prompt/enhance) #7 交叉引用)+ docstring 机制归属修正(实际是工具集注册级物理过滤而非 PermissionService,引擎 bundle 可证)+ C5 系测试扩为 6 例;
  3. 修复后活体验证:toolDenylist: "Write"(非法形态)→ -32602 且零会话创建;model + toolDenylist(8 项含 Node REPL 族)→ 会话创建、模型锁定成功、真实 turn end_turn。已合并。

贡献记录

你的提交以原始作者署名保留在 main 历史(合并提交 a26f8be)。工具名单这个「会话级前置只读」原语是本仓安全叙事里缺的一块(与 review 体系的 --disallowed-tools 黑名单思路呼应),从动机到实现都记在你的名下。

欢迎继续贡献,后续不会再让 PR 晾着。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants