import type { QuestionAnswer } from "@sema-agent/core"; import type { ApprovalHmacKey } from "./auth-keys.js"; /** * The canonical message an approval MAC signs — a fixed-order, JSON-escaped tuple so no field value (an id, a * hash, a free-text note) can ambiguate the boundary with the next (same discipline as the approvals-stream diff * key). 🔴 Bound to CLIENT-VISIBLE values only: `sessionId` (in the /decide URL) + `boundCallId`/`boundInputHash` * (surfaced by listPending) + `reason` and `permissionModeAfter` (the caller's own body). It must NOT include the checkpointToken — that is * the unexposed resume credential the client can never see (it resumes by sessionId), so a checkpointToken-bound * message would be uncomputable by the signer. * * ── `reason` 入签(三方裁 (a):cli [ref] / core [ref]§四 / server;clay「干净切」令 [ref])───────────── * `reason` 是随决定落账的自由文本(审计面的一部分)。它此前**在信封之外** ⇒ 能改线上字节的一方(反代 / * 被接管的客户端 —— 正是 HMAC 要防的风险模型)可以在操作员批准同一个动作的那次决定里,把落账理由换成 * 别的:动作没变,**审计记录变了**。三方裁定纳入签名载荷,不留 `reasonUnsigned` 宽限代际(未上生产)。 * * ── 🔴 **两把绑定的分工**(S-433 F1 立此,别再把它们当成一回事)───────────────────────────────── * `principal-jwt.ts` 的 `approvalBnd`(JWT `cnf.bnd`)只哈希**前三位** `[sessionId, boundCallId, boundInputHash]` * —— 它绑的是**「哪一张卡」**(这份身份凭据被签发去决哪一次待决动作),所以决定内容改了它**不该**变。 * 本文件的 HMAC 信封绑的是**「决了什么」**—— 决定本身的每一个会改变后果的字段都在里面(`decision`、 * `reason`,以及 S-433 起的 `permissionModeAfter`)。⇒ 「同一张卡上把 approve 的档偷偷调宽」这件事, * 结构上只有 HMAC 这一把拦得住;往 `cnf.bnd` 里加位既不解决它,还会把一份合法 JWT 绑死在一个它签发时 * 根本不知道的值上。**本次(S-433 F1)只动 HMAC 这一把,`approvalBnd` 一个字节不动。** * * ── 🔴 字节构造规则(权威定义;跨仓签名器照此实现,docs/ASSISTANT-WIRE-CONTRACT.md §6.2 同源)────────── * 被 HMAC 的是下面这个字符串的 **UTF-8 字节**: * 1. **恒 5 元素、定序**的紧凑 JSON 数组(元素间**无空格**): * `[sessionId, boundCallId|null, boundInputHash|null, decision, reason|null]` * —— 恒定长度是刻意的:签名器里少一个"有 reason 才追加"的条件分支,就少一类跨实现漂移。 * 🔴 **唯一的例外是尾位**(S-452,server 7.87.0 起是**一只扩展对象**;7.86.0 的裸尾位形已删除): * 对扩展键**闭集** {@link APPROVAL_ENVELOPE_EXT_KEYS}(字典序即声明序)逐键判 `!== undefined`, * 在场的收进一只对象 `ext`;**`ext` 为空 ⇒ 一个字节都不追加**(消息与 7.85.0 逐字节相同), * 非空 ⇒ 追加为第 6 位 `[…head, ext]`。`JSON.stringify` 对 `ext` 的键序 = 插入序 = 闭集字典序, * 所以规范化无歧义,**与调用方给键的顺序无关**。 * 为什么不继续用「裸的第 6、7 格」(S-433 的形):两个扩展位都可选 ⇒ 只有 `answer` 在场时它落在 * 第 6 格、与 `permissionModeAfter` 撞位,靠类型猜是谁 = 签名歧义。扩展对象让「在场的是谁」自带 * 名字,而且以后再加决裁字段不动数组形(规则集不再长)。 * 向后兼容仍然成立的那一半:**不带任何扩展键的体**,字节与 7.85.0 逐字相同 ⇒ 不签这些位的既有 * 签名器照常验过。带了扩展键而签名方没签(或按 7.86.0 的裸尾位形签)⇒ MAC 不匹配 ⇒ 既有 * `401 approval_mac_invalid`,不铸第二种拒绝形。 * 1b. **`answer` 的被签值 = 位置投影**(不是请求体上那个对象){@link projectQuestionAnswer}: * `answers.map(i => [i.header, i.selected, i.note ?? null])`。**不做通用规范化、不做 Unicode * 归一化、不排序**——投影只搬这三位,于是数组序天然保持、异常键名(`__proto__`)结构上不可达, * 而「多余键可以不进签名却照样转给引擎」这个洞也一并消失(签的与转发的是同一份:引擎收到的是 * {@link questionAnswerFromProjection} 由同一份投影重建的对象)。 * 2. 缺席 ⇒ `null`;**`""`(空串)是与 `null` 不同的被签值**(否则"把理由抹成空"不改变 MAC)。 * 3. 字符串转义 = RFC 8259 最小集:`"`→`\"`、`\`→`\\`、U+0000–U+001F → `\n`/`\t`/`\uXXXX`。 * 4. **非 ASCII 原样输出 UTF-8,不转 `\uXXXX`**。⚠️ Python 的 `json.dumps` 缺省 `ensure_ascii=True` 会踩 * —— 必须 `json.dumps(t, ensure_ascii=False, separators=(",", ":"))`。 * 5. **`<` `>` `&` 不转义**。⚠️ Go 的 `json.Marshal` 缺省 HTML 转义会踩 —— 必须 * `enc := json.NewEncoder(w); enc.SetEscapeHTML(false)`(并去掉它追加的换行)。 * 6. 孤代理项按 ES2019 well-formed 语义转成 `\udXXX`(JS 原生行为,其它语言按同规则)。 * 7. MAC = HMAC-SHA256(上述字节),**小写十六进制**上 wire。 * 逐字节黄金向量(含中文/引号/换行/制表/`<`/`&`/emoji)钉在 `test/approval-hmac.test.ts`,签名器对拍用。 * * 之所以到今天才把规则写死:此前四个字段全是**受限字母表**(uuid / 调用 id / 十六进制 / 枚举),转义怎么 * 写都一样、规则不写也测不出来;`reason` 是第一个进入签名载荷的自由文本,转义从"无所谓"变成"承重"。 */ /** * `answer` 的**位置投影** —— 被 HMAC 签的那一份,也是转发给引擎的那一份的唯一来源。 * * 形:`[header, selected, note ?? null]` 逐项、按 `answers` 的**原序**。三条性质都是它白送的,不是 * 额外规则(设计稿 §3.5 拿实现证伪了「通用递归按键排序」那一版): * · **数组序保持** —— 投影不排序,`selected` 换序 ⇒ 不同 MAC; * · **不做 Unicode 归一化** —— 只搬字符串,按收到的码点签; * · **异常键名不可达** —— 投影不读任意键,`__proto__` / `constructor` 这类名字进不来。 * * 🔴 为什么不直接签请求体上那个对象:本仓的形状校验器对三个具名键严格,但**键序**由调用方决定 * (`JSON.stringify` 吃插入序)⇒ 两份语义相同的体会签出两串字节。投影把「哪些位、什么序」定死在 * 一个属主里,签名器(在别的仓)照抄这一行即可。 */ export type QuestionAnswerProjection = readonly (readonly [header: string, selected: readonly string[], note: string | null])[]; export declare function projectQuestionAnswer(a: QuestionAnswer): QuestionAnswerProjection; /** 投影 → 引擎收的那个对象。**「签的 = 转发的」的兑现点**:路由不把请求体上的 `answer` 原样上送, * 而是先投影(签它)、再由本函数重建(送它)—— 中间没有第二条路径,所以「签了 A 却转发了 A′」 * 在结构上不可能。`note` 为 `null` ⇒ **不造键**(与 core `QuestionAnswerItem.note?` 的缺席同义)。 */ export declare function questionAnswerFromProjection(p: QuestionAnswerProjection): QuestionAnswer; /** * 信封的**扩展键闭集**(字典序 = 声明序 = `ext` 的 JSON 键序)。 * * 加一个决裁字段进签名载荷 = 在 {@link ApprovalEnvelope} 上加一位 + 在本表加一个词;两者只做一半 ⇒ * 下面那两条编译期门当场红。**不许有第三种在场判**:恒 `!== undefined`(不是真值判、也不是 `?? null` * 折叠)—— `null` 与缺席在扩展位上是两个不同的被签值(「签名方明说这次不动」≠「签名方不知道有这个键」)。 */ export declare const APPROVAL_ENVELOPE_EXT_KEYS: readonly ["answer", "permissionModeAfter"]; export type ApprovalEnvelopeExtKey = (typeof APPROVAL_ENVELOPE_EXT_KEYS)[number]; /** The canonical envelope both {@link approvalHmacMessage} (the signer's/verifier's shared byte-rule) and * {@link verifyApprovalHmac} take — ONE named type so the two signatures cannot silently drift into two * structurally-similar-but-not-identical inline shapes (drift there would only surface at RUNTIME as a MAC * mismatch, never at compile time — see the file-header canonical-message note for why each field's presence * is load-bearing). */ export interface ApprovalEnvelope { sessionId: string; boundCallId?: string | null; boundInputHash?: string | null; decision: string; reason?: string | null; /** * 🔴 **S-452(7.87.0)—— 操作员对一次 AskUserQuestion 的回答进签名载荷。** * * 它此前**不在**信封里 ⇒ 一个能改写线上字节的中间人(反代 / 被接管的客户端 —— 正是 HMAC 的风险 * 模型)可以在操作员签过的那次批准里只换 `answer`:动作没变、MAC 照过,**模型收到的答案变了**。 * 与 `reason`(改审计记录)、`permissionModeAfter`(改手)同一条判据的第三格:**凡改变续跑能做 * 什么 / 落什么账的决裁字段都在签名信封里**。 * * 值是 {@link projectQuestionAnswer} 的产物,不是请求体上那个对象(理由见该函数)。 */ answer?: QuestionAnswerProjection; /** * 🔴 **S-433 合并复审 F1 [high]**(7.86.0 立,S-452 把它从裸尾位挪进扩展对象)—— `plan_review` 的 * `permissionModeAfter` 选的是**批准之后这条 run 有没有手**(`acceptEdits` = 工作目录内的写不再逐次 * 征询)。它铸下来时没有进签名载荷 ⇒ 中间人可以把操作员签的那次 `default` 批准原地改成 `acceptEdits`: * 动作没变、MAC 照过,**权限变了**。 * * 🔴 编码位置在 7.87.0 变了(**BREAKING vs 7.86.0**,零双读):7.86.0 把它裸追加为第 6 位, * 7.87.0 起它是扩展对象里的一个具名键。7.86.0 形已签的证明在 7.87.0 上一律 `401 approval_mac_invalid` * —— 证明是瞬时物不是持久数据,没有迁移面,消费方的 401 就是通知(宪法「hard breaking 三句」)。 */ permissionModeAfter?: string | null; } export declare function approvalHmacMessage(env: ApprovalEnvelope): string; /** 签名载荷里 `reason` 的字符上限。**拒**而不是截 —— 截断在这条路径上**结构上不可行**:服务端截过的字节 * 与签名方签的字节必然不同,MAC 恒不匹配,"截断"只会把一个可诊断的 413 变成一个费解的 401。 * 取 4096 与本仓既有的人写文本上限同档(`MAX_ELICIT_MESSAGE_CHARS` / `MAX_AGENT_TEXT_CHARS`)。 */ export declare const MAX_APPROVAL_REASON_CHARS = 4096; /** * [ref] D-G: verify the INTEGRITY of an approval-decision envelope. Returns true iff `mac` (hex) equals * HMAC-SHA256(canonical-envelope) under ANY key in the set — the `kid`-hinted key first, then the rest (so a MAC * signed with a key being rotated out still verifies during the overlap). `timingSafeEqual` avoids a timing * oracle. An empty key-set ⇒ false (caller decides whether absence = skip-because-inactive or fail-closed). */ export declare function verifyApprovalHmac(env: ApprovalEnvelope, mac: string, kid: string | undefined, keys: ApprovalHmacKey[]): boolean; //# sourceMappingURL=approval-hmac.d.ts.map