@sema-agent/server 7.75.4 → 7.76.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/USAGE.md +29 -13
- package/dist/boot/reapers.d.ts +5 -1
- package/dist/boot/reapers.js +6 -1
- package/dist/boot/shutdown.js +5 -1
- package/dist/config-catalog.js +1 -1
- package/dist/config-types.d.ts +9 -7
- package/dist/http/routes/approvals-assistant.js +42 -2
- package/dist/http/server.d.ts +5 -1
- package/dist/main.js +3 -1
- package/dist/parent-watch.d.ts +55 -23
- package/dist/parent-watch.js +38 -23
- package/dist/plugins/approval-nonce-store.d.ts +94 -0
- package/dist/plugins/approval-nonce-store.js +126 -0
- package/dist/plugins/permission-rule-store-sql.d.ts +1 -30
- package/dist/plugins/permission-rule-store-sql.js +2 -10
- package/dist/plugins/pg-pool.js +8 -0
- package/dist/plugins/pg-session-storage.js +1 -0
- package/dist/plugins/sql-driver.d.ts +27 -0
- package/dist/plugins/sql-driver.js +12 -0
- package/dist/plugins/store-backend.d.ts +6 -0
- package/dist/plugins/store-backend.js +7 -0
- package/dist/plugins/tidb-pool.js +26 -1
- package/dist/plugins/tidb-session-storage.d.ts +4 -0
- package/dist/plugins/tidb-session-storage.js +7 -2
- package/dist/principal-jwt.d.ts +72 -12
- package/dist/principal-jwt.js +24 -8
- package/dist/tool-approval.d.ts +2 -1
- package/dist/tool-approval.js +8 -1
- package/dist/trace/redact.d.ts +7 -4
- package/dist/trace/redact.js +64 -33
- package/package.json +2 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { atomicWriteFile, ensureDir, readJsonlRecords, AppendLog } from "@sema-agent/core";
|
|
3
|
+
import { mysqlDriver, pgDriver, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
|
|
4
|
+
import { isDupKeyError } from "./sql-errors.js";
|
|
5
|
+
export const APPROVAL_NONCE_TABLE = "approval_nonce";
|
|
6
|
+
export const MAX_APPROVAL_NONCE_CHARS = 190;
|
|
7
|
+
export class SqlApprovalNonceStore {
|
|
8
|
+
db;
|
|
9
|
+
constructor(db) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
}
|
|
12
|
+
q(tidb, pg) {
|
|
13
|
+
return this.db.dialect === "tidb" ? tidb : pg;
|
|
14
|
+
}
|
|
15
|
+
async nowMs() {
|
|
16
|
+
return sqlDbNowMs(this.db, APPROVAL_NONCE_TABLE);
|
|
17
|
+
}
|
|
18
|
+
async consume(sessionId, nonce, expiresAtMs) {
|
|
19
|
+
try {
|
|
20
|
+
await this.db.query(this.q(`INSERT INTO ${APPROVAL_NONCE_TABLE} (session_id, nonce, consumed_at_ms, expires_at_ms) VALUES (?,?,${SQL_DB_NOW_MS.tidb},?)`, `INSERT INTO ${APPROVAL_NONCE_TABLE} (session_id, nonce, consumed_at_ms, expires_at_ms) VALUES ($1,$2,${SQL_DB_NOW_MS.pg},$3)`), [sessionId, nonce, expiresAtMs]);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (isDupKeyError(this.db.dialect, err))
|
|
25
|
+
return false;
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async reapExpired() {
|
|
30
|
+
const res = await this.db.query(this.q(`DELETE FROM ${APPROVAL_NONCE_TABLE} WHERE expires_at_ms <= ${SQL_DB_NOW_MS.tidb}`, `DELETE FROM ${APPROVAL_NONCE_TABLE} WHERE expires_at_ms <= ${SQL_DB_NOW_MS.pg}`), []);
|
|
31
|
+
return res.affected;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class TiDBApprovalNonceStore extends SqlApprovalNonceStore {
|
|
35
|
+
constructor(pool) {
|
|
36
|
+
super(mysqlDriver(pool));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export class PgApprovalNonceStore extends SqlApprovalNonceStore {
|
|
40
|
+
constructor(pool) {
|
|
41
|
+
super(pgDriver(pool));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const keyOf = (sessionId, nonce) => JSON.stringify([sessionId, nonce]);
|
|
45
|
+
export class MemoryApprovalNonceStore {
|
|
46
|
+
rows = new Map();
|
|
47
|
+
async nowMs() {
|
|
48
|
+
return Date.now();
|
|
49
|
+
}
|
|
50
|
+
async consume(sessionId, nonce, expiresAtMs) {
|
|
51
|
+
const k = keyOf(sessionId, nonce);
|
|
52
|
+
if (this.rows.has(k))
|
|
53
|
+
return false;
|
|
54
|
+
this.rows.set(k, expiresAtMs);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
async reapExpired() {
|
|
58
|
+
const now = await this.nowMs();
|
|
59
|
+
let n = 0;
|
|
60
|
+
for (const [k, exp] of this.rows) {
|
|
61
|
+
if (exp <= now) {
|
|
62
|
+
this.rows.delete(k);
|
|
63
|
+
n++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class FileApprovalNonceStore {
|
|
70
|
+
path;
|
|
71
|
+
tmpDir;
|
|
72
|
+
rows = new Map();
|
|
73
|
+
log;
|
|
74
|
+
constructor(root) {
|
|
75
|
+
const dir = join(root, "approval-nonces");
|
|
76
|
+
ensureDir(dir);
|
|
77
|
+
this.tmpDir = join(dir, ".tmp");
|
|
78
|
+
ensureDir(this.tmpDir);
|
|
79
|
+
this.path = join(dir, "consumed.jsonl");
|
|
80
|
+
for (const r of readJsonlRecords(this.path)) {
|
|
81
|
+
if (!r || typeof r.s !== "string" || typeof r.n !== "string" || typeof r.e !== "number")
|
|
82
|
+
continue;
|
|
83
|
+
this.rows.set(keyOf(r.s, r.n), { s: r.s, n: r.n, e: r.e });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
logFor() {
|
|
87
|
+
return (this.log ??= new AppendLog(this.path));
|
|
88
|
+
}
|
|
89
|
+
async nowMs() {
|
|
90
|
+
return Date.now();
|
|
91
|
+
}
|
|
92
|
+
async consume(sessionId, nonce, expiresAtMs) {
|
|
93
|
+
const k = keyOf(sessionId, nonce);
|
|
94
|
+
if (this.rows.has(k))
|
|
95
|
+
return false;
|
|
96
|
+
const record = { s: sessionId, n: nonce, e: expiresAtMs };
|
|
97
|
+
this.logFor().append(record, true);
|
|
98
|
+
this.rows.set(k, record);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
async reapExpired() {
|
|
102
|
+
const nowMs = await this.nowMs();
|
|
103
|
+
const survivors = [];
|
|
104
|
+
let removed = 0;
|
|
105
|
+
for (const record of this.rows.values()) {
|
|
106
|
+
if (record.e <= nowMs) {
|
|
107
|
+
removed++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
survivors.push(record);
|
|
111
|
+
}
|
|
112
|
+
if (removed === 0)
|
|
113
|
+
return 0;
|
|
114
|
+
this.log?.closeForSwap();
|
|
115
|
+
atomicWriteFile(this.tmpDir, this.path, survivors.map((r) => `${JSON.stringify(r)}\n`).join(""));
|
|
116
|
+
for (const [k, record] of [...this.rows])
|
|
117
|
+
if (record.e <= nowMs)
|
|
118
|
+
this.rows.delete(k);
|
|
119
|
+
return removed;
|
|
120
|
+
}
|
|
121
|
+
dispose() {
|
|
122
|
+
this.log?.close();
|
|
123
|
+
this.log = undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=approval-nonce-store.js.map
|
|
@@ -250,30 +250,6 @@ export type RuleTicketReclaimResult = {
|
|
|
250
250
|
* 廉价,重铸一张即可(core [ref] 建议的姿态)。
|
|
251
251
|
*/
|
|
252
252
|
export declare const RULE_IMPORT_TICKET_GC_GRACE_MS: number;
|
|
253
|
-
/**
|
|
254
|
-
* [ref] A7 —— **数据库自己的钟**(毫秒),两方言各一句(codex 交叉轮 R5-F1,验真后修)。
|
|
255
|
-
*
|
|
256
|
-
* 用处:`settled_at_ms` 的写入与清扫「已落定」那一支的截止**必须在同一个钟域**。它们此前各拿各副本的
|
|
257
|
-
* 墙钟:落定那台若比跑清扫那台慢过一个宽限(本仓 [ref]③ 记过一次真实的 11 分钟 NTP 纠正),一份**刚刚**
|
|
258
|
-
* 生出来的权威回执下一拍就被删,而"落定之后必有一个完整的回放窗"是本批新许下的承诺 —— 它不能建立在
|
|
259
|
-
* 「两台机器的钟差不多」这条没人声明过的假设上。数据库的钟是这两台副本**唯一**共享的那口钟。
|
|
260
|
-
*
|
|
261
|
-
* 🔴 射程收官([ref] / [ref],2026-09-02):**这张票上的每一个时刻量都走本钟**,一个不剩 ——
|
|
262
|
-
* `consumed_at_ms` / `settled_at_ms`(R8-F2 起)、`expires_at_ms` / `created_at_ms`(本批)。
|
|
263
|
-
*
|
|
264
|
-
* 本批补上的是 **TTL 轴**,也是最后一条:铸票 `expires_at_ms = <本钟> + ttl`、判定
|
|
265
|
-
* `expires_at_ms > <本钟>`、清扫 `expires_at_ms <= <本钟> − 宽限`。修前那一形是**两台副本各出一半**的
|
|
266
|
-
* 比较(铸票副本的墙钟定到期时刻,兑付副本的墙钟做判定),于是 TTL 在集群上**不单调**:同一张票在钟慢
|
|
267
|
-
* 的那台上还活着、在钟快的那台上已经死了;极端形是「出生即过期」(铸票副本慢过一个 TTL ⇒ 票铸出来就
|
|
268
|
-
* 没人兑得动)。这不需要故障就能发作 —— 负载均衡把 prepare 与 redeem 分到两台副本是**常态**。
|
|
269
|
-
* 上一批的进程内单调锚只覆盖「同一副本铸、同一副本兑」那一格,连同它的台账在本批一并删除。
|
|
270
|
-
*
|
|
271
|
-
* 判定读不到本钟 ⇒ 语句抛错 ⇒ 调用方按不确定失败处置(fail-closed:绝不退回本地墙钟放行)。
|
|
272
|
-
*/
|
|
273
|
-
export declare const SQL_DB_NOW_MS: {
|
|
274
|
-
readonly tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)";
|
|
275
|
-
readonly pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint";
|
|
276
|
-
};
|
|
277
253
|
/** prepare 时刻候选集的规范摘要(载荷绑定 F1 ④ 的判据)。纯数据 ⇒ `build*`。
|
|
278
254
|
*
|
|
279
255
|
* 🔴 入参型收到 core 的 `RuleCandidate`(core 7.9.0 [ref] 起含 `behavior`)—— 规则的身份是
|
|
@@ -294,12 +270,7 @@ export declare class SqlRuleImportTicketStore {
|
|
|
294
270
|
private readonly db;
|
|
295
271
|
constructor(db: SqlDriver);
|
|
296
272
|
private q;
|
|
297
|
-
/**
|
|
298
|
-
* 问一次**数据库自己的钟**(毫秒)。铸票用它当基准,于是到期时刻与后续每一次判定同域。
|
|
299
|
-
*
|
|
300
|
-
* 读不出数 ⇒ **响亮抛**([ref] fail-closed):静默兜个本地 `Date.now()` 正是本批要删掉的那条腿,
|
|
301
|
-
* 而一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值。
|
|
302
|
-
*/
|
|
273
|
+
/** 问一次**数据库自己的钟**(毫秒,{@link sqlDbNowMs})。铸票用它当基准,于是到期时刻与后续每一次判定同域。 */
|
|
303
274
|
private dbNowMs;
|
|
304
275
|
/**
|
|
305
276
|
* 铸一张票。
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { applyTombstones, assertDeleteDeltaCarriesNoAdd, assertRedemptionNotQuarantined, assertWriteDeltaScopeDurable, foldDelta, parseRuleText, readRowBehavior, screenRuleSyncState, PERMISSION_RULE_WRITER, RULE_BEHAVIORS, RULE_SYNC_DROP_CODES, EMPTY_DURABLE_RULE_PARTITION, } from "@sema-agent/core";
|
|
4
|
-
import { dialectProtocolJsonEncoder } from "./sql-driver.js";
|
|
4
|
+
import { dialectProtocolJsonEncoder, sqlDbNowMs, SQL_DB_NOW_MS } from "./sql-driver.js";
|
|
5
5
|
import { isMissingColumnError } from "./sql-errors.js";
|
|
6
6
|
import { canonicalJson } from "../canonical-json.js";
|
|
7
7
|
import { EDITED_RULE_BREADTH_WARNING_CODES, PERSISTED_RULE_MATCHES, isPersistedRuleTool, RULE_APPROVAL_RECORD_SCHEMA, UNCOVERED_SEGMENT_REASONS } from "../permission-rule-vocab.js";
|
|
@@ -624,10 +624,6 @@ export class SqlRuleApprovalRecordStore {
|
|
|
624
624
|
}
|
|
625
625
|
}
|
|
626
626
|
export const RULE_IMPORT_TICKET_GC_GRACE_MS = 10 * 60_000;
|
|
627
|
-
export const SQL_DB_NOW_MS = {
|
|
628
|
-
tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)",
|
|
629
|
-
pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint",
|
|
630
|
-
};
|
|
631
627
|
export function buildRulePayloadHash(candidates) {
|
|
632
628
|
return createHash("sha256").update(canonicalJson(candidates), "utf8").digest("hex");
|
|
633
629
|
}
|
|
@@ -640,11 +636,7 @@ export class SqlRuleImportTicketStore {
|
|
|
640
636
|
return this.db.dialect === "tidb" ? tidbSql : pgSql;
|
|
641
637
|
}
|
|
642
638
|
async dbNowMs() {
|
|
643
|
-
|
|
644
|
-
const n = Number(rows[0]?.["db_now_ms"] ?? Number.NaN);
|
|
645
|
-
if (!Number.isFinite(n))
|
|
646
|
-
throw new Error(`${PERMISSION_RULE_TICKET_TABLE}: the database clock came back unreadable (${String(rows[0]?.["db_now_ms"])})`);
|
|
647
|
-
return n;
|
|
639
|
+
return sqlDbNowMs(this.db, PERMISSION_RULE_TICKET_TABLE);
|
|
648
640
|
}
|
|
649
641
|
async mint(input) {
|
|
650
642
|
const nowMs = await this.dbNowMs();
|
package/dist/plugins/pg-pool.js
CHANGED
|
@@ -125,6 +125,14 @@ export const PG_SCHEMA_STATEMENTS = [
|
|
|
125
125
|
created_at TIMESTAMPTZ(3) NOT NULL,
|
|
126
126
|
PRIMARY KEY (session_id, tool_name)
|
|
127
127
|
)`,
|
|
128
|
+
`CREATE TABLE IF NOT EXISTS approval_nonce (
|
|
129
|
+
session_id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
130
|
+
nonce VARCHAR(190) COLLATE "C" NOT NULL,
|
|
131
|
+
consumed_at_ms BIGINT NOT NULL,
|
|
132
|
+
expires_at_ms BIGINT NOT NULL,
|
|
133
|
+
PRIMARY KEY (session_id, nonce)
|
|
134
|
+
)`,
|
|
135
|
+
`CREATE INDEX IF NOT EXISTS idx_approval_nonce_expires ON approval_nonce (expires_at_ms)`,
|
|
128
136
|
`CREATE TABLE IF NOT EXISTS sendfile_link (
|
|
129
137
|
id VARCHAR(64) COLLATE "C" NOT NULL,
|
|
130
138
|
scope VARCHAR(190) COLLATE "C" NOT NULL,
|
|
@@ -49,6 +49,7 @@ export const PG_SESSION_SCHEMA = [
|
|
|
49
49
|
PRIMARY KEY (session_id, seq)
|
|
50
50
|
)`,
|
|
51
51
|
`CREATE UNIQUE INDEX IF NOT EXISTS uq_session_event_entry ON session_event (session_id, entry_id)`,
|
|
52
|
+
`CREATE INDEX IF NOT EXISTS idx_session_event_type ON session_event (session_id, type, seq)`,
|
|
52
53
|
];
|
|
53
54
|
export async function ensureSchema(pool) {
|
|
54
55
|
for (const stmt of PG_SESSION_SCHEMA)
|
|
@@ -158,6 +158,33 @@ export interface SqlEngineFacts {
|
|
|
158
158
|
/** TiDB's session `tidb_txn_mode`; `null` = this engine has no such indicator (NOT "optimistic"). */
|
|
159
159
|
readonly txnMode: "pessimistic" | null;
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* **数据库自己的钟**(毫秒),两方言各一句 —— 本仓这口钟在 SQL 面的**唯一**铸点([ref] A7 / [ref] /
|
|
163
|
+
* 合并复审 R2 把 S-280 的 nonce 店并进来)。
|
|
164
|
+
*
|
|
165
|
+
* 用处永远是同一条:**一个时刻量的铸造点与它的判定点必须在同一个钟域**。副本各拿各的墙钟时,两台机器
|
|
166
|
+
* 之间的偏差没有任何一条腿强制过上界(本仓 [ref]③ 记过一次真实的 11 分钟 NTP 纠正),于是
|
|
167
|
+
* 「A 铸的东西 B 判着还活着」这类承诺就建立在一条没人声明过的假设上。数据库的钟是一组副本**唯一**
|
|
168
|
+
* 共享的那口钟,所以每一条「谁生谁死」的判决都落到它身上:
|
|
169
|
+
* · 规则导入票(`permission-rule-store-sql.ts`):`expires_at_ms` 的铸造 / 判定 / 清扫三处;
|
|
170
|
+
* · 直连门 plan_review 的消费记录(`approval-nonce-store.ts`):受理窗的判定与记录的清扫。
|
|
171
|
+
*
|
|
172
|
+
* 读不到本钟 ⇒ 语句抛错 ⇒ 调用方按不确定失败处置([ref] fail-closed:**绝不**退回本地墙钟放行)。
|
|
173
|
+
*
|
|
174
|
+
* 它住在 driver 这只叶子模块里,是因为「这个引擎怎么说『现在几点』」是**方言事实**,与任何一只店无关;
|
|
175
|
+
* 放在某一只店里会让下一个需要它的店要么 import 一整只无关的店、要么自己抄一份(抄出来的两份会漂)。
|
|
176
|
+
*/
|
|
177
|
+
export declare const SQL_DB_NOW_MS: {
|
|
178
|
+
readonly tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)";
|
|
179
|
+
readonly pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint";
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* 问一次 {@link SQL_DB_NOW_MS} —— 所有需要「库钟此刻」的店共用这**一份**读法。
|
|
183
|
+
*
|
|
184
|
+
* `subject` 只进错误文案(出问题时运维要知道是哪一张表的判决卡住了)。读不出数 ⇒ **响亮抛**:
|
|
185
|
+
* 一次「钟读不出来」在授权面上是运维事件,不是一个可以猜的默认值,更不是掉回 `Date.now()` 的理由。
|
|
186
|
+
*/
|
|
187
|
+
export declare function sqlDbNowMs(db: SqlDriver, subject: string): Promise<number>;
|
|
161
188
|
export interface SqlDriver extends SqlExec {
|
|
162
189
|
readonly dialect: SqlDialect;
|
|
163
190
|
/**
|
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { pgSafeJsonStringify, pgProtocolJsonStringify } from "./pg-safe-json.js";
|
|
2
|
+
export const SQL_DB_NOW_MS = {
|
|
3
|
+
tidb: "CAST(UNIX_TIMESTAMP(NOW(3)) * 1000 AS SIGNED)",
|
|
4
|
+
pg: "(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint",
|
|
5
|
+
};
|
|
6
|
+
export async function sqlDbNowMs(db, subject) {
|
|
7
|
+
const sql = db.dialect === "tidb" ? `SELECT ${SQL_DB_NOW_MS.tidb} AS db_now_ms` : `SELECT ${SQL_DB_NOW_MS.pg} AS db_now_ms`;
|
|
8
|
+
const { rows } = await db.query(sql, []);
|
|
9
|
+
const n = Number(rows[0]?.["db_now_ms"] ?? Number.NaN);
|
|
10
|
+
if (!Number.isFinite(n))
|
|
11
|
+
throw new Error(`${subject}: the database clock came back unreadable (${String(rows[0]?.["db_now_ms"])})`);
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
2
14
|
function mysqlResult(r) {
|
|
3
15
|
if (Array.isArray(r))
|
|
4
16
|
return { rows: r, affected: 0 };
|
|
@@ -8,6 +8,7 @@ import { type OneTimeMigrationReport } from "./one-time-migrations.js";
|
|
|
8
8
|
import { LocalCheckpointStore } from "./local-checkpoint-store.js";
|
|
9
9
|
import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
|
|
10
10
|
import { type ApprovalExemptionStore } from "./approval-exemption-store.js";
|
|
11
|
+
import { type ApprovalNonceStore } from "./approval-nonce-store.js";
|
|
11
12
|
import { type ApprovalAskStore as ApprovalAskStoreType } from "./approval-ask-store-sql.js";
|
|
12
13
|
import { type MemoryOptOutGrantStore } from "./memory-optout-grant-store-sql.js";
|
|
13
14
|
import { type SendFileLedger } from "./send-file-ledger.js";
|
|
@@ -39,6 +40,7 @@ export type RunStore = TiDBRunStore | PgRunStore | FileRunStore;
|
|
|
39
40
|
* FileResumeAnchorStore is the FILE-backed local twin (P0.5 variant-1; survives restart). */
|
|
40
41
|
export type ResumeAnchorStore = TiDBResumeAnchorStore | PgResumeAnchorStore | FileResumeAnchorStore;
|
|
41
42
|
export type { ApprovalExemptionStore } from "./approval-exemption-store.js";
|
|
43
|
+
export type { ApprovalNonceStore } from "./approval-nonce-store.js";
|
|
42
44
|
/** [ref]([ref] v3.1 §3.0-§3.2 流内审批协议持久层):`ApprovalAskStore` 接口 + 行/补丁类型。
|
|
43
45
|
* 车3 刀 3a 起 `StoreBackend.approvalAsk()` 已在(见接口体的那条注)。 */
|
|
44
46
|
export type { ApprovalAskStore, AskRow, NewAskRow, AskTransitionPatch, DecideAskInput, DecideResult, ExpireResult, BindGateInput, BatchRow, AskDecision } from "./approval-ask-store-sql.js";
|
|
@@ -147,6 +149,10 @@ export interface StoreBackend {
|
|
|
147
149
|
/** Per-session per-toolName approval exemption ("本会话不再询问") — an approval MEMORY the F4
|
|
148
150
|
* ask-gate consults after deny/neverAuto. REQUIRED on all backends (works local, like resumeAnchor). */
|
|
149
151
|
approvalExemption(): ApprovalExemptionStore;
|
|
152
|
+
/** S-280 直连门审批证明的一次性消费记录(`plan_review` 的 nonce)。REQUIRED on all backends —— 与
|
|
153
|
+
* `approvalExemption` 同姿势:tidb/pg = SQL 双方言(单类双语句),local = File 追加日志 + 压实。
|
|
154
|
+
* **不能**退化成进程内易失形:一次重启若把记录抹掉,JWT TTL 窗内的重放就复活了,而那正是本店要关的洞。 */
|
|
155
|
+
approvalNonce(): ApprovalNonceStore;
|
|
150
156
|
/** [ref]([ref] §3.0-§3.2)流内审批协议持久层:每个流内工具调用一行的 ask 状态机 + 批行。
|
|
151
157
|
* REQUIRED on all backends — tidb/pg = SQL 双方言 twin;local = {@link InMemoryApprovalAskStore}。
|
|
152
158
|
*
|
|
@@ -12,6 +12,7 @@ import { LocalSessionStore } from "./local-session-store.js";
|
|
|
12
12
|
import { LocalCheckpointStore } from "./local-checkpoint-store.js";
|
|
13
13
|
import { FileResumeAnchorStore } from "./file-resume-anchor-store.js";
|
|
14
14
|
import { TiDBApprovalExemptionStore, PgApprovalExemptionStore, FileApprovalExemptionStore } from "./approval-exemption-store.js";
|
|
15
|
+
import { TiDBApprovalNonceStore, PgApprovalNonceStore, FileApprovalNonceStore } from "./approval-nonce-store.js";
|
|
15
16
|
import { TiDBApprovalAskStore, PgApprovalAskStore } from "./approval-ask-store-sql.js";
|
|
16
17
|
import { InMemoryApprovalAskStore } from "./approval-ask-store-memory.js";
|
|
17
18
|
import { TiDBMemoryOptOutGrantStore, PgMemoryOptOutGrantStore } from "./memory-optout-grant-store-sql.js";
|
|
@@ -112,6 +113,7 @@ class TiDBBackend {
|
|
|
112
113
|
run() { return new TiDBRunStore(this.pool); }
|
|
113
114
|
resumeAnchor() { return new TiDBResumeAnchorStore(this.pool); }
|
|
114
115
|
approvalExemption() { return new TiDBApprovalExemptionStore(this.pool); }
|
|
116
|
+
approvalNonce() { return new TiDBApprovalNonceStore(this.pool); }
|
|
115
117
|
approvalAsk() { return new TiDBApprovalAskStore(this.pool); }
|
|
116
118
|
permissionRule() { return createSqlPermissionRuleStores(mysqlDriver(this.pool)); }
|
|
117
119
|
sendFileLedger() { return new TiDBSendFileLedger(this.pool); }
|
|
@@ -159,6 +161,7 @@ class PgBackend {
|
|
|
159
161
|
run() { return new PgRunStore(this.pool); }
|
|
160
162
|
resumeAnchor() { return new PgResumeAnchorStore(this.pool); }
|
|
161
163
|
approvalExemption() { return new PgApprovalExemptionStore(this.pool); }
|
|
164
|
+
approvalNonce() { return new PgApprovalNonceStore(this.pool); }
|
|
162
165
|
approvalAsk() { return new PgApprovalAskStore(this.pool); }
|
|
163
166
|
permissionRule() { return createSqlPermissionRuleStores(pgDriver(this.pool)); }
|
|
164
167
|
sendFileLedger() { return new PgSendFileLedger(this.pool); }
|
|
@@ -200,6 +203,7 @@ class LocalBackend {
|
|
|
200
203
|
sessionStore;
|
|
201
204
|
resumeAnchorStore;
|
|
202
205
|
approvalExemptionStore;
|
|
206
|
+
approvalNonceStore;
|
|
203
207
|
approvalAskStore = new InMemoryApprovalAskStore();
|
|
204
208
|
sendFileLedgerStore;
|
|
205
209
|
workflowJournalStore;
|
|
@@ -238,6 +242,7 @@ class LocalBackend {
|
|
|
238
242
|
});
|
|
239
243
|
this.resumeAnchorStore = new FileResumeAnchorStore(this.fileBackend.root);
|
|
240
244
|
this.approvalExemptionStore = new FileApprovalExemptionStore(this.fileBackend.root);
|
|
245
|
+
this.approvalNonceStore = new FileApprovalNonceStore(this.fileBackend.root);
|
|
241
246
|
this.sendFileLedgerStore = new FileSendFileLedger(this.fileBackend.root);
|
|
242
247
|
this.workflowJournalStore = new FileWorkflowJournalStore(this.fileBackend.root);
|
|
243
248
|
this.outcomeSinkInst = new FileOutcomeSink(this.fileBackend.root);
|
|
@@ -255,6 +260,7 @@ class LocalBackend {
|
|
|
255
260
|
this.runStore.dispose();
|
|
256
261
|
this.resumeAnchorStore.dispose();
|
|
257
262
|
this.approvalExemptionStore.dispose();
|
|
263
|
+
this.approvalNonceStore.dispose();
|
|
258
264
|
this.sendFileLedgerStore.dispose();
|
|
259
265
|
this.workflowJournalStore.dispose();
|
|
260
266
|
this.checkpointStoreInst?.close();
|
|
@@ -264,6 +270,7 @@ class LocalBackend {
|
|
|
264
270
|
run() { return this.runStore; }
|
|
265
271
|
resumeAnchor() { return this.resumeAnchorStore; }
|
|
266
272
|
approvalExemption() { return this.approvalExemptionStore; }
|
|
273
|
+
approvalNonce() { return this.approvalNonceStore; }
|
|
267
274
|
approvalAsk() { return this.approvalAskStore; }
|
|
268
275
|
permissionRule() {
|
|
269
276
|
if (!this.permissionRuleStoresInst) {
|
|
@@ -69,7 +69,24 @@ export const SCHEMA_STATEMENTS = [
|
|
|
69
69
|
payload JSON NOT NULL,
|
|
70
70
|
ts DATETIME(3) NOT NULL,
|
|
71
71
|
PRIMARY KEY (session_id, seq),
|
|
72
|
-
UNIQUE KEY uq_session_event_entry (session_id, entry_id)
|
|
72
|
+
UNIQUE KEY uq_session_event_entry (session_id, entry_id),
|
|
73
|
+
-- S-284(7.76.0):「本会话按 seq 倒序的最新一条某 type 事件」这一**类**读法的访问路径。
|
|
74
|
+
-- 列序 = 等值前缀 (session_id, type) + 排序列 seq。
|
|
75
|
+
-- · **热路径(本索引的存在理由)**:computeFloor 的 type = 'compaction' ORDER BY seq DESC LIMIT 1 ——
|
|
76
|
+
-- 每次 wake 的第一条 SQL。缺这条路径时两库都退化成**整会话扫**:TiDB 按 PK 取整 session 区间
|
|
77
|
+
-- (keep order:false)把 N 行 ~1KB payload 全读进 cop 侧 TopN(实测 N=100 → 10000:0.91ms → 30.91ms);
|
|
78
|
+
-- PG 的规划器随表变大从 Index Scan Backward 翻成 Bitmap Heap Scan、再翻成 Seq Scan + Sort
|
|
79
|
+
-- (0.23ms → 1.42ms)。于是「每轮热读 = floor 之上那 ~30 行」这条 §1.5 承诺被第一条 SQL 单方面毁掉。
|
|
80
|
+
-- 补上之后两库都是一次索引定位 + 一行回表:TiDB IndexRangeScan keep order:true, desc + cop Limit 1
|
|
81
|
+
-- (1.04 / 0.90 / 0.87ms),PG Index Scan Backward(0.23 / 0.21 / 0.21ms)—— 与 N 无关。
|
|
82
|
+
-- · **冷路径(**只有 PG 实测吃到**,如实登记)**:getEpochAnchor 的
|
|
83
|
+
-- seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100。实测(N=10058、
|
|
84
|
+
-- 58 条候选):PG 走 Index Scan + 小 Sort,2.04ms → 0.49ms;**TiDB 在 pseudo 统计下不选这条索引**,
|
|
85
|
+
-- 计划仍是整会话 TableRangeScan keep order:true, desc + cop Selection —— 与无索引时同一个计划。
|
|
86
|
+
-- 不在本版加方言索引提示(那是一条特判,且该路径是 rewind 切在 kept tail 内才走的冷回退,不是每轮热读)。
|
|
87
|
+
-- 写路径代价(实测,非估计):多一条二级索引项 ⇒ TiDB 单行 append p50 1.34 → 1.58ms、10058 行批量装载
|
|
88
|
+
-- 845 → 952ms;PG 单行 append p50 0.82 → 0.80ms(噪声内)、批量装载 375 → 405ms。
|
|
89
|
+
KEY idx_session_event_type (session_id, type, seq)
|
|
73
90
|
) COLLATE utf8mb4_bin`,
|
|
74
91
|
`CREATE TABLE IF NOT EXISTS task_run (
|
|
75
92
|
-- ⚠️ 表级 COLLATE 的**非键列**影响(#192 A11,codex 复审 2026-08-08 round2 点名——先前"只有
|
|
@@ -298,6 +315,14 @@ export const SCHEMA_STATEMENTS = [
|
|
|
298
315
|
granted_by VARCHAR(190) NULL,
|
|
299
316
|
created_at DATETIME(3) NOT NULL,
|
|
300
317
|
PRIMARY KEY (session_id, tool_name)
|
|
318
|
+
) COLLATE utf8mb4_bin`,
|
|
319
|
+
`CREATE TABLE IF NOT EXISTS approval_nonce (
|
|
320
|
+
session_id VARCHAR(64) NOT NULL,
|
|
321
|
+
nonce VARCHAR(190) NOT NULL,
|
|
322
|
+
consumed_at_ms BIGINT NOT NULL,
|
|
323
|
+
expires_at_ms BIGINT NOT NULL,
|
|
324
|
+
PRIMARY KEY (session_id, nonce),
|
|
325
|
+
KEY idx_approval_nonce_expires (expires_at_ms)
|
|
301
326
|
) COLLATE utf8mb4_bin`,
|
|
302
327
|
`CREATE TABLE IF NOT EXISTS sendfile_link (
|
|
303
328
|
id VARCHAR(64) NOT NULL,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { BaseSessionStorage, type PromptEpochDescriptor, type SessionMetadata, type SessionTreeEntry, type SessionWriteOptions } from "@sema-agent/core";
|
|
2
2
|
import type { Pool } from "mysql2/promise";
|
|
3
|
+
export declare const SESSION_EVENT_LATEST_BY_TYPE_SQL: {
|
|
4
|
+
readonly floor: "SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1";
|
|
5
|
+
readonly epochAnchor: "SELECT /*+ USE_INDEX(session_event, idx_session_event_type) */ seq, payload FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100";
|
|
6
|
+
};
|
|
3
7
|
/**
|
|
4
8
|
* TiDB-backed {@link BaseSessionStorage} — the durable L1 event log for one session.
|
|
5
9
|
*
|
|
@@ -3,6 +3,11 @@ import { BaseSessionStorage, SessionError, leafIdAfterEntry, normalizePromptEpoc
|
|
|
3
3
|
import { toIso } from "./sql-row-helpers.js";
|
|
4
4
|
import { isMysqlDupKeyError } from "./sql-errors.js";
|
|
5
5
|
import { PLACEMENT_SELECT_COLUMNS, placementFromRow } from "./session-placement.js";
|
|
6
|
+
const SESSION_EVENT_BY_TYPE_HINT = "/*+ USE_INDEX(session_event, idx_session_event_type) */";
|
|
7
|
+
export const SESSION_EVENT_LATEST_BY_TYPE_SQL = {
|
|
8
|
+
floor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} payload FROM session_event WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC LIMIT 1`,
|
|
9
|
+
epochAnchor: `SELECT ${SESSION_EVENT_BY_TYPE_HINT} seq, payload FROM session_event WHERE session_id = ? AND seq < ? AND type IN ('prompt_epoch','compaction') ORDER BY seq DESC LIMIT 100`,
|
|
10
|
+
};
|
|
6
11
|
const isDupKey = isMysqlDupKeyError;
|
|
7
12
|
export class TiDBSessionStorage extends BaseSessionStorage {
|
|
8
13
|
pool;
|
|
@@ -19,7 +24,7 @@ export class TiDBSessionStorage extends BaseSessionStorage {
|
|
|
19
24
|
return undefined;
|
|
20
25
|
let cursor = this.floorSeq;
|
|
21
26
|
for (;;) {
|
|
22
|
-
const [rows] = await this.pool.query(
|
|
27
|
+
const [rows] = await this.pool.query(SESSION_EVENT_LATEST_BY_TYPE_SQL.epochAnchor, [this.sessionId, cursor]);
|
|
23
28
|
if (rows.length === 0)
|
|
24
29
|
return undefined;
|
|
25
30
|
for (const r of rows) {
|
|
@@ -44,7 +49,7 @@ export class TiDBSessionStorage extends BaseSessionStorage {
|
|
|
44
49
|
return new TiDBSessionStorage(pool, sessionId, metadata, entries, meta.leaf_id ?? null, floorEntryId, floorSeq);
|
|
45
50
|
}
|
|
46
51
|
static async computeFloor(pool, sessionId) {
|
|
47
|
-
const [compRows] = await pool.query(
|
|
52
|
+
const [compRows] = await pool.query(SESSION_EVENT_LATEST_BY_TYPE_SQL.floor, [sessionId]);
|
|
48
53
|
if (!compRows[0])
|
|
49
54
|
return { floorEntryId: null, floorSeq: 0 };
|
|
50
55
|
const comp = (typeof compRows[0].payload === "string" ? JSON.parse(compRows[0].payload) : compRows[0].payload);
|
package/dist/principal-jwt.d.ts
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
import type { ApprovalHmacKey, PrincipalJwtKey } from "./auth-keys.js";
|
|
2
|
+
/**
|
|
3
|
+
* 验签允许的**时钟不一致容差**(秒)—— 本仓这条轴上的单一来源。
|
|
4
|
+
*
|
|
5
|
+
* 它回答的是**一跳**:签发方(issuer)与判定这份 token 的那口钟可以差多少(exp/nbf 门的宽限)。
|
|
6
|
+
*
|
|
7
|
+
* 🔴 它**不**回答「两台副本之间差多少」:S-280 的消费记录不再靠任何钟差上界活着 —— 受理窗的判定、
|
|
8
|
+
* 落地后的复核、到期清扫三处读的是**同一口钟**(消费记录店自己的钟,见
|
|
9
|
+
* `plugins/approval-nonce-store.ts` 的 `nowMs()`)。曾经由本常量推导出的那个「副本间钟差」常量已
|
|
10
|
+
* 整条删除:它是一条**猜出来的**上界,没有任何一条腿强制它,而正确的修法是根本不需要这个数。
|
|
11
|
+
*/
|
|
12
|
+
export declare const PRINCIPAL_JWT_CLOCK_SKEW_SEC = 30;
|
|
2
13
|
/**
|
|
3
14
|
* [ref] D-G: verify a compact JWS principal token. Returns the crypto-verified principal (`sub`) + the
|
|
4
15
|
* channel-binding (`bnd`) on success, or `null` on ANY failure (caller fail-closes — NEVER falls back to a header).
|
|
@@ -14,15 +25,62 @@ export declare function verifyPrincipalJwt(token: string, keys: PrincipalJwtKey[
|
|
|
14
25
|
}): {
|
|
15
26
|
sub: string;
|
|
16
27
|
bnd?: string;
|
|
28
|
+
acceptedBeforeMs: number;
|
|
17
29
|
} | null;
|
|
18
30
|
/** The channel-binding value a direct client's principal JWT must carry in `cnf.bnd`: the hex SHA-256 of the
|
|
19
31
|
* CLIENT-VISIBLE action identity (sessionId + boundCallId + boundInputHash — NOT the unexposed checkpointToken).
|
|
20
32
|
* Binds the identity assertion to THIS specific suspended action so a captured JWT cannot be lifted onto another. */
|
|
33
|
+
/**
|
|
34
|
+
* 直连门信封**第三槽**的 plan_review 半场:**决定所作用内容的哈希**。
|
|
35
|
+
*
|
|
36
|
+
* 第三槽在两种门上是**同一条规则**「这份决定作用在哪份内容上」:动作门填被批工具调用的 `boundInputHash`
|
|
37
|
+
* (core 铸);非动作门 `plan_review` 的 `edit` 填**修订后的计划**的哈希(计划随本次请求一起提交,签名器手上
|
|
38
|
+
* 就有原文),`approve` / `reject` 不作用于任何新内容 ⇒ null。B-121(test 黑盒发现,验真后修):此前 edit 也填
|
|
39
|
+
* null,于是为「只改 README」签出的证明可以拿去提交任意 `editedPlan`——签名只护住了 decision/reason,没护住
|
|
40
|
+
* 决定真正落地的那份内容。`cnf.bnd` 与 HMAC 都吃第三槽,所以 JWT 与信封同时绑住它;不匹配 ⇒
|
|
41
|
+
* `401 principal_unbound`(既有码,cnf.bnd 不符)。旧注记「计划哈希被拒」说的是 approve(worker 没有可哈希的
|
|
42
|
+
* 原文),不覆盖 edit。
|
|
43
|
+
*
|
|
44
|
+
* 形:`sha256(utf8(editedPlan))` hex —— 与 `approvalBnd` 同摘要、同编码;逐字节,不归一化(签名器与 worker
|
|
45
|
+
* 必须对同一串字节达成一致,任何"归一化"都是第二个写者)。
|
|
46
|
+
*/
|
|
47
|
+
/** 良构 Unicode(无孤立代理项)。`u` 旗下配对代理项是一个码点、不匹配 `\p{Surrogate}`;只有孤立的才命中。 */
|
|
48
|
+
export declare function isWellFormedUnicode(s: string): boolean;
|
|
49
|
+
export declare function editedPlanBinding(editedPlan: string): string;
|
|
21
50
|
export declare function approvalBnd(env: {
|
|
22
51
|
sessionId: string;
|
|
23
52
|
boundCallId?: string | null;
|
|
24
53
|
boundInputHash?: string | null;
|
|
25
54
|
}): string;
|
|
55
|
+
/**
|
|
56
|
+
* 一次性凭据:这份证明**必须被消费掉一次**才算数,以及它的消费记录该活到什么时候。
|
|
57
|
+
* 只有非动作门(plan_review)的证明带它 —— 动作门的一次性由 checkpointToken 给(见 {@link verifyDirectDoorProof})。
|
|
58
|
+
*/
|
|
59
|
+
export interface SingleUseProof {
|
|
60
|
+
/** 签名器为这一次决定新铸的随机值(= 信封第 2 位 `boundCallId`,同时进 `cnf.bnd` 与 HMAC)。 */
|
|
61
|
+
readonly nonce: string;
|
|
62
|
+
/**
|
|
63
|
+
* 这份证明**不再被受理**的时刻(epoch ms,**开区间上界**:`店钟 >= 它` ⇒ 验签已经不收它)。
|
|
64
|
+
* 一个值、两处用,所以它们不可能漂:
|
|
65
|
+
* · 消费记录的到期列逐字落它 —— 清理腿的删除线就是**店钟**,没有任何余量、也不需要;
|
|
66
|
+
* · 消费**落地之后**的复核线(再问一次店钟)—— 消费是一次跨网络的写,验签发生在它之前。
|
|
67
|
+
*
|
|
68
|
+
* 🔴 「它」与「店钟」必须是同一口钟:本值由 `verifyPrincipalJwt` 从 `exp + skew` 算出,而那一关的
|
|
69
|
+
* `nowSec` 由调用方从**店钟**喂进来(`routes/approvals-assistant.ts`)。
|
|
70
|
+
*/
|
|
71
|
+
readonly acceptedBeforeMs: number;
|
|
72
|
+
}
|
|
73
|
+
/** {@link verifyDirectDoorProof} 的判决。`singleUse` 在场 = 调用方必须在改任何状态**之前**消费它一次。 */
|
|
74
|
+
export type DirectDoorProofResult = {
|
|
75
|
+
ok: true;
|
|
76
|
+
principal: string;
|
|
77
|
+
singleUse?: SingleUseProof;
|
|
78
|
+
} | {
|
|
79
|
+
ok: false;
|
|
80
|
+
status: number;
|
|
81
|
+
errorCode: string;
|
|
82
|
+
error: string;
|
|
83
|
+
};
|
|
26
84
|
/**
|
|
27
85
|
* [ref] D-G: verify the WHOLE direct-door proof for one /decide — the signed principal JWT (identity) + its
|
|
28
86
|
* cnf.bnd channel-binding + the HMAC envelope (integrity) — and return the CRYPTO-VERIFIED principal, or a typed
|
|
@@ -30,13 +88,22 @@ export declare function approvalBnd(env: {
|
|
|
30
88
|
* verify principal → bind → mac-present → mac-valid (so a non-operator never gets a MAC oracle, an unverified
|
|
31
89
|
* caller never reaches the operator list).
|
|
32
90
|
*
|
|
91
|
+
* 🔴 `opts.nowSec`(S-280 / 合并复审 R2):受理窗要按**哪口钟**判。非动作门(plan_review)的反重放是
|
|
92
|
+
* 一条**消费记录**,而那条记录的生死由**消费记录店的钟**决定 —— 所以那条腿必须把店钟喂进来
|
|
93
|
+
* (`floor(店钟/1000)`),否则「还受理 ⇒ 记录还在」就退化成「两台副本的钟差不多」这条没人声明过的假设。
|
|
94
|
+
* 缺席 = 本机墙钟,这对动作门(`/decide`)是对的:它的反重放是 checkpointToken 的一次性,不牵扯第二台
|
|
95
|
+
* 机器上的一张行。
|
|
96
|
+
*
|
|
33
97
|
* 🔴 `opts.actionBinding`: the boundCallId/boundInputHash action-binding is `policy_ask`-
|
|
34
98
|
* SPECIFIC (a pending TOOL call). A NON-ACTION gate — `plan_review` (and any review-pause) — `binds NO action`
|
|
35
99
|
* (core checkpoint-store.ts: pendingAction `{kind:"plan_review"}`, editedPlan is a typed sibling that binds no
|
|
36
100
|
* action), so the proof correctly DEGRADES to "verify WHO": JWT identity + cnf.bnd channel-binding + the HMAC over
|
|
37
|
-
* {sessionId,
|
|
38
|
-
*
|
|
39
|
-
*
|
|
101
|
+
* {sessionId, nonce, <content hash>, decision, reason}. Pass `actionBinding:false` for a non-action gate; the default
|
|
102
|
+
* (true) keeps the /decide tool-approval contract byte-identical. The THIRD slot carries one rule on both gates —
|
|
103
|
+
* "the content this decision acts on": the bound tool input for /decide, `editedPlanBinding(editedPlan)` for a
|
|
104
|
+
* plan_review `edit` (B-121), null for approve/reject. Binding approve/reject to a hash of the EXISTING plan was
|
|
105
|
+
* rejected (the plan artifact lives in a profile ref store; the worker has no TOCTOU original) — that ruling never
|
|
106
|
+
* covered `edit`, whose content arrives with the request.
|
|
40
107
|
*/
|
|
41
108
|
export declare function verifyDirectDoorProof(proof: {
|
|
42
109
|
jwt?: string;
|
|
@@ -56,13 +123,6 @@ export declare function verifyDirectDoorProof(proof: {
|
|
|
56
123
|
approvalHmacKeys: ApprovalHmacKey[];
|
|
57
124
|
}, opts?: {
|
|
58
125
|
actionBinding?: boolean;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
principal: string;
|
|
62
|
-
} | {
|
|
63
|
-
ok: false;
|
|
64
|
-
status: number;
|
|
65
|
-
errorCode: string;
|
|
66
|
-
error: string;
|
|
67
|
-
};
|
|
126
|
+
nowSec?: number;
|
|
127
|
+
}): DirectDoorProofResult;
|
|
68
128
|
//# sourceMappingURL=principal-jwt.d.ts.map
|
package/dist/principal-jwt.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, createPublicKey, verify as cryptoVerify } from "node:crypto";
|
|
2
2
|
import { verifyApprovalHmac } from "./approval-hmac.js";
|
|
3
|
+
export const PRINCIPAL_JWT_CLOCK_SKEW_SEC = 30;
|
|
3
4
|
function b64urlToBuf(s) {
|
|
4
5
|
return Buffer.from(s, "base64url");
|
|
5
6
|
}
|
|
@@ -61,15 +62,23 @@ export function verifyPrincipalJwt(token, keys, expect) {
|
|
|
61
62
|
if (typeof payload.sub !== "string" || payload.sub.length === 0)
|
|
62
63
|
return null;
|
|
63
64
|
const now = expect.nowSec ?? Math.floor(Date.now() / 1000);
|
|
64
|
-
const skew = expect.skewSec ??
|
|
65
|
-
if (typeof payload.exp !== "number" || payload.exp + skew < now)
|
|
65
|
+
const skew = expect.skewSec ?? PRINCIPAL_JWT_CLOCK_SKEW_SEC;
|
|
66
|
+
if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp + skew < now)
|
|
66
67
|
return null;
|
|
67
|
-
if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || payload.nbf - skew > now))
|
|
68
|
+
if (payload.nbf !== undefined && (typeof payload.nbf !== "number" || !Number.isFinite(payload.nbf) || payload.nbf - skew > now))
|
|
68
69
|
return null;
|
|
69
70
|
if (expect.maxTtlSec && payload.exp - now > expect.maxTtlSec + skew)
|
|
70
71
|
return null;
|
|
71
72
|
const bnd = typeof payload.cnf?.bnd === "string" ? payload.cnf.bnd : undefined;
|
|
72
|
-
return { sub: payload.sub, bnd };
|
|
73
|
+
return { sub: payload.sub, bnd, acceptedBeforeMs: (Math.floor(payload.exp + skew) + 1) * 1000 };
|
|
74
|
+
}
|
|
75
|
+
export function isWellFormedUnicode(s) {
|
|
76
|
+
return !/\p{Surrogate}/u.test(s);
|
|
77
|
+
}
|
|
78
|
+
export function editedPlanBinding(editedPlan) {
|
|
79
|
+
if (!isWellFormedUnicode(editedPlan))
|
|
80
|
+
throw new Error("editedPlanBinding: editedPlan is not well-formed Unicode (lone surrogate) — utf8 hashing would be lossy");
|
|
81
|
+
return createHash("sha256").update(editedPlan, "utf8").digest("hex");
|
|
73
82
|
}
|
|
74
83
|
export function approvalBnd(env) {
|
|
75
84
|
return createHash("sha256").update(JSON.stringify([env.sessionId, env.boundCallId ?? null, env.boundInputHash ?? null])).digest("hex");
|
|
@@ -78,11 +87,16 @@ export function verifyDirectDoorProof(proof, envelope, config, opts) {
|
|
|
78
87
|
const actionBinding = opts?.actionBinding ?? true;
|
|
79
88
|
if (!proof.jwt)
|
|
80
89
|
return { ok: false, status: 401, errorCode: "principal_unverified", error: "direct door requires a signed principal token (X-Approval-Principal-Token)" };
|
|
81
|
-
const verified = verifyPrincipalJwt(proof.jwt, config.principalJwtPubkeys, { iss: config.principalJwtIss ?? "\0", aud: config.principalJwtAud ?? "\0", maxTtlSec: config.principalJwtMaxTtlSec });
|
|
90
|
+
const verified = verifyPrincipalJwt(proof.jwt, config.principalJwtPubkeys, { iss: config.principalJwtIss ?? "\0", aud: config.principalJwtAud ?? "\0", maxTtlSec: config.principalJwtMaxTtlSec, nowSec: opts?.nowSec });
|
|
82
91
|
if (!verified)
|
|
83
92
|
return { ok: false, status: 401, errorCode: "principal_unverified", error: "principal token failed verification" };
|
|
84
|
-
if (actionBinding
|
|
85
|
-
|
|
93
|
+
if (actionBinding) {
|
|
94
|
+
if (!envelope.boundInputHash)
|
|
95
|
+
return { ok: false, status: 400, errorCode: "approval_mac_required", error: "direct door requires boundInputHash (the bound action) in the body" };
|
|
96
|
+
}
|
|
97
|
+
else if (!envelope.boundCallId) {
|
|
98
|
+
return { ok: false, status: 400, errorCode: "approval_nonce_required", error: "direct door requires a single-use nonce in boundCallId for a non-action gate (sign it into cnf.bnd and the HMAC envelope; see ASSISTANT-WIRE-CONTRACT §6.1/§6.2)" };
|
|
99
|
+
}
|
|
86
100
|
if (verified.bnd !== approvalBnd(envelope))
|
|
87
101
|
return { ok: false, status: 401, errorCode: "principal_unbound", error: "principal token is not bound to this decision (cnf.bnd mismatch)" };
|
|
88
102
|
if (!proof.mac)
|
|
@@ -90,6 +104,8 @@ export function verifyDirectDoorProof(proof, envelope, config, opts) {
|
|
|
90
104
|
const ok = verifyApprovalHmac({ sessionId: envelope.sessionId, boundCallId: envelope.boundCallId ?? null, boundInputHash: envelope.boundInputHash ?? null, decision: envelope.decision, reason: envelope.reason ?? null }, proof.mac, proof.kid, config.approvalHmacKeys);
|
|
91
105
|
if (!ok)
|
|
92
106
|
return { ok: false, status: 401, errorCode: "approval_mac_invalid", error: "approval envelope MAC invalid" };
|
|
93
|
-
return
|
|
107
|
+
return actionBinding
|
|
108
|
+
? { ok: true, principal: verified.sub }
|
|
109
|
+
: { ok: true, principal: verified.sub, singleUse: { nonce: envelope.boundCallId, acceptedBeforeMs: verified.acceptedBeforeMs } };
|
|
94
110
|
}
|
|
95
111
|
//# sourceMappingURL=principal-jwt.js.map
|
package/dist/tool-approval.d.ts
CHANGED
|
@@ -468,7 +468,8 @@ export type RuleRefusalReason = Extract<CardRulePersisted, {
|
|
|
468
468
|
* 部署整条车道判死。 */
|
|
469
469
|
| "rule_governance_forced"
|
|
470
470
|
/**
|
|
471
|
-
* [ref]
|
|
471
|
+
* [ref]:**这一行**没有可铸规则的素材。原为 durable(PARKED)迟到决议腿专属;自 live 腿也接上素材闸起**两腿同词**
|
|
472
|
+
* (第四条成因:命令含脱敏器认得的凭据形 ⇒ 素材整只扣下,两腿同谓词)。
|
|
472
473
|
*
|
|
473
474
|
* 成因闭集(三条,处置相同):①这只 ask 从没进过规则车道(治理档 / 无属主 / 引擎没铸候选 / args 里
|
|
474
475
|
* 读不出命令原字节)⇒ `rule_command` 列 NULL;②行由**加这两列之前**的构建落下;③行上的卡读不回来
|