northcinder 买家侧 Agent 商务防线
NorthCinder 不是把购物流程交给一个会点击网页的模型,而是把搜索、排序、信任信号、人工确认、签名 mandate 与 checkout rail 拆成多个互相制衡的源码边界。本页只看代码:本地 MCP server 在 client/src/server.ts:339 建立搜索入口,远端 bridge 在 remote/src/mcp-server.ts:123 刻意保持只读,授权存储在 client/src/authorization.ts:299 生成一次性确认码,真正 checkout 则由 packages/checkout/src/orchestrator.ts:101 的 mandate 硬门控制。
它解决什么,凭什么值得拆
这个仓库的核心价值在于把“Agent 能买东西”改写成“Agent 只能请求、解释和执行被验证过的购买尝试”。搜索工具在 client/src/server.ts:339 后会把服务端排序结果重新校验,client/src/server.ts:426 记录 rankingVerified 与 coverage,浏览器观察结果从 client/src/server.ts:572 进入但在 client/src/server.ts:1323 被拒绝直接授权,人工授权工具从 client/src/server.ts:1244 开始,把 confirmation code 放到本地渠道而非 MCP 返回值。这让模型可以推荐和解释,但不能仅凭一次搜索结果绕过用户确认。
更值得拆的是它的失败语义:client/src/server.ts:1338 拒绝跨币种 shipping,client/src/server.ts:1351 校验 cap 与 offer total,client/src/server.ts:1541 拒绝 pending authorization,client/src/server.ts:1568 在 checkout 中标记执行中,而 packages/checkout/src/orchestrator.ts:123 对 mandate verification 失败只返回结构化错误。审计层在 client/src/audit-log.ts:57 以 append-only JSONL 落盘,订单图在 packages/orders/src/store.ts:136 保留无法解析邮件,整体设计偏向可追责而不是自动化炫技。
▸核心数据
service/src/trust/engine.ts:63 与 service/test/trust-engine.test.ts:73。深度阅读 → 入口展开。
架构边界:本地可买,远端只读
源码把本地 server 与 remote bridge 分得很清楚。client/src/server.ts:1 的注释定义本地 MCP server 覆盖 search/ranking/auth/checkout/audit,而 remote/src/mcp-server.ts:1 明确 remote bridge 只有 search 和 trust 两个只读工具。服务端搜索返回后,本地在 client/src/server.ts:285 追加本地历史证据但不影响排序,在 client/src/server.ts:403 把 ranking tamper warning 写进 brief。这条边界避免了把购买权限暴露给远端 API key 持有人。
▸系统拓扑
▸模块清单
模块拆分不是按 UI 页面拆,而是按交易边界拆。client/src/server.ts:113 注入 service、authorization、checkout、audit、profile、watches 和 orders;client/src/checkout-wiring.ts:51 组合 rail 优先级;packages/checkout/src/index.ts:1 只导出 mandate hard gate 和 rails;packages/orders/src/parser.ts:16 则把邮件解析保持为确定性插件链。
client/src/server.ts:507 search、client/src/server.ts:1244 authorization、client/src/server.ts:1505 checkout、client/src/server.ts:904 watches。remote/src/mcp-server.ts:31 明确无 authorization/checkout,remote/src/mcp-server.ts:76 清洗上游错误。packages/checkout/src/orchestrator.ts:77 rail 选择先于 nonce,packages/checkout/src/orchestrator.ts:107 verifyMandate 后才执行。service/src/trust/engine.ts:44 TTL,service/src/trust/engine.ts:119 冷探测预算,service/src/trust/engine.ts:163 后台刷新。packages/orders/src/store.ts:8 说明相邻而非扩展,packages/orders/src/store.ts:153 只读适配 checkout record。client/test/authorization.test.ts:47 查 code 不泄漏,client/test/ranking-verification.test.ts:119 查排序篡改。▸关键数据结构
22/** Durable record of an authorized checkout, citing the mandate that gated it. */ 23export interface OrderRecord { 24 orderId: string; 25 createdAt: string; 26 offerId: string; 27 merchantId: string; 28 /** Canonical merchant domain paired with merchantId to avoid local-history collisions. */ 29 merchantDomain: string; 30 railId: string; 31 /** "completed" = purchase made by the rail; "handed_off" = user's own session completes it. */ 32 status: "completed" | "handed_off"; 33 /** The mandate that authorized this checkout (cited in full for audit). */ 34 mandateId: string; 35 mandate: PurchaseMandate; 36 evidence: RailEvidence; 37} 38 39export type CheckoutOutcome = 40 | { ok: true; order: OrderRecord } 41 | { ok: false; stage: "mandate"; error: MandateRejection } 42 | { ok: false; stage: "rail"; error: CheckoutError };
核心模块:搜索验证、授权、审计
▸搜索结果不会被服务端排名单独决定
本地 search_products 在 client/src/server.ts:339 校验服务端响应 schema,并在 client/src/server.ts:371 按 sourceStore+offer.id 建缓存。随后 client/src/server.ts:382 重新计算 ranking,client/src/server.ts:387 校验 store coverage,client/src/server.ts:426 把结果写进 structured output。测试在 client/test/ranking-verification.test.ts:112 构造被赞助结果插队的假服务,确保 client/test/ranking-verification.test.ts:155 能看到 rankingVerified=false。
339 function finalizeSearch(params: { 340 searchId: string; 341 interpreted: InterpretedQuery; 342 data: SearchRankResponse; 343 continuedFrom?: string; 344 }) { 345 const { searchId, interpreted, continuedFrom } = params; 346 const parsedData = SearchRankResponseSchema.safeParse(params.data); 347 if (!parsedData.success) { 348 return failure({ 349 code: "invalid_service_response", 350 message: "configured engine returned an invalid search response", 351 }); 352 } 353 const data = parsedData.data; 354 const query = interpreted.criteria; 355 const { results, storeStatuses, trustSignals, registeredStores, browserObservationReport } = data; 356 357 seenSearches.set(searchId, interpreted); 358 359 for (const r of results) { 360 seenOffers.set(offerKey(r.offer.sourceStore, r.offer.id), r.offer); 361 const merchantKey = trustKey(r.offer.merchant); 362 seenMerchants.set(merchantKey, r.offer.merchant); 363 const keys = seenMerchantKeysById.get(r.offer.merchant.id) ?? new Set<string>(); 364 keys.add(merchantKey); 365 seenMerchantKeysById.set(r.offer.merchant.id, keys); 366 } 367 368 const verification = verifySearchRanking(data, query); 369 const coverage = verifyStoreCoverage(registeredStores, storeStatuses); 370 const brief = composeBuyersBrief({ 371 searchId, 372 results, 373 interpretedQuery: interpreted, 374 storeStatuses, 375 trustSignals, 376 }); 377 seenBriefs.set(searchId, brief); 378 379 const localTrustEvidence: Record<string, ReturnType<typeof localTrustEvidenceFor>> = {}; 380 const localTrustEvidenceKeys: Record<string, string> = {}; 381 for (const f of brief.finalists) { 382 const finalistKey = offerKey(f.sourceStore, f.offerId); 383 const fullMerchant = seenOffers.get(finalistKey)?.merchant; 384 if (!fullMerchant) continue; 385 const merchantKey = trustKey(fullMerchant); 386 localTrustEvidenceKeys[finalistKey] = merchantKey;
▸授权码本地化,mandate 绑定四元组
授权 store 的注释从 client/src/authorization.ts:1 就声明三步流程,client/src/authorization.ts:198 确保 summary 不带 code,client/src/authorization.ts:240 保存 offer/maxAmount/intent 快照,client/src/authorization.ts:255 把 merchant、offer、variant、total、nonce 纳入 fingerprint,client/src/authorization.ts:299 写入 0600 code file。对应测试在 client/test/authorization.test.ts:47 和 client/test/authorization.test.ts:84 检查 code 与路径都不从 MCP 泄漏。
240 /** Fingerprint over the canonical tuple AS DISPLAYED to the human. */ 241 fingerprint: string; 242 /** 243 * Private request-time snapshot of the WHOLE offer as displayed to the 244 * human. approve() deep-compares the live offer against it (catches 245 * mutation of ANY field — incl. product.id, variant gid, title — not 246 * just the fingerprinted ones), and checkout reads THIS, never the 247 * externally-reachable live object. 248 */ 249 offerSnapshot: Offer; 250 /** Snapshot of the cap shown on the trusted channels (mutation-detected at approve). */ 251 approvedCap: Money; 252 /** Snapshot of the intent line the human read (mutation-detected at approve). */ 253 intentSnapshot: string; 254 /** True while a checkout attempt is executing (decline is refused then). */ 255 checkoutInFlight: boolean; 256 } 257 const records = new Map<string, InternalRecord>(); 258 259 /** Fingerprint over the authorization's CURRENT tuple + the issuance nonce. */ 260 function currentFingerprint(auth: Authorization, nonce: string): string { 261 const total = offerTotal(auth.offer); 262 return orderFingerprint({ 263 merchantId: auth.offer.merchant.id, 264 offerId: auth.offer.id, 265 // Bind the PURCHASE-relevant identities, not just the offer id: the 266 // ACP rail buys by product.id, cart-permalink by the variant gid. 267 productId: auth.offer.product.id, 268 variantKey: auth.offer.product.attributes[VARIANT_ATTRIBUTE] ?? "", 269 totalMinor: total.amount, 270 currency: total.currency, 271 nonce, 272 }); 273 } 274 275 /** 276 * Defensive read view (the CHECKOUT door): a deep clone whose offer and cap 277 * come from the private request-time snapshots — approve-time truth, never 278 * the externally-reachable live object. Post-approve mutation of a held 279 * reference therefore cannot re-aim what checkout verifies or charges. 280 */ 281 function snapshotView(record: InternalRecord): Authorization { 282 return { 283 ...structuredClone(record.authorization), 284 offer: structuredClone(record.offerSnapshot), 285 maxAmount: structuredClone(record.approvedCap), 286 intent: record.intentSnapshot, 287 }; 288 } 289 290 function expireIfDue(record: InternalRecord): void { 291 const auth = record.authorization; 292 if (auth.status === "pending" && now().getTime() > new Date(auth.expiresAt).getTime()) { 293 auth.status = "expired"; 294 rmSync(record.codeFile, { force: true }); 295 } 296 } 297 298 return { 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(),
关键流程:从 search 到 checkout attempt
购买链被拆成 request、approve、complete 三段。client/src/server.ts:1244 只创建 pending request,client/src/server.ts:1402 需要人类提供一次性 code,client/src/server.ts:1505 才尝试 checkout。真正的安全关键点在 packages/checkout/src/orchestrator.ts:101:rail selection 之后先 verifyMandate,packages/checkout/src/orchestrator.ts:127 才允许 rail.execute 接触 VerifiedMandate。
▸购买授权三段式
▸订单与信任的旁路证据
测试验证:失败路径比成功路径更关键
测试侧覆盖的是交易系统真正怕的路径:client/test/authorization.test.ts:98 验证签名 mandate,client/test/authorization.test.ts:122 限制错误 code 次数,client/test/authorization.test.ts:141 阻止过期 approval,client/test/authorization.test.ts:177 校验四元组文本,client/test/ranking-verification.test.ts:171 断言 audit 写入 tamper,service/test/trust-engine.test.ts:101 检查冷探测超时返回 unknown。
▸测试覆盖
排名验证测试直接构造 sponsored offer 被服务端置顶的场景。client/test/ranking-verification.test.ts:74 定义第一个自然排序结果,client/test/ranking-verification.test.ts:86 定义 sponsored offer,client/test/ranking-verification.test.ts:119 把 sponsored 放到第一位,client/test/ranking-verification.test.ts:144 调用 search_products,client/test/ranking-verification.test.ts:155 检查 rankingVerified=false,client/test/ranking-verification.test.ts:164 再查文本中包含 divergence。
112 error: { 113 code: "invalid_service_response", 114 message: "configured engine returned an invalid search response", 115 }, 116 }); 117 expect(existsSync(auditPath) ? readFileSync(auditPath, "utf8") : "").toBe(auditBefore); 118 119 const authorization = await client.callTool({ 120 name: "request_purchase_authorization", 121 arguments: { offerId: provenanceStrippedBrowser.id, sourceStore: provenanceStrippedBrowser.sourceStore }, 122 }); 123 expect(JSON.parse((authorization.content as Array<{ text: string }>)[0]!.text)).toMatchObject({ 124 error: { code: "unknown_offer" }, 125 }); 126 await client.close(); 127 }); 128 129 it("an HONEST service is verified: rankingVerified true in output and audit log", async () => { 130 const honest: SearchRankResponse = { 131 results: rankOffers(OFFERS, { text: "sneaker" }, { trust: TRUST }), 132 storeStatuses: STORE_STATUSES, 133 trustSignals: TRUST, 134 }; 135 const { client, auditPath } = await connectedClient(serviceReturning(honest)); 136 const result = await client.callTool({ name: "search_products", arguments: { text: "sneaker" } }); 137 expect(result.isError ?? false).toBe(false); 138 const structured = result.structuredContent as { rankingVerified: unknown; rankingDivergences?: unknown }; 139 expect(structured.rankingVerified).toBe(true); 140 expect(structured.rankingDivergences).toBeUndefined(); 141 const text = (result.content as Array<{ text: string }>)[0]!.text; 142 expect(text).toContain("Ranking verified"); 143 expect(text).not.toContain(RANKING_TAMPER_WARNING); 144 145 const search = auditSearchLine(auditPath); 146 expect(search.rankingVerified).toBe(true); 147 await client.close(); 148 }); 149 150 it("TAMPER: a fake service returning a boosted re-order is flagged rankingVerified false with the exact divergence", async () => { 151 const ranked = rankOffers(OFFERS, { text: "sneaker" }, { trust: TRUST }); 152 // Honest order is [cheap, mid, paid] (sponsored strictly last). The tampered 153 // service "boosts" the sponsored offer to the top — the classic pay-for-rank. 154 expect(ranked.map((r) => r.offer.id)).toEqual(["cheap", "mid", "paid"]); 155 const boosted: SearchRankResponse = { 156 results: [ranked[2]!, ranked[0]!, ranked[1]!], 157 storeStatuses: STORE_STATUSES, 158 trustSignals: TRUST, 159 }; 160 const { client, auditPath } = await connectedClient(serviceReturning(boosted)); 161 const result = await client.callTool({ name: "search_products", arguments: { text: "sneaker" } }); 162 expect(result.isError ?? false).toBe(false); // results still returned — but loudly flagged 163 164 const structured = result.structuredContent as { 165 rankingVerified: unknown; 166 rankingDivergences?: Array<{ kind: string; position: number; expected: { offerKey: string }; actual: { offerKey: string } }>; 167 }; 168 expect(structured.rankingVerified).toBe(false); 169 expect(structured.rankingDivergences).toBeDefined(); 170 expect(structured.rankingDivergences![0]).toEqual({ 171 kind: "order_mismatch", 172 position: 1, 173 expected: { offerKey: "ebay:cheap", score: ranked[0]!.score }, 174 actual: { offerKey: "ebay:paid", score: ranked[2]!.score },
▸已知边界 / 失败模式
client/src/server.ts:1027和client/src/server.ts:1312都拒绝同一个 offerId 跨 store 的模糊选择,避免 silently pick。client/src/server.ts:1044与packages/checkout/src/orchestrator.ts:65双重拒绝 browser observation 直接进入购买。client/src/server.ts:1580规定 no_rail 不烧 nonce,但 verification 后 rail 失败会消耗授权,偏向防重复付款。packages/orders/src/parser.ts:45插件异常会降级到 unparsed,packages/orders/src/store.ts:176保留记录,不吞邮件。
亮点:交易权限被拆成可审查接口
client/src/authorization.ts:315 写入文件,client/src/authorization.ts:330 设置 0600,MCP 只返回 summary。remote/src/mcp-server.ts:21 明确不包含授权、checkout、watch、profile、orders;remote/src/mcp-server.ts:199 仍复用排序校验。57export function readAuditPage( 58 path: string, 59 opts: { page?: number; pageSize?: number; chunkSize?: number } = {}, 60): AuditPage { 61 const pageSize = Math.max(1, opts.pageSize ?? AUDIT_PAGE_SIZE_DEFAULT); 62 63 let totalEntries = 0; 64 forEachLineFromEnd( 65 path, 66 (line) => { 67 if (line.trim().length > 0) totalEntries += 1; 68 }, 69 opts.chunkSize, 70 ); 71 72 const totalPages = Math.max(1, Math.ceil(totalEntries / pageSize)); 73 const page = Math.min(Math.max(1, opts.page ?? 1), totalPages); 74 // Newest first: page 1 starts at the END of the file. `offset`/`windowEnd` 75 // are ranks counted from EOF (rank 0 = newest non-blank line). 76 const offset = (page - 1) * pageSize; 77 const windowEnd = page * pageSize; 78 79 const rawLines: string[] = [];
58 /** Set when deny/allow is authoritative — the engine can skip probes entirely. */ 59 shortCircuit: boolean; 60} 61 62/** Curation layer: instant, live, no I/O. Only the verifiable DOMAIN is matched. */ 63function evaluateSeed(seed: TrustSeed, domain: string): SeedEvaluation { 64 const denied = findEntry(seed.deny, domain); 65 if (denied) { 66 return { inputs: { denyHit: true }, evidence: [{ source: "seed-list", detail: `deny-listed: ${denied.detail}` }], shortCircuit: true }; 67 } 68 const allowed = findEntry(seed.allow, domain); 69 if (allowed) { 70 return { inputs: { allowHit: true }, evidence: [{ source: "seed-list", detail: `allow-listed: ${allowed.detail}` }], shortCircuit: true }; 71 } 72 const platform = findEntry(seed.knownPlatformDomains, domain); 73 if (platform) { 74 return { inputs: { platformHit: true }, evidence: [{ source: "platform-heuristic", detail: platform.detail }], shortCircuit: false }; 75 } 76 return { inputs: {}, evidence: [], shortCircuit: false }; 77} 78 79function toSignal(merchant: Merchant, inputs: TrustDerivationInputs, evidence: TrustEvidence[]): TrustSignal { 80 const { level } = deriveTrustLevel(inputs); 81 // Evidence is non-empty by construction (schema min(1)); a merchant with no 82 // curation and no measured signals still carries the explicit unknown floor. 83 const lines = 84 evidence.length > 0
技术亮点之外,别忽略这些
风险主要不是“代码没有防线”,而是工程集成时容易误解这些防线。client/src/checkout-wiring.ts:57 只有配置 ACP merchants 才启用 ACP rail,client/src/checkout-wiring.ts:73 默认还会加入 cart-permalink handoff;client/src/server.ts:1512 说明一个 mandate 只授权一次尝试。如果宿主 Agent UI 没有清晰呈现 pending/approved/declined 状态,安全语义会被用户体验稀释。
client/src/server.ts:1409 仍要求宿主向用户索取 code;宿主 UI 不清晰会造成误操作。remote/src/mcp-server.ts:178 对 upstream failure 只能结构化返回,无法替用户补齐商品来源。service/src/trust/engine.ts:119 预算内探测,超时会返回 unknown;这是诚实失败,不是完整风险画像。packages/orders/src/parser.ts:31 逐个插件尝试,未知格式会进入 unparsed,自动化覆盖度取决于插件库。