从 pending code 到 single-use mandate 深度解析
这一页看购买权限如何从模型输出变成人类确认后的签名 mandate:code 不从 MCP 返回,fingerprint 绑定购买四元组,checkout 前再做 cryptographic verification。
导入链与契约
授权 store 的注释在 client/src/authorization.ts:1 定义 request/approve/decline 三步,client/src/authorization.ts:45 设置 15 分钟 TTL,client/src/authorization.ts:49 限制三次错误尝试,client/src/authorization.ts:179 生成随机 code 并使用 timingSafeEqual 比较。
1/** 2 * The explicit human approval gate (spec §4 invariant 4): 3 * 4 * request_purchase_authorization → PENDING authorization + human summary 5 * approve_purchase(code) → mandate SIGNED, authorization approved 6 * decline_purchase → authorization VOID (symmetric, guilt-free) 7 * 8 * The confirmation code is written to 9 * `<configDir>/pending-authorizations/<id>.code` (0600). The standard MCP 10 * runtime disables the optional STDERR banner because hosts commonly capture 11 * child-process stderr. The tool result deliberately omits the code. Another 12 * program the buyer runs under the same OS account can read buyer-local state; 13 * separate accounts are an optional local hardening boundary. 14 * Wrong-code attempts are bounded; pending authorizations expire. 15 * 16 * approval "sign what you see": both trusted channels render the FOUR-TUPLE 17 * (merchant of record, exact item + variant, all-in total with an honest 18 * unknown-tax statement, payment context per rail) plus an order fingerprint 19 * displayed WITH the code. The fingerprint commits the code to the tuple — 20 * approval recomputes it and rejects any tuple mutated after issuance — and 21 * the mandate nonce is fixed at request time so the mandate signed at 22 * approval is exactly the order the human saw fingerprinted. 23 * 24 * The purchase mandate is signed only AT APPROVAL TIME (issuance = the human 25 * act), with the user's local ed25519 keypair, binding offer id, merchant, 26 * and a hard spending cap. 27 */
核心实现,逐段拆
▸request:写本地 code,不回传给 MCP
client/src/authorization.ts:299 创建 authorizationId、code、nonce 与 fingerprint,client/src/authorization.ts:315 写 code file,client/src/authorization.ts:321 记录 merchant/offer/total,client/src/authorization.ts:330 chmod 到 0600。测试在 client/test/authorization.test.ts:47 与 client/test/authorization.test.ts:84 证明 summary 与 stderr 不泄漏 code。
299 request(offer, opts) { 300 const at = now(); 301 const id = `auth_${randomUUID()}`; 302 // Own copies: the authorization's tuple must not alias caller-held 303 // objects (a later caller-side mutation must not silently retarget it). 304 const ownOffer = structuredClone(offer); 305 const maxAmount = structuredClone(opts.maxAmount ?? offerTotal(offer)); 306 const paymentContext = opts.paymentContext ?? "none"; 307 const authorization: Authorization = { 308 id, 309 status: "pending", 310 offer: ownOffer, 311 intent: opts.intent, 312 maxAmount, 313 paymentContext, 314 createdAt: at.toISOString(), 315 expiresAt: new Date(at.getTime() + ttlMs).toISOString(), 316 attemptsRemaining: maxAttempts, 317 }; 318 319 const code = generateCode(); 320 // The mandate nonce is fixed NOW so the fingerprint displayed with the 321 // code commits to the exact mandate that approval will sign. 322 const nonce = randomBytes(18).toString("base64url"); 323 const fingerprint = currentFingerprint(authorization, nonce); 324 const codeFile = join(pendingDir, `${id}.code`); 325 // Every buyer-local approval channel shows the FULL four-tuple the mandate will sign 326 // (merchant of record, exact item + variant, all-in total with honest 327 // unknown-tax statement, payment context) plus the hard cap the 328 // (untrusted) host agent chose — "sign what you see". 329 const tupleLines = renderOrderTuple(ownOffer, paymentContext); 330 const capLine = `HARD SPENDING CAP: ${formatMoney(maxAmount)} — the agent may spend up to this amount`; 331 // local UI second door: the approval-page URL rides the same buyer-local 332 // channels as the code — never the tool result. 333 const approvalUrl = options.approvalUrl?.(id); 334 const approvalUrlLine = 335 approvalUrl !== undefined ? `Or review & approve/decline in your browser: ${approvalUrl}` : undefined; 336 // Fail CLOSED but never let the underlying fs error — which typically 337 // embeds the absolute configDir/pending-authorizations path — reach a 338 // tool-facing error string; request_purchase_authorization 339 // calls this via deps.authorizations.request at server.ts). 340 runFsOp(() => { 341 mkdirSync(pendingDir, { recursive: true, mode: 0o700 }); 342 writeFileSync( 343 codeFile, 344 [ 345 code,
▸approve:签名并限制错误尝试
client/src/authorization.ts:346 开始 approve 流程,client/src/authorization.ts:369 递增 wrongAttempts,client/src/authorization.ts:376 达到上限后 declined,client/src/authorization.ts:388 重新计算 fingerprint,client/src/authorization.ts:397 签发 mandate。测试在 client/test/authorization.test.ts:122 覆盖错误码上限。
346 `# Authorization ${id} · order-fingerprint ${fingerprint}`, 347 `# ${authorization.intent}`, 348 ...tupleLines.map((line) => `# ${line}`), 349 `# ${capLine}`, 350 `# Give the first line of this file to your agent ONLY if you approve this purchase.`, 351 ...(approvalUrlLine !== undefined ? [`# ${approvalUrlLine}`] : []), 352 `# ${DECLINE_CHANNEL_LINE}`, 353 ``, 354 ].join("\n"), 355 { mode: 0o600 }, 356 ); 357 chmodSync(codeFile, 0o600); 358 }, "purchase authorization request failed: the confirmation code could not be written"); 359 360 if (!options.quiet) { 361 // Optional low-level diagnostic mode only. The standard MCP runtime 362 // sets quiet=true because hosts commonly capture child-process stderr. 363 process.stderr.write( 364 [ 365 ``, 366 `┌─ ${BRAND_NAME} purchase approval ──────────────────────────────────────`, 367 `│ ${authorization.intent}`, 368 ...tupleLines.map((line) => `│ ${line}`), 369 `│ ${capLine}`, 370 `│ Authorization ${id}`, 371 `│ CONFIRMATION CODE: ${code} · order-fingerprint ${fingerprint}`, 372 `│ Give this code to your agent ONLY if you approve this purchase.`, 373 ...(approvalUrlLine !== undefined ? [`│ ${approvalUrlLine}`] : []), 374 `│ ${DECLINE_CHANNEL_LINE}`, 375 `└─────────────────────────────────────────────────────────────────`, 376 ``, 377 ].join("\n"), 378 ); 379 } 380 381 records.set(id, { 382 authorization, 383 code, 384 codeFile, 385 nonce, 386 fingerprint, 387 offerSnapshot: structuredClone(ownOffer), 388 approvedCap: structuredClone(maxAmount), 389 intentSnapshot: opts.intent, 390 checkoutInFlight: false, 391 }); 392 // local UI push hook (best-effort, contained): the approval push must never 393 // fail — or delay reporting — the authorization request itself. 394 if (options.onRequested !== undefined) { 395 try { 396 options.onRequested({ 397 authorizationId: id, 398 fingerprint, 399 intent: authorization.intent, 400 expiresAt: authorization.expiresAt, 401 ...(approvalUrl !== undefined ? { approvalUrl } : {}), 402 }); 403 } catch { 404 process.stderr.write( 405 `[${BRAND_NAME}] approval push hook failed (authorization ${id}): hook_error\n`, 406 ); 407 } 408 } 409 return { 410 authorization, 411 summary: buildAuthorizationSummary(authorization), 412 codeDelivery: { file: codeFile, stderr: !options.quiet }, 413 fingerprint, 414 }; 415 },
关键流程
server 层在 client/src/server.ts:1338 先拒绝 mixed-currency shipping,client/src/server.ts:1359 拒绝 cap 小于 total,client/src/server.ts:1431 才调用 approve,client/src/server.ts:1455 返回 mandate id。这些校验把错误尽量提前到人类确认之前。
▸验证联动
这页把实现和测试一起看:源码引用说明控制点,测试引用说明这些控制点已经被行为化约束,避免只把 README 的安全承诺复述成架构结论。
边界 / 失败模式
client/src/server.ts:1471把 decline 做成对称工具,不需要 code,用户说不时不会被二次门禁卡住。client/src/authorization.ts:290会在过期后清理 code file,避免 stale code 长期有效。client/test/authorization.test.ts:141覆盖过期无法 approve,client/test/authorization.test.ts:160覆盖 approve twice 失败。