本地证据只展示,审计与订单图分开落盘 深度解析
这一页拆 NorthCinder 的可追责层:trust engine 控制冷探测预算,本地历史只做 display evidence,audit log append-only,订单图不篡改 checkout log。
导入链与契约
trust engine 在 service/src/trust/engine.ts:44 定义默认 TTL,service/src/trust/engine.ts:63 定义 800ms 预算,service/src/trust/engine.ts:92 先看 allow/deny/platform seed,service/src/trust/engine.ts:119 再按预算冷探测。这说明搜索路径优先稳定响应,而不是阻塞等完整风控。
44} 45 46function domainMatches(domain: string, seedDomain: string): boolean { 47 const d = domain.toLowerCase(); 48 const s = seedDomain.toLowerCase(); 49 return d === s || d.endsWith(`.${s}`); 50} 51function findEntry(entries: TrustSeedEntry[], domain: string): TrustSeedEntry | undefined { 52 return entries.find((e) => domainMatches(domain, e.domain)); 53} 54 55interface SeedEvaluation { 56 inputs: Pick<TrustDerivationInputs, "allowHit" | "denyHit" | "platformHit">; 57 evidence: TrustEvidence[]; 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
核心实现,逐段拆
▸audit log:追加失败即 fail closed
audit log 的注释在 client/src/audit-log.ts:1 说明 append-only,client/src/audit-log.ts:40 定义 audit 类型,client/src/audit-log.ts:57 append 时 mkdir 0700,client/src/audit-log.ts:62 追加 JSONL,client/src/audit-log.ts:64 若 chmod/write 失败直接抛错。
1/** 2 * Append-only local audit log (spec §2 "user-auditable", §4.5): every search, 3 * ranking (with its machine-readable reasons), mandate issuance/approval, and 4 * checkout attempt lands here as one JSONL line in the user's config dir. 5 * This file is what makes "neutral" PROVABLE to the user — so writes fail 6 * CLOSED: if the trail cannot be written, the operation that needed auditing 7 * reports an error instead of proceeding un-audited. 8 * 9 * The log is never truncated or rewritten by this module; `append` is the 10 * only operation. 11 */ 12import { appendFileSync, chmodSync, existsSync, mkdirSync } from "node:fs"; 13import { join } from "node:path"; 14import { forEachLineFromEnd } from "./bounded-tail-reader.js"; 15import { runFsOp } from "./fs-error-sanitizer.js"; 16 17export const AUDIT_LOG_FILENAME = "audit.jsonl"; 18 19export interface AuditEvent extends Record<string, unknown> { 20 /** e.g. "search" | "trust" | "authorization_requested" | "authorization_approved" 21 * | "authorization_denied" | "checkout_attempt" | "checkout_result" */ 22 type: string; 23} 24 25export interface AuditLog { 26 readonly path: string; 27 /** Appends one JSONL line ({at: ISO timestamp, ...event}). Throws on I/O failure. */ 28 append(event: AuditEvent): void; 29} 30 31/** One page of the audit trail (local UI dashboard browser — read side only). */ 32export interface AuditPage { 33 /** Entries NEWEST FIRST. An unparsable line surfaces as { raw } — never hidden. */ 34 entries: Array<Record<string, unknown>>; 35 page: number; 36 pageSize: number; 37 totalEntries: number; 38 totalPages: number; 39} 40 41export const AUDIT_PAGE_SIZE_DEFAULT = 50; 42 43/** 44 * Paged, read-only view over the append-only JSONL trail, newest first 45 * (page 1 = the most recent events). Purely a READER: it never writes, 46 * truncates, or rewrites — the log's only mutation stays `append`. 47 * 48 * BOUNDED reader (local UI follow-up fix): this never does 49 * `readFileSync(path).split("\n")` — that loads the ENTIRE log into one 50 * string and one array before paginating, and the log only grows (one line 51 * per search/auth event, forever). Instead it walks the file BACKWARD in 52 * fixed-size chunks via `forEachLineFromEnd`, so memory stays bounded to 53 * ~one chunk regardless of log size — a first pass counts total non-blank 54 * lines (needed for totalPages), a second stops early once it has collected 55 * this page's window. 56 */ 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[] = [];
▸订单图:邮件解析与 checkout log 分离
orders store 在 packages/orders/src/store.ts:8 声明与 checkout order-store 相邻而非扩展,packages/orders/src/store.ts:100 读取 graph,packages/orders/src/store.ts:110 以 tmp+rename 持久化,packages/orders/src/store.ts:136 保留 unparsed 邮件,packages/orders/src/store.ts:153 把 checkout record 只读适配为 Order。
95export function createOrderGraphStore(configDir: string): OrderGraphStore { 96 const path = join(configDir, ORDER_GRAPH_FILENAME); 97 const unparsedPath = join(configDir, UNPARSED_FILENAME); 98 const processedPath = join(configDir, PROCESSED_MESSAGES_FILENAME); 99 100 function loadGraph(): OrderGraphFile { 101 if (!existsSync(path)) return { ...EMPTY_GRAPH, orders: {}, shipments: {}, returnWindows: {} }; 102 try { 103 const raw = JSON.parse(readFileSync(path, "utf8")) as OrderGraphFile; 104 return { version: 1, orders: raw.orders ?? {}, shipments: raw.shipments ?? {}, returnWindows: raw.returnWindows ?? {} }; 105 } catch { 106 throw new Error(`${BRAND_NAME} orders: ${path} is not valid JSON — refusing to touch it (fix or remove the file)`); 107 } 108 } 109 110 function persistGraph(graph: OrderGraphFile): void { 111 mkdirSync(configDir, { recursive: true, mode: 0o700 }); 112 const tmp = `${path}.tmp`; 113 writeFileSync(tmp, `${JSON.stringify(graph, null, 2)}\n`, { mode: 0o600 }); 114 chmodSync(tmp, 0o600); 115 renameSync(tmp, path); 116 } 117 118 function loadProcessed(): Set<string> { 119 if (!existsSync(processedPath)) return new Set(); 120 try { 121 const raw = JSON.parse(readFileSync(processedPath, "utf8")) as { messageIds: string[] }; 122 return new Set(raw.messageIds ?? []); 123 } catch { 124 return new Set(); 125 } 126 } 127 128 function persistProcessed(ids: Set<string>): void { 129 mkdirSync(configDir, { recursive: true, mode: 0o700 }); 130 const tmp = `${processedPath}.tmp`; 131 writeFileSync(tmp, `${JSON.stringify({ version: 1, messageIds: [...ids] }, null, 2)}\n`, { mode: 0o600 }); 132 chmodSync(tmp, 0o600); 133 renameSync(tmp, processedPath); 134 } 135 136 function appendUnparsed(record: UnparsedEmailRecord): void { 137 mkdirSync(configDir, { recursive: true, mode: 0o700 }); 138 const existed = existsSync(unparsedPath); 139 appendFileSync(unparsedPath, `${JSON.stringify(record)}\n`, { mode: 0o600 }); 140 if (!existed) chmodSync(unparsedPath, 0o600); 141 } 142 143 /** Strips control chars from the free-text fields a hostile mail/caller can influence (leak/injection discipline — see sanitize.ts). */ 144function sanitizeOrderText(order: Order): Order { 145 return { 146 ...order, 147 merchantName: sanitizeTextField(order.merchantName), 148 ...(order.orderNumber !== undefined ? { orderNumber: sanitizeTextField(order.orderNumber) } : {}), 149 items: order.items.map((item) => ({ ...item, title: sanitizeTextField(item.title) })), 150 }; 151} 152 153function orderRecordToOrder(record: OrderRecord): Order { 154 return { 155 id: record.orderId, 156 merchantName: record.merchantId, 157 merchantDomain: record.merchantId, 158 orderDate: record.createdAt, 159 items: [{ title: record.mandate.intent, quantity: 1 }], 160 total: record.mandate.constraints.maxAmount, 161 status: record.status === "completed" ? "confirmed" : "unknown", 162 source: { kind: "checkout", orderId: record.orderId }, 163 }; 164 }
关键流程
server 的 client/src/server.ts:1219 获取 service trust 后,client/src/server.ts:1225 才追加 localEvidence,client/src/server.ts:1228 审计 trust 事件。订单工具测试在 client/test/order-tools.test.ts:125 用真实 MCP client/server 检查 drop-dir 重扫、get_order 和 ICS 刷新。
▸验证联动
这页把实现和测试一起看:源码引用说明控制点,测试引用说明这些控制点已经被行为化约束,避免只把 README 的安全承诺复述成架构结论。
边界 / 失败模式
service/test/trust-engine.test.ts:73覆盖新鲜缓存不触发 probe,service/test/trust-engine.test.ts:101覆盖 timeout 返回 unknown。packages/orders/src/parser.ts:31插件逐个尝试,packages/orders/src/parser.ts:48捕获异常并继续,避免坏插件吞掉邮件。client/test/order-tools.test.ts:151检查响应不泄漏 configDir,client/test/order-tools.test.ts:155检查 order_read audit。