P4.7 P2 write-class tool 审批半场 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox (
- [ ]).
Goal: write-class tool 被调 → gateway 判 accessClass=write → 不执行副作用 → 建 ApprovalRequest(复用现有 Expert 审批 UI)→ 返 queued_for_approval(Hermes 不阻塞)→ Expert 批准后 server-side 解密 params 重跑真副作用。
Architecture: 复用 ApprovalRequest(载体决策已定,推翻 spike 的 ExpertQueueItem——理由见 docs/superpowers/specs/2026-07-09-p4.7-p2-write-approval-carrier-design.md)。accessClass 由 gateway server-side 查(授权留控制平面,不信 agent 传)。原始 params AES-256-GCM 加密存 ToolCall(复用 crypto.ts)。批准后同步执行(不引 BullMQ)。
Tech Stack: NestJS(ToolGatewayService / ActionPolicyService / ApprovalRequestService / ConfigAssetsService)+ api/src/common/crypto.ts(AES-256-GCM)+ Python agent(humanwork_mcp_server status 分派)。
权威事实(全部 file:line 亲验,HEAD c8f969af)
- 加密:
api/src/common/crypto.tsencryptValue(s):string(:31)/decryptValue(s):string(:48),格式enc:iv:tag:ct,密钥缺失明文透传(非 oauth-token-crypto 的抛错版)。ToolCall 加密列照它。 - run→specialistId:
AgentRun.specialistId(entities.ts:1855,nullable)直接可用;gatewaytool-gateway.service.ts:85已 findOne 整行 run。空则视为无 write 绑定。 - accessClass 查:
getPublishedToolBindings({orgId,specialistId})(config-assets.service.ts:636)返PublishedToolBinding[],content.accessClass(tool-binding.schema.ts:19)。toolName === content.toolSlug(同命名空间,AGENT_TOOL_REGISTRYkey = toolSlug)。gateway 未注入 ConfigAssetsService(tool-gateway.service.ts:70-79),需 DI 新增 + module import ConfigAssetsModule。 - 执行段:
tool-gateway.service.ts:244-297(status→running→executor→succeeded/failed+ledger)只依赖 toolCall 行+params+executor+ledger,不依赖前半段 manifest/policy 产物 → 可抽executeApprovedToolCall。 - approve 钩子:
ApprovalRequestService.resolve(approval-request.service.ts:145-189)同步事务,approved 分支现仅翻状态+refreshRunBlocker,无重跑。 - ⚠️ pre-existing bug:
humanwork_mcp_server.py:180只认status=="success",但 gateway 返"succeeded"(tool-gateway.service.ts:257,270)→ read tool 真实响应也走不通(P1 验收 mock 返 "ok" 漏了这段)。 - ExecuteToolGatewayInput(
tool-gateway.service.ts:46-59)、EvaluateActionPolicyInput(action-policy.service.ts:11-19)、decide(:60,riskLevel 分支:109)均无 accessClass。 - write 过滤点:
intersectReadTools(tool-resolution.util.ts:42accessClass !== "read" continue)。
File Structure(依赖顺序)
| Task | 文件 | 改什么 |
|---|---|---|
| T0 | agent/humanwork_mcp_server.py + tests | status 分派:认 succeeded(修 bug)+ queued_for_approval 正常返回 |
| T4 | api/src/common/entities.ts(ToolCall) + migration | 加密列 input_params_encrypted nullable |
| T2 | runtime-control-plane.module.ts + tool-gateway.service.ts | import ConfigAssetsModule + 注入 ConfigAssetsService + run.specialistId 查 accessClass |
| T1 | action-policy.service.ts + tool-gateway.service.ts | accessClass 透传 + decide write→require_approval |
| T3 | tool-resolution.util.ts + conversations.service.ts | write 进 manifest(flag-gated) |
| T5 | tool-gateway.service.ts | write→建 approval+存加密 params+返 queued_for_approval;抽 executeApprovedToolCall |
| T6 | approval-request.service.ts | approve 后同步调 executeApprovedToolCall(解密 params,事务外,失败降级) |
| T7 | CLAUDE.md ×3 + final review + 本地验收 | 文档 + code-reviewer + e2e |
Task 0: 修 status wire bug + 加 queued_for_approval 分支(影响 P1+P2,先做)
Files: agent/humanwork_mcp_server.py;tests agent/tests/test_humanwork_mcp_server.py
先 Read:humanwork_mcp_server.py:175-200(dispatch status 分派)+ agent/tests/test_humanwork_mcp_server.py(现有 status mock,注意它用 "success"——要改成真实 "succeeded")。
- Step 1: 写失败测试 —— gateway 返
succeeded→ 返回 result(不抛);返queued_for_approval→ 作为正常结果返回(不抛,Hermes 视为成功);返blocked→ 仍抛;failed→ 仍抛。
# 对齐真实测试结构,先 Read
async def test_succeeded_status_returns_result():
# mock gateway_response = {"status": "succeeded", "result": {...}}
# assert dispatch 返回 result,不抛
async def test_queued_for_approval_returns_as_normal_result():
# mock {"status": "queued_for_approval", "requestId": "...", "message": "..."}
# assert 返回一个正常结果 dict(含 status/message),不抛 ToolExecutionError
async def test_blocked_still_raises():
# {"status":"blocked"} → 仍抛
- Step 2: 跑测试确认失败
Run: cd agent && python -m pytest tests/test_humanwork_mcp_server.py -v -k "succeeded or queued or blocked"
Expected: FAIL(现只认 "success")
- Step 3: 实现 —— status 分派:
succeeded(或兼容success)→ 返 result;queued_for_approval→ 返{"status":"queued_for_approval","message": gateway_response.get("message") 或默认文案}(正常结果,让 Hermes 知道"已提交审批");blocked/failed不变。
status = gateway_response.get("status")
if status in ("succeeded", "success"): # 兼容旧 mock + 修真实值
return gateway_response.get("result")
if status == "queued_for_approval":
# write-class 已提交 Expert 审批,Hermes 视为成功完成本次 tool(副作用批准后执行)
return {
"status": "queued_for_approval",
"message": gateway_response.get("message")
or "This action has been submitted for expert approval and will run once approved.",
"requestId": gateway_response.get("requestId"),
}
if status == "blocked":
... # 不变
# failed / 其它 —— 不变
- Step 4: 跑测试确认通过 + 回归
Run: cd agent && python -m pytest tests/ evals/ -k "mcp or tool_gateway or hermes or echo" -v
Expected: PASS(含 echo-guard 不回归)
- Step 5: Commit
git add agent/humanwork_mcp_server.py agent/tests/test_humanwork_mcp_server.py
git commit -m "fix(agent): humanwork MCP server accepts succeeded status + queued_for_approval branch (P4.7 P2-T0, fixes pre-existing success/succeeded mismatch)"
Task 4: ToolCall 原始 params 加密列 + migration
Files: api/src/common/entities.ts(ToolCall);api/migrations/<ts>-AddToolCallEncryptedParams.ts;test api/test/(ToolCall 实体或 crypto round-trip)
先 Read:entities.ts ToolCall 定义(:2107-2203,看 inputRedacted 列 :2162 作模板)+ crypto.ts:31,48(encryptValue/decryptValue)+ 任一现有 migration 作格式模板(api/migrations/ 里近期一个)。
- Step 1: 写失败测试 —— crypto round-trip:
JSON.parse(decryptValue(encryptValue(JSON.stringify(params)))) === params(证加密列存取语义)。
import { encryptValue, decryptValue } from "../src/common/crypto";
it("tool params encrypt round-trips", () => {
const params = { orderId: "ORD-1", amount: 99, to: "a@b.com" };
const enc = encryptValue(JSON.stringify(params));
expect(JSON.parse(decryptValue(enc))).toEqual(params);
});
- Step 2: 跑测试确认(crypto 已存在应直接过——这步验的是复用语义正确)
Run: cd api && npx jest -t "encrypt round" --silent
-
Step 3: 加实体列 + migration —— ToolCall 加
@Column({ name: "input_params_encrypted", type: "text", nullable: true }) inputParamsEncrypted: string | null;(照 inputRedacted 模板)。migration expand-contract:ADD COLUMN ... nullable(migration 文件只 export MigrationInterface class,无 helper——root api/CLAUDE.md)。 -
Step 4: 跑 migration 干净 + tsc
Run: cd api && npx tsc --noEmit(EXIT 0);migration 语法自检(不跑全量 npm test,seed-eval spec 会自改写迁移)
Expected: tsc 0
- Step 5: Commit
git add api/src/common/entities.ts api/migrations/*ToolCallEncryptedParams* api/test/*
git commit -m "feat(runtime): ToolCall.inputParamsEncrypted (AES-256-GCM) for approved-write re-execution (P4.7 P2-T4)"
Task 2: gateway server-side 查 accessClass
Files: api/src/runtime-control-plane/runtime-control-plane.module.ts;tool-gateway.service.ts;test tool-gateway.service.spec.ts 或对应
先 Read:runtime-control-plane.module.ts:100-146(imports)+ tool-gateway.service.ts:70-79(构造函 数)+ :81-95(execute 开头 run 查询)+ config-assets.service.ts:636(getPublishedToolBindings 签名)。
-
Step 1: 写失败测试 —— gateway 有一个私有/内部方法
resolveAccessClass(run, toolName):run.specialistId 存在 + 有匹配 write binding → "write";无匹配/specialistId 空 → "read"(默认不拦)。 -
Step 2: 跑测试确认失败
Run: cd api && npx jest tool-gateway --silent
- Step 3: 实现 —— module import ConfigAssetsModule;gateway 构造注入
ConfigAssetsService;resolveAccessClass:if (!run.specialistId) return "read"; const bindings = await configAssets.getPublishedToolBindings({orgId: run.orgId, specialistId: run.specialistId}); const b = bindings.find(x => x.content.toolSlug === toolName); return b?.content.accessClass ?? "read";(fail-open:查失败→"read" 不拦,因为 read 是安全默认?否——write 场景 fail-open 到 read 会漏审批。此处应 fail-CLOSED:查失败→当 write 拦下。这是安全决策,写进注释)。
⚠️ fail 方向决策:accessClass 查失败时,read 场景应放行、write 场景应拦。无法区分时保守当 write 拦(宁可多审不可漏审,对齐 P3.5 保守 over-flag 哲学)。但注意:这会让"查不到 binding 的 tool"也被拦——而 read tool P1 已在 manifest 里、intersectReadTools 已确认它是 read。折中:只对在 write manifest 里的 tool 查 accessClass;read tool 走原路径不调 resolveAccessClass。见 T3(write 进 manifest 时带标记)。
- Step 4: 跑测试 + tsc + lint
Run: cd api && npx jest tool-gateway --silent && npx tsc --noEmit
Expected: PASS + EXIT 0
- Step 5: Commit
git commit -am "feat(runtime): gateway resolves tool accessClass server-side from published bindings (P4.7 P2-T2)"
Task 1: ActionPolicy write→require_approval
Files: action-policy.service.ts;tool-gateway.service.ts(透传 accessClass 到 policy);test action-policy.service.spec.ts
先 Read:action-policy.service.ts:11-19(EvaluateActionPolicyInput)+ :60-122(decide)+ tool-gateway.service.ts:183-191(execute 调 policy.evaluate)。
-
Step 1: 写失败测试 —— decide:
actionType:"tool_execution"+accessClass:"write"→require_expert_approval(无视 riskLevel);accessClass:"read"+ medium → allow(不回归)。 -
Step 2: 跑测试确认失败
Run: cd api && npx jest action-policy --silent
-
Step 3: 实现 ——
EvaluateActionPolicyInput+ExecuteToolGatewayInput加accessClass?: "read"|"write";decide 在 riskLevel 分支(:109)前加if (actionType === "tool_execution" && accessClass === "write") return require_expert_approval;gateway execute 调 policy 时传accessClass: resolvedAccessClass(T2 的结果)。 -
Step 4: 跑测试 + tsc
Run: cd api && npx jest action-policy tool-gateway --silent && npx tsc --noEmit
Expected: PASS + EXIT 0
- Step 5: Commit
git commit -am "feat(runtime): ActionPolicy routes write-class tool_execution to expert approval (P4.7 P2-T1)"
Task 3: write tool 进 manifest(flag-gated)
Files: tool-resolution.util.ts;conversations.service.ts(resolveSpecialistToolsets);test tool-dispatch-wiring.spec.ts + tool-resolution.util.spec.ts
先 Read:tool-resolution.util.ts:24-49(intersectReadTools)+ conversations.service.ts resolveSpecialistToolsets(P1 的,:5772 附近,注意 P1 后已改返 {toolsetNames,mcpTools})+ conversations/CLAUDE.md P4.7 段。
设计:P1 只让 read 进 manifest。P2 让 write 也进(否则 NOT_IN_MANIFEST,走不到 approval)。但要保证 gateway 能区分——manifest 的 allowedTools 是 string[](toolName.action),gateway 靠 T2 的 resolveAccessClass 查 accessClass(不靠 manifest 标记),所以 manifest 只需包含 write tool 即可,gateway 自己判。决策:新增 intersectAllTools(read+write 都进)或让 intersectReadTools 出全集——但保留 read/write 区分供 flag 分级。最简:让交集包含 write,gateway 侧 resolveAccessClass 拦。
-
Step 1: 写失败测试 —— flag ON:write binding 也进 mcpTools/manifest(之前被 intersectReadTools 挡掉);read+write 混合 → 都进。flag OFF:字节等同(无变化)。
-
Step 2-4: 实现 + 测试 + tsc(放宽 write 过滤,两 dispatch site。注意 flag-OFF 字节等同仍是承压属性)
-
Step 5: Commit
git commit -am "feat(conversations): admit write-class tools into manifest so gateway can gate them (P4.7 P2-T3)"
Task 5: write→建 approval + 存加密 params + 返 queued_for_approval
Files: tool-gateway.service.ts;test tool-gateway.service.spec.ts
先 Read:tool-gateway.service.ts:183-242(policy evaluate + 现有 require_approval 建 ApprovalRequest 分支)+ :244-297(执行段,要抽 executeApprovedToolCall)+ :218-224(现 payload)。
-
Step 1: 写失败测试 —— write tool(policy 返 require_approval):建 ApprovalRequest(payload 有 toolCallId)+ ToolCall.inputParamsEncrypted 存加密原始 params(
encryptValue(JSON.stringify(params)))+ ToolCall.status=waiting_approval + 返回{status:"queued_for_approval", requestId, toolCallId}(不是 waiting_approval,让 MCP handler 视为正常)+ 不调 executor(无副作用)。 -
Step 2: 跑测试确认失败
-
Step 3: 实现 —— ①现有 require_approval 分支(:211-242):write-class 时存
toolCall.inputParamsEncrypted = encryptValue(JSON.stringify(input.params))(原始 params,非 redact);返回 status 从waiting_approval改/映射为queued_for_approval(或加字段让 wire 出 queued_for_approval)。②抽executeApprovedToolCall(toolCall):从:244-297抽出,解密inputParamsEncrypted得原始 params,原子 claimstatus waiting_approval→running(防重),调 executor,回写 succeeded/failed+ledger。绕过 manifest/policy 复检(首次已 gate)。 -
Step 4: 跑测试 + tsc + lint
-
Step 5: Commit(动 gateway 执行/加密 → 派 code-reviewer 前先自测绿)
git commit -am "feat(runtime): write-class tool queues approval with encrypted params, returns queued_for_approval (P4.7 P2-T5)"
Task 6: 批准后执行闭环
Files: approval-request.service.ts;test approval-request.service.spec.ts
先 Read:approval-request.service.ts:145-189(resolve)+ :206-221(refreshRunBlockerState)+ T5 抽的 executeApprovedToolCall。
设计:resolve 的 approved 分支,事务 commit 后(不在审批事务内——执行副作用失败不能回滚审批,ADR-019),若该 approval 是 expert_tool_approval(有 toolCallId in payload),调 executeApprovedToolCall(toolCall)。执行失败 → ToolCall failed + ledger,审批保持 approved(降级记录,不回滚)。需给 ApprovalRequestService 注入能调 executeApprovedToolCall 的东西(ToolGatewayService,注意避免循环依赖——若成环用 forwardRef 或把 executeApprovedToolCall 放独立 service)。
-
Step 1: 写失败测试 —— approve 一个 expert_tool_approval → commit 后调 executeApprovedToolCall(mock executor)→ ToolCall 跑到 succeeded;executor 抛错 → ToolCall failed 但 approval 仍 approved(不回滚)。非 tool approval(如 channel_send)→ 不调。
-
Step 2: 跑测试确认失败
-
Step 3: 实现 —— resolve approved 分支 commit 后 hook;循环依赖检查(ToolGatewayService ↔ ApprovalRequestService)——若成环,把
executeApprovedToolCall抽到独立 service 或用 forwardRef(先 Read 现有依赖图判断)。 -
Step 4: 跑测试 + tsc + lint
-
Step 5: Commit
git commit -am "feat(runtime): execute approved write-class tool call after expert approval (P4.7 P2-T6)"
Task 7: 文档 + final review + 本地验收
-
Step 1: 文档 —— conversations/CLAUDE.md(write 进 manifest,read-only 不再是唯一)、config-assets/CLAUDE.md(tool_binding execution write 半场已建)、runtime approval 契约。grep 核实 symbol。
-
Step 2: P2 级 final code-reviewer —— 动 gateway 执行/加密/授权/审批闭环,必派。重点:write→approval 无副作用泄漏、加密 params 正确(密钥缺失明文回退在 dev 可接受、prod invariant 保证)、批准后执行的原子 claim 防重、执行失败不回滚审批、accessClass fail-closed(查失败当 write 拦)、flag-OFF/read tool 零回归。
-
Step 3: 本地端到端验收(verify skill) —— 扩 P1 的验收:humanwork_mcp_server + mock gateway 返
queued_for_approval→ MCP handler 正常返回(不抛);NestJS 侧真实 HTTP:write tool → gateway 建 approval + 存加密 params + 返 queued_for_approval + 不执行;approve → 解密 + executor 跑。捕获真实行为。
验证策略
- 每 seam 单测(status 分派/加密 round-trip/accessClass 查/policy write 分支/write 进 manifest/queued 返回/批准后执行)。
- 端到端:write tool → queued_for_approval(Hermes 不阻塞)→ Expert approve → server-side 解密执行。这条链路 P2 第一次通。
- flag-OFF + read tool(P1)零回归——承压安全属性。
follow-up(P2 之外)
- assignedExpertId 定向派单(现无负责-Expert 解析,留空全体可见)。
- externalIdempotencyKey 真正的重放去重(executor 不消费它,批准后重跑靠 status 原子 claim 防重)。
- I-1 org-claim 交叉校验、regen 自己的 AgentRun。