Skip to main content

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.ts encryptValue(s):string:31)/ decryptValue(s):string:48),格式 enc:iv:tag:ct,密钥缺失明文透传(非 oauth-token-crypto 的抛错版)。ToolCall 加密列照它。
  • run→specialistIdAgentRun.specialistIdentities.ts:1855,nullable)直接可用;gateway tool-gateway.service.ts:85 已 findOne 整行 run。空则视为无 write 绑定。
  • accessClass 查getPublishedToolBindings({orgId,specialistId})config-assets.service.ts:636)返 PublishedToolBinding[]content.accessClasstool-binding.schema.ts:19)。toolName === content.toolSlug(同命名空间,AGENT_TOOL_REGISTRY key = toolSlug)。gateway 未注入 ConfigAssetsServicetool-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.resolveapproval-request.service.ts:145-189)同步事务,approved 分支现仅翻状态+refreshRunBlocker,无重跑。
  • ⚠️ pre-existing bughumanwork_mcp_server.py:180 只认 status=="success",但 gateway 返 "succeeded"tool-gateway.service.ts:257,270)→ read tool 真实响应也走不通(P1 验收 mock 返 "ok" 漏了这段)。
  • ExecuteToolGatewayInputtool-gateway.service.ts:46-59)、EvaluateActionPolicyInputaction-policy.service.ts:11-19)、decide:60,riskLevel 分支 :109)均无 accessClass。
  • write 过滤点intersectReadToolstool-resolution.util.ts:42 accessClass !== "read" continue)。

File Structure(依赖顺序)

Task文件改什么
T0agent/humanwork_mcp_server.py + testsstatus 分派:认 succeeded(修 bug)+ queued_for_approval 正常返回
T4api/src/common/entities.ts(ToolCall) + migration加密列 input_params_encrypted nullable
T2runtime-control-plane.module.ts + tool-gateway.service.tsimport ConfigAssetsModule + 注入 ConfigAssetsService + run.specialistId 查 accessClass
T1action-policy.service.ts + tool-gateway.service.tsaccessClass 透传 + decide write→require_approval
T3tool-resolution.util.ts + conversations.service.tswrite 进 manifest(flag-gated)
T5tool-gateway.service.tswrite→建 approval+存加密 params+返 queued_for_approval;抽 executeApprovedToolCall
T6approval-request.service.tsapprove 后同步调 executeApprovedToolCall(解密 params,事务外,失败降级)
T7CLAUDE.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

先 Readhumanwork_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)

先 Readentities.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 ... nullablemigration 文件只 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.tstool-gateway.service.ts;test tool-gateway.service.spec.ts 或对应

先 Readruntime-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 构造注入 ConfigAssetsServiceresolveAccessClassif (!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.tstool-gateway.service.ts(透传 accessClass 到 policy);test action-policy.service.spec.ts

先 Readaction-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 + ExecuteToolGatewayInputaccessClass?: "read"|"write";decide 在 riskLevel 分支(:109if (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.tsconversations.service.ts(resolveSpecialistToolsets);test tool-dispatch-wiring.spec.ts + tool-resolution.util.spec.ts

先 Readtool-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

先 Readtool-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,原子 claim status 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

先 Readapproval-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。