@whaletalk/dsh-codex-collab 0.1.9 → 0.1.10

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.
@@ -0,0 +1,1078 @@
1
+ /**
2
+ * codex-approvals.mjs —— 「Codex 接手 DSH 单次审批」核心:控制凭据、任务 grant、不可变执行快照、
3
+ * 官方请求身份认领、单次状态机、幂等、租约/总期限、撤销与最小持久审计。
4
+ *
5
+ * 认证边界(本轮修正 C):
6
+ * - 传输的是**私有 raw key**(`X-Dsh-Control`),服务端与本机 key 文件做等值比较;
7
+ * `sha256(key)` 之类的持久摘要**不再能认证**(它不是 bearer)。
8
+ * - grant/audit 只存**域分离的单向摘要** `credDigest`(`digest:sha256('dsh-approval-grant-digest:'+key)`),
9
+ * 不能用于登录;raw key 不进工具输入/结果/错误/日志/审计。
10
+ * - key 读入做格式校验(64 hex),不合规一律视为"无 key"(能力关闭),绝不把任意配置当 header 发出去。
11
+ *
12
+ * 绑定边界(本轮修正 A/B/D):
13
+ * - 快照按 `(sessionId, callId)` 存**不可变深拷贝**(含 args/cwd/root/parent/step/turn),
14
+ * 绝不用"当前某 callId 的快照"回看另一个请求;turn/step 必须都是实数,未知不作为可批准默认值。
15
+ * - pending 持有打开时刻的执行上下文副本;决定时要求**实时快照身份仍等于**该副本(参数/上下文变化即拒绝)。
16
+ * - 每个官方 `request()`/callback 各自一个独立桥接 pending 与一次性状态;官方 ask 身份(UUID)必须
17
+ * **唯一认领**(歧义即交人工),一次决定只完成一条。
18
+ * - 指纹绑定 session/callId/toolName/turn/step/root/parent/cwd/真实参数 + task/grant 身份。
19
+ */
20
+ import fs from 'node:fs'
21
+ import os from 'node:os'
22
+ import path from 'node:path'
23
+ import crypto from 'node:crypto'
24
+ import { createFileLock } from './fs-lock.mjs'
25
+
26
+ export const APPROVAL_STATES = {
27
+ PENDING: 'pending',
28
+ SUBMITTED: 'decision-submitted',
29
+ CONFIRMED: 'confirmed',
30
+ REJECTED: 'rejected',
31
+ CANCELLED: 'cancelled',
32
+ EXPIRED: 'expired',
33
+ SUPERSEDED: 'superseded',
34
+ AMBIGUOUS: 'unsupported-ambiguous',
35
+ CONTEXT_CHANGED: 'context-changed',
36
+ DECISION_UNKNOWN: 'decision-unknown',
37
+ }
38
+
39
+ export const GRANT_STATES = { ACTIVE: 'active', REVOKED: 'revoked', EXPIRED: 'expired' }
40
+
41
+ export const DECISIONS = { APPROVE_ONCE: 'approve-once', REJECT: 'reject' }
42
+ const OUTCOME_FOR = { 'approve-once': 'allowed-once', reject: 'rejected' }
43
+
44
+ export const APPROVAL_REASONS = {
45
+ NO_KEY: 'approval-key-missing',
46
+ BAD_KEY_FORMAT: 'approval-key-malformed',
47
+ BAD_HEADER: 'approval-control-invalid',
48
+ GRANT_MISSING: 'grant-missing',
49
+ GRANT_REVOKED: 'grant-revoked',
50
+ GRANT_EXPIRED: 'grant-expired',
51
+ GRANT_CRED_MISMATCH: 'grant-cred-mismatch',
52
+ GRANT_SCOPE_MISMATCH: 'grant-scope-mismatch',
53
+ GRANT_VERSION_MISMATCH: 'grant-version-mismatch',
54
+ GRANT_LOG_CORRUPT: 'grant-log-corrupt',
55
+ ORIGIN_REQUIRED: 'origin-required',
56
+ ORIGIN_MISMATCH: 'origin-mismatch',
57
+ TASK_REQUIRED: 'task-required',
58
+ PENDING_MISSING: 'approval-not-found',
59
+ PENDING_ALREADY_DECIDED: 'approval-already-decided',
60
+ PENDING_EXPIRED: 'approval-expired',
61
+ IDEMPOTENCY_CONFLICT: 'idempotency-conflict',
62
+ FINGERPRINT_MISMATCH: 'fingerprint-mismatch',
63
+ CONTEXT_CHANGED: 'approval-context-changed',
64
+ IDENTITY_UNKNOWN: 'execution-identity-unknown',
65
+ OFFICIAL_UNBOUND: 'official-request-unbound',
66
+ OFFICIAL_AMBIGUOUS: 'official-request-ambiguous',
67
+ EXTRA_FIELDS: 'unexpected-fields',
68
+ AUDIT_WRITE_FAILED: 'audit-write-failed',
69
+ UNSUPPORTED_TARGET: 'approval-unsupported-target',
70
+ UNSUPPORTED_CALL: 'approval-unsupported-call',
71
+ }
72
+
73
+ const DEFAULT_LEASE_MS = 60_000
74
+ const DEFAULT_GRANT_TTL_MS = 15 * 60_000
75
+ const MAX_GRANT_TTL_MS = 60 * 60_000
76
+ /** 认证续期能延到的绝对上限(相对签发时间),避免"无限续期"变成永久授权。 */
77
+ export const MAX_AUTHORIZED_TTL_MS = 8 * 60 * 60_000
78
+ const SNAPSHOT_TTL_MS = 10 * 60_000
79
+ const SNAPSHOT_MAX = 256
80
+ const KEY_PATTERN = /^[0-9a-f]{64}$/
81
+
82
+ const uid = (prefix) => prefix + '-' + Date.now().toString(36) + '-' + crypto.randomBytes(6).toString('hex')
83
+
84
+ export const sha256Hex = (value) => crypto.createHash('sha256').update(String(value)).digest('hex')
85
+
86
+ // ---------------------------------------------------------------- 控制凭据
87
+
88
+ export const controlKeyPath = (explicit) => {
89
+ if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit)
90
+ if (typeof process.env.DSH_APPROVAL_CONTROL_KEY_PATH === 'string' && process.env.DSH_APPROVAL_CONTROL_KEY_PATH.trim() !== '') {
91
+ return path.resolve(process.env.DSH_APPROVAL_CONTROL_KEY_PATH)
92
+ }
93
+ return path.join(os.homedir(), '.dsh-codex-approval', 'control.key')
94
+ }
95
+
96
+ /** 非认证用途的指纹(域分离,不可反推、也不可当 header 用)。 */
97
+ export const keyFingerprint8 = (key) => sha256Hex('dsh-approval-key-fp:' + String(key)).slice(0, 8)
98
+
99
+ /**
100
+ * grant/audit 里保存的单向摘要:**域分离**,与认证 header 不同域 ⇒ 拿到它也无法登录。
101
+ * (修正 C:以前存 `sha256(rawKey)`,而 header 正是同一个 hash,等于把 bearer 落盘。)
102
+ */
103
+ export const controlCredDigest = (key) => 'digest:' + sha256Hex('dsh-approval-grant-digest:' + String(key))
104
+
105
+ /** 离线原子初始化控制 key(只在不存在时创建;force 才轮换,轮换=撤销全部旧 grant)。 */
106
+ export const initControlKey = (options = {}) => {
107
+ const file = controlKeyPath(options.path)
108
+ const force = options.force === true
109
+ const dir = path.dirname(file)
110
+ fs.mkdirSync(dir, { recursive: true })
111
+ const tmp = path.join(dir, '.key-' + uid('tmp'))
112
+ const raw = crypto.randomBytes(32).toString('hex')
113
+ fs.writeFileSync(tmp, raw + '\n', { encoding: 'utf8', mode: 0o600 })
114
+ let rotated = false
115
+ try {
116
+ if (force) {
117
+ fs.renameSync(tmp, file)
118
+ rotated = true
119
+ } else {
120
+ try {
121
+ fs.linkSync(tmp, file)
122
+ } catch (e) {
123
+ if (e !== null && typeof e === 'object' && e.code === 'EEXIST') {
124
+ fs.unlinkSync(tmp)
125
+ throw new Error('control key already exists at ' + file + ' (use explicit rotate to replace it; rotating revokes every existing grant)')
126
+ }
127
+ throw e
128
+ }
129
+ fs.unlinkSync(tmp)
130
+ }
131
+ } finally {
132
+ try { fs.unlinkSync(tmp) } catch (e) { /* temp 已转正或已删 */ }
133
+ }
134
+ return { path: file, fp8: keyFingerprint8(raw), rotated }
135
+ }
136
+
137
+ /**
138
+ * 读 key;**格式不合法按"没有 key"处理**(能力关闭)。避免把任意配置/文件内容当 header 发出去。
139
+ * @returns {string|null}
140
+ */
141
+ export const readControlKey = (explicitPath) => {
142
+ const file = controlKeyPath(explicitPath)
143
+ let value
144
+ try {
145
+ value = fs.readFileSync(file, 'utf8').trim()
146
+ } catch (e) {
147
+ return null
148
+ }
149
+ if (!KEY_PATTERN.test(value)) return null
150
+ return value
151
+ }
152
+
153
+ /** 客户端用:raw key 作为认证 header;无 key/格式不合规 → null(调用方零执行拒绝)。 */
154
+ export const controlHeaderValue = (explicitPath) => {
155
+ const key = readControlKey(explicitPath)
156
+ if (key === null) return null
157
+ return { 'X-Dsh-Control': key }
158
+ }
159
+
160
+ /** 服务端用:与 raw key 等值比较(timingSafeEqual,等长前提)。 */
161
+ export const verifyControlHeader = (headerValue, key) => {
162
+ if (typeof headerValue !== 'string' || !KEY_PATTERN.test(headerValue)) return false
163
+ if (typeof key !== 'string' || !KEY_PATTERN.test(key)) return false
164
+ const a = Buffer.from(headerValue, 'utf8')
165
+ const b = Buffer.from(key, 'utf8')
166
+ if (a.length !== b.length) return false
167
+ return crypto.timingSafeEqual(a, b)
168
+ }
169
+
170
+ // ---------------------------------------------------------------- 脱敏摘要
171
+
172
+ const SECRET_PATTERNS = [
173
+ /\b(authorization|bearer|cookie|password|passwd|token|secret|api[-_]?key|access[-_]?key|client[-_]?secret)\b\s*[:=]\s*("[^"]*"|'[^']*'|[^\s,;}]+)/gi,
174
+ /"(authorization|password|passwd|token|secret|api[-_]?key|access[-_]?key|client[-_]?secret)"\s*:\s*"[^"]*"/gi,
175
+ /\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{6,}/g,
176
+ ]
177
+ /** 只用于审计/展示摘要:遮蔽常见凭据形状(含 JSON 引号键),并截断。不是"能去掉所有秘密"的保证。 */
178
+ export const sanitizePreview = (text, limit = 300) => {
179
+ let out = typeof text === 'string' ? text.replace(/[\r\n]+/g, ' ') : ''
180
+ for (const pattern of SECRET_PATTERNS) out = out.replace(pattern, (match, p1) => (String(match).trimStart().startsWith('"') ? '"' + p1 + '":<redacted>' : p1 + '=<redacted>'))
181
+ out = out.replace(/(https?:\/\/)[^\s@/]+:[^\s@/]+@/g, '$1<redacted>@')
182
+ return out.length > limit ? out.slice(0, limit) + '…' : out
183
+ }
184
+
185
+ /**
186
+ * 审计只保留**可核对的最小身份/摘要**(不落任何原文,包括命令文本):
187
+ * 动作内容以 argsSha256 呈现,需要看真实参数走**已鉴权的审批详情**(pendingView)。
188
+ */
189
+ export const auditActionSummary = (execution) => ({
190
+ toolName: execution.toolName,
191
+ callId: execution.callId,
192
+ turn: execution.turn,
193
+ step: execution.step,
194
+ cwd: execution.cwd,
195
+ argsSha256: sha256Hex(canonicalJson(execution.args)),
196
+ })
197
+
198
+ /** 结构化参数的稳定 JSON(键排序)。 */
199
+ export function canonicalJson(value) {
200
+ if (value === undefined) return 'null'
201
+ if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null'
202
+ if (Array.isArray(value)) return '[' + value.map(canonicalJson).join(',') + ']'
203
+ const keys = Object.keys(value).sort()
204
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(value[k])).join(',') + '}'
205
+ }
206
+
207
+ const deepFreeze = (value) => {
208
+ if (value === null || typeof value !== 'object') return value
209
+ for (const key of Object.keys(value)) deepFreeze(value[key])
210
+ return Object.freeze(value)
211
+ }
212
+ const deepCopy = (value) => (value === undefined ? undefined : deepFreeze(structuredClone(value)))
213
+
214
+ /**
215
+ * 官方执行身份里"父调用"的真实形态(已核对本机官方源码,不是杜撰):
216
+ * - `tools/pre-execute` 收到的 exec 是 dsh-tools `createExecution()` 构造的 execution 对象:
217
+ * `{token, callId, rootCallId, name, args, signal, agent?, parent?, schema?}`(dsh-tools/lib/index.js:3143-3168)
218
+ * - 嵌套/PTC 派发时 `parent` 是**父执行的 token**(dsh-tools/lib/ptc.js:439 `parent: exec.token`);
219
+ * 顶层执行没有 parent。
220
+ * - `rootCallId` 由 SDK 归一:顶层 `rootCallId = exec.rootCallId ?? callId`(:3135)。
221
+ * - **exec 上没有 cwd**:cwd 的真实来源是 `exec.agent.session.header.cwd`(dsh-tools:1423 同一取值路径;
222
+ * dsh-session:1044-1046 保证它是合法绝对路径字符串)。
223
+ * token 不可序列化,所以父身份只能按"是否存在 + 可序列化的稳定键"参与;**present 但无法稳定表达 → unsupported**。
224
+ */
225
+ export const canonicalParentKey = (parent) => {
226
+ if (parent === undefined || parent === null) return null
227
+ if (typeof parent === 'string') return parent === '' ? 'unsupported' : 's:' + parent
228
+ if (typeof parent === 'object') {
229
+ for (const field of ['token', 'callId', 'id', 'name']) {
230
+ const value = parent[field]
231
+ if (typeof value === 'string' && value !== '') return field + ':' + value
232
+ }
233
+ return 'unsupported' // 存在父执行但拿不到稳定身份 → 不许假装完整
234
+ }
235
+ return 'unsupported'
236
+ }
237
+
238
+ /** 身份内容(不含 turn/step):任一必需项缺失就返回 null(**未知即 unsupported**,绝不当作完整)。 */
239
+ export const identityContent = (parts) => {
240
+ const callId = typeof parts.callId === 'string' && parts.callId !== '' ? parts.callId : null
241
+ const sessionId = typeof parts.sessionId === 'string' && parts.sessionId !== '' ? parts.sessionId : null
242
+ const toolName = typeof parts.toolName === 'string' && parts.toolName !== '' ? parts.toolName : null
243
+ const cwd = typeof parts.cwd === 'string' && parts.cwd !== '' ? parts.cwd : null
244
+ // 父身份:只给 parent 就派生;只给 parentKey 就用它;**两者都给且不一致 → unsupported**
245
+ // (否则一个陈旧/伪造的 parentKey=null 可以遮蔽真实存在的 parent,让代际变化不可见)
246
+ const derivedParentKey = canonicalParentKey(parts.parent)
247
+ let parentKey
248
+ if (parts.parentKey === undefined) parentKey = derivedParentKey
249
+ else if (parts.parent === undefined) parentKey = parts.parentKey
250
+ else parentKey = parts.parentKey === derivedParentKey ? parts.parentKey : 'unsupported'
251
+ if (callId === null || sessionId === null || toolName === null || cwd === null) return null
252
+ if (parentKey === 'unsupported') return null
253
+ return {
254
+ sessionId,
255
+ callId,
256
+ rootCallId: typeof parts.rootCallId === 'string' && parts.rootCallId !== '' ? parts.rootCallId : callId,
257
+ parentKey: parentKey === undefined ? null : parentKey,
258
+ toolName,
259
+ cwd,
260
+ argsSha256: sha256Hex(canonicalJson(parts.args)),
261
+ }
262
+ }
263
+
264
+ /** 身份内容 id(toolName/root/parent/cwd/args 任一变化 → 不同):用于快照"代际"识别。 */
265
+ export const contentIdOf = (content) => (content === null ? null : 'cid-' + sha256Hex(JSON.stringify([
266
+ content.sessionId, content.callId, content.rootCallId, content.parentKey, content.toolName, content.cwd, content.argsSha256,
267
+ ])).slice(0, 24))
268
+
269
+ /**
270
+ * **完整执行身份** = 身份内容 + 真实 turn/step(都必须是整数)。
271
+ * 任一项未知 → null:调用方必须 `next()`/拒绝(unsupported),不得凭不完整身份放行。
272
+ */
273
+ export const executionIdentityId = (parts) => {
274
+ const content = identityContent(parts)
275
+ if (content === null) return null
276
+ const turn = parts.turn
277
+ const step = parts.step
278
+ if (!Number.isInteger(turn) || !Number.isInteger(step)) return null
279
+ return 'xid-' + sha256Hex(JSON.stringify([
280
+ content.sessionId, content.callId, content.rootCallId, content.parentKey, content.toolName,
281
+ content.cwd, content.argsSha256, turn, step,
282
+ ])).slice(0, 32)
283
+ }
284
+
285
+ /**
286
+ * 不可变动作指纹:绑定 session/callId/toolName/turn/step/root/parent/cwd/真实参数 + 授权身份(task/grant)。
287
+ * 这些字段任一变化 → 指纹不同 → 决定被拒(修正 A:cwd/root/parent 参与;D:授权身份参与;
288
+ * 第二轮:parent 用官方真实字段 `exec.parent` 参与,不再只在注释里声称)。
289
+ */
290
+ export const actionFingerprint = (parts) => sha256Hex(JSON.stringify([
291
+ parts.sessionId,
292
+ parts.callId,
293
+ parts.toolName,
294
+ typeof parts.turn === 'number' ? parts.turn : 'no-turn',
295
+ typeof parts.step === 'number' ? parts.step : 'no-step',
296
+ parts.rootCallId === undefined ? null : parts.rootCallId,
297
+ parts.parentKey === undefined ? canonicalParentKey(parts.parent) : parts.parentKey,
298
+ parts.cwd === undefined ? null : parts.cwd,
299
+ canonicalJson(parts.args),
300
+ parts.taskId === undefined ? null : parts.taskId,
301
+ parts.grantId === undefined ? null : parts.grantId,
302
+ ]))
303
+
304
+ // ---------------------------------------------------------------- 持久存储
305
+
306
+ export const approvalStoreRoot = (explicit) => {
307
+ if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit)
308
+ if (typeof process.env.DSH_APPROVAL_DIR === 'string' && process.env.DSH_APPROVAL_DIR.trim() !== '') return path.resolve(process.env.DSH_APPROVAL_DIR)
309
+ return path.join(os.homedir(), '.dsh-codex-approval')
310
+ }
311
+
312
+ /**
313
+ * JSONL 读取。`strict` 用于**权限记录**:任何不可解析行(含被截断的最后一行)都必须安全失败,
314
+ * 而不是跳过坏行后继续把旧 grant 当有效(修正 F)。
315
+ */
316
+ const readJsonl = (file, options = {}) => {
317
+ let text
318
+ try {
319
+ text = fs.readFileSync(file, 'utf8')
320
+ } catch (e) {
321
+ if (e !== null && typeof e === 'object' && e.code === 'ENOENT') return []
322
+ if (options.strict === true) throw new Error('cannot read ' + file + ': ' + e.message)
323
+ return []
324
+ }
325
+ if (text.trim() === '') return []
326
+ const rows = []
327
+ const lines = text.split('\n')
328
+ for (let i = 0; i < lines.length; i++) {
329
+ const trimmed = lines[i].trim()
330
+ if (trimmed === '') continue
331
+ try {
332
+ rows.push(JSON.parse(trimmed))
333
+ } catch (e) {
334
+ if (options.strict === true) throw new Error(file + ' has an unparseable line ' + (i + 1) + ' — refusing to treat damaged authorization state as valid')
335
+ // 非权限文件(如审计展示)允许跳过坏行
336
+ }
337
+ }
338
+ return rows
339
+ }
340
+
341
+ export const openApprovalStore = (options = {}) => {
342
+ const root = approvalStoreRoot(options.root)
343
+ const dir = root
344
+ const grantsFile = path.join(dir, 'grants.jsonl')
345
+ const auditFile = path.join(dir, 'audit.jsonl')
346
+ const lock = createFileLock({ lockFile: path.join(dir, '.write.lock'), timeoutMs: options.lockTimeoutMs })
347
+
348
+ const ensureDir = () => { fs.mkdirSync(dir, { recursive: true }) }
349
+
350
+ const appendLine = (file, row) => {
351
+ ensureDir()
352
+ let size = 0
353
+ try { size = fs.statSync(file).size } catch (e) { size = 0 }
354
+ if (size > 0) {
355
+ let last = 0
356
+ const fd = fs.openSync(file, 'r')
357
+ try {
358
+ const buf = Buffer.alloc(1)
359
+ fs.readSync(fd, buf, 0, 1, size - 1)
360
+ last = buf[0]
361
+ } finally {
362
+ fs.closeSync(fd)
363
+ }
364
+ if (last !== 0x0a) fs.appendFileSync(file, '\n', 'utf8')
365
+ }
366
+ fs.appendFileSync(file, JSON.stringify(row) + '\n', 'utf8')
367
+ }
368
+
369
+ const withLock = (fn) => lock.withLock(fn)
370
+
371
+ /** 折叠 grant(严格读:坏行 → 抛出让调用方 fail-closed)。 */
372
+ const foldGrants = () => {
373
+ const map = new Map()
374
+ for (const row of readJsonl(grantsFile, { strict: true })) {
375
+ if (row.type === 'grant' && typeof row.grantId === 'string') map.set(row.grantId, row)
376
+ if (row.type === 'grant-update' && typeof row.grantId === 'string') {
377
+ const grant = map.get(row.grantId)
378
+ if (grant === undefined) continue
379
+ if (row.status !== undefined) grant.status = row.status
380
+ if (row.leaseUntil !== undefined) grant.leaseUntil = row.leaseUntil
381
+ if (row.expiresAt !== undefined) grant.expiresAt = row.expiresAt
382
+ if (row.revokedAt !== undefined) grant.revokedAt = row.revokedAt
383
+ if (row.revokeReason !== undefined) grant.revokeReason = row.revokeReason
384
+ if (row.version !== undefined) grant.version = row.version
385
+ if (row.authorization !== undefined) grant.authorization = row.authorization
386
+ }
387
+ }
388
+ return map
389
+ }
390
+
391
+ const snapshots = new Map() // `${sessionId}\u0000${callId}` → 不可变执行快照
392
+ const pendings = new Map() // approvalKey → pending(每次官方 ask 一条)
393
+ const executionIndex = new Map() // `${sessionId}\u0000${callId}` → [approvalKey...]
394
+ const officialAsks = new Map() // officialId → {officialId, sessionId, toolName, callId, seenAt, claimedBy?}
395
+ const orphans = [] // 观察到但无对应 pending 的 decided(诊断)
396
+
397
+ const snapKey = (sessionId, callId) => sessionId + '\u0000' + callId
398
+
399
+ const pruneSnapshots = (now) => {
400
+ for (const [key, snapshot] of snapshots) {
401
+ if (now - snapshot.createdAt > SNAPSHOT_TTL_MS) snapshots.delete(key)
402
+ }
403
+ while (snapshots.size > SNAPSHOT_MAX) snapshots.delete(snapshots.keys().next().value)
404
+ }
405
+
406
+ const store = {
407
+ root,
408
+ dir,
409
+ grantsFile,
410
+ auditFile,
411
+ APPROVAL_STATES,
412
+
413
+ audit(row) {
414
+ try {
415
+ withLock(() => appendLine(auditFile, { ts: Date.now(), actor: 'codex-delegate', ...row }))
416
+ return true
417
+ } catch (e) {
418
+ console.error('[codex-approvals] audit append failed:', String(e && e.message ? e.message : e).slice(0, 200))
419
+ return false
420
+ }
421
+ },
422
+
423
+ // ------------------------------------------------------------ grant
424
+
425
+ issueGrant({ originThreadId, taskId, scope, ttlMs, leaseMs, credDigest, authorization }) {
426
+ const now = Date.now()
427
+ const ttl = Math.min(typeof ttlMs === 'number' && ttlMs > 0 ? ttlMs : DEFAULT_GRANT_TTL_MS, MAX_GRANT_TTL_MS)
428
+ const lease = typeof leaseMs === 'number' && leaseMs > 0 ? leaseMs : DEFAULT_LEASE_MS
429
+ const sessionId = scope !== null && typeof scope === 'object' && typeof scope.sessionId === 'string' ? scope.sessionId : ''
430
+ if (sessionId === '') throw new Error('grant scope requires a sessionId (session-target binding)')
431
+ if (typeof credDigest !== 'string' || !credDigest.startsWith('digest:')) throw new Error('grant requires a domain-separated credential digest (never the raw key or its bare hash)')
432
+ const grant = {
433
+ type: 'grant',
434
+ grantId: uid('grt'),
435
+ version: 1,
436
+ originThreadId,
437
+ taskId,
438
+ scope: { kind: 'session', sessionId },
439
+ // 授权说明/工作范围随 grant 固定记录并回给审批模型;来源必须是可信发起端提供的文本(服务器不解释成策略)。
440
+ authorization: typeof authorization === 'string' ? sanitizePreview(authorization, 500) : undefined,
441
+ credDigest,
442
+ issuedAt: now,
443
+ expiresAt: now + ttl,
444
+ leaseUntil: now + lease,
445
+ leaseMs: lease,
446
+ status: GRANT_STATES.ACTIVE,
447
+ nonce: crypto.randomBytes(8).toString('hex'),
448
+ }
449
+ withLock(() => appendLine(grantsFile, grant))
450
+ return grant
451
+ },
452
+
453
+ getGrant(grantId) {
454
+ try {
455
+ return foldGrants().get(grantId)
456
+ } catch (e) {
457
+ return undefined
458
+ }
459
+ },
460
+
461
+ /** 列表(支持宣称的过滤;严格读,坏行 → 空 + reason)。 */
462
+ listGrants(filter = {}) {
463
+ let all
464
+ try {
465
+ all = Array.from(foldGrants().values())
466
+ } catch (e) {
467
+ return []
468
+ }
469
+ const out = all.filter((g) => {
470
+ if (filter.grantId !== undefined && g.grantId !== filter.grantId) return false
471
+ if (filter.taskId !== undefined && g.taskId !== filter.taskId) return false
472
+ if (filter.originThreadId !== undefined && g.originThreadId !== filter.originThreadId) return false
473
+ if (filter.sessionId !== undefined && g.scope.sessionId !== filter.sessionId) return false
474
+ if (filter.activeOnly === true && g.status !== GRANT_STATES.ACTIVE) return false
475
+ return true
476
+ })
477
+ out.sort((a, b) => b.issuedAt - a.issuedAt)
478
+ return out
479
+ },
480
+
481
+ grantLogHealthy() {
482
+ try {
483
+ foldGrants()
484
+ return true
485
+ } catch (e) {
486
+ return false
487
+ }
488
+ },
489
+
490
+ /**
491
+ * 校验 grant:身份(grantId/task/origin/session)+ 凭据摘要 + 版本 + 未撤销 + 绝对 TTL + 租约。
492
+ * `options.ignoreLease` 只给续租用(租约正是要被重置的东西)。
493
+ */
494
+ validateGrant({ grantId, taskId, originThreadId, sessionId, credDigest, grantVersion, now = Date.now() }, opts = {}) {
495
+ let grant
496
+ try {
497
+ grant = foldGrants().get(grantId)
498
+ } catch (e) {
499
+ return { ok: false, reason: APPROVAL_REASONS.GRANT_LOG_CORRUPT }
500
+ }
501
+ if (grant === undefined) return { ok: false, reason: APPROVAL_REASONS.GRANT_MISSING }
502
+ if (grant.status === GRANT_STATES.REVOKED) return { ok: false, reason: APPROVAL_REASONS.GRANT_REVOKED }
503
+ if (now > grant.expiresAt) return { ok: false, reason: APPROVAL_REASONS.GRANT_EXPIRED }
504
+ if (typeof credDigest !== 'string' || credDigest !== grant.credDigest) return { ok: false, reason: APPROVAL_REASONS.GRANT_CRED_MISMATCH }
505
+ if (taskId !== undefined && grant.taskId !== taskId) return { ok: false, reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }
506
+ if (originThreadId !== undefined && grant.originThreadId !== originThreadId) return { ok: false, reason: APPROVAL_REASONS.ORIGIN_MISMATCH }
507
+ if (sessionId !== undefined && grant.scope.sessionId !== sessionId) return { ok: false, reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }
508
+ if (grantVersion !== undefined && grantVersion !== grant.version) return { ok: false, reason: APPROVAL_REASONS.GRANT_VERSION_MISMATCH }
509
+ if (opts.ignoreLease !== true && now > grant.leaseUntil) return { ok: false, reason: APPROVAL_REASONS.GRANT_EXPIRED }
510
+ return { ok: true, grant }
511
+ },
512
+
513
+ /** 认证续租:延长**租约**(不复活已过的绝对 TTL)。 */
514
+ renewLease({ grantId, taskId, originThreadId, sessionId, credDigest, leaseMs, now = Date.now() }) {
515
+ const check = store.validateGrant({ grantId, taskId, originThreadId, sessionId, credDigest, now }, { ignoreLease: true })
516
+ if (check.ok !== true) return check
517
+ const lease = typeof leaseMs === 'number' && leaseMs > 0 ? leaseMs : (check.grant.leaseMs || DEFAULT_LEASE_MS)
518
+ const leaseUntil = Math.min(now + lease, check.grant.expiresAt)
519
+ try {
520
+ withLock(() => appendLine(grantsFile, { type: 'grant-update', grantId, leaseUntil, at: now }))
521
+ } catch (e) {
522
+ return { ok: false, reason: 'lease-write-failed' }
523
+ }
524
+ return { ok: true, grant: { ...check.grant, leaseUntil } }
525
+ },
526
+
527
+ /**
528
+ * 认证续期:在**原授权范围内**显式延长绝对 TTL(封顶 MAX_AUTHORIZED_TTL_MS),并顺带延租约。
529
+ * 这就是"长任务如何显式续期"的官方路径(不靠后台无限续租)。
530
+ */
531
+ renewGrant({ grantId, taskId, originThreadId, sessionId, credDigest, extendSec, now = Date.now() }) {
532
+ const check = store.validateGrant({ grantId, taskId, originThreadId, sessionId, credDigest, now }, { ignoreLease: true })
533
+ if (check.ok !== true) return check
534
+ const grant = check.grant
535
+ const cap = grant.issuedAt + MAX_AUTHORIZED_TTL_MS
536
+ const extendMs = typeof extendSec === 'number' && extendSec > 0 ? Math.min(extendSec, 3600) * 1000 : grant.leaseMs || DEFAULT_LEASE_MS
537
+ // 从**当前有效期**起延长(不是每次重置成 now+extend),并封顶在授权窗口内。
538
+ const expiresAt = Math.min(grant.expiresAt + extendMs, cap)
539
+ const leaseMs = grant.leaseMs || DEFAULT_LEASE_MS
540
+ const leaseUntil = Math.min(now + leaseMs, expiresAt)
541
+ try {
542
+ withLock(() => appendLine(grantsFile, { type: 'grant-update', grantId, expiresAt, leaseUntil, at: now }))
543
+ } catch (e) {
544
+ return { ok: false, reason: 'renew-write-failed' }
545
+ }
546
+ return { ok: true, grant: { ...grant, expiresAt, leaseUntil }, cap }
547
+ },
548
+
549
+ revokeGrant(grantId, reason) {
550
+ let grant
551
+ try {
552
+ grant = foldGrants().get(grantId)
553
+ } catch (e) {
554
+ return { ok: false, reason: APPROVAL_REASONS.GRANT_LOG_CORRUPT }
555
+ }
556
+ if (grant === undefined) return { ok: false, reason: APPROVAL_REASONS.GRANT_MISSING }
557
+ if (grant.status === GRANT_STATES.REVOKED) return { ok: true, grant }
558
+ withLock(() => appendLine(grantsFile, { type: 'grant-update', grantId, status: GRANT_STATES.REVOKED, revokedAt: Date.now(), revokeReason: String(reason || 'revoked').slice(0, 200) }))
559
+ for (const pending of pendings.values()) {
560
+ if (pending.grantId === grantId && pending.state === APPROVAL_STATES.PENDING) pending.state = APPROVAL_STATES.SUPERSEDED
561
+ }
562
+ return { ok: true, grant: store.getGrant(grantId) }
563
+ },
564
+
565
+ // ------------------------------------------------------------ 不可变执行快照
566
+
567
+ /**
568
+ * 记录执行快照:按 (sessionId, callId) 存**深拷贝冻结**的 args(修正 A:全局 callId 会串会话)。
569
+ * 第二轮修正:同时冻结**完整执行身份的可见部分**——toolName / rootCallId / parentKey / cwd(cwdSource)——
570
+ * `snapshotId` 覆盖这些内容,因此 tool-only / root-only / parent-only 变化都会换 id;
571
+ * turn/step 只能由 annotateSnapshot 以实数写入(未知不得作为可批准默认值)。
572
+ */
573
+ snapshotCall({ sessionId, callId, rootCallId, parent, parentKey, name, args, cwd, cwdSource, now = Date.now() }) {
574
+ pruneSnapshots(now)
575
+ if (typeof sessionId !== 'string' || sessionId === '' || typeof callId !== 'string' || callId === '') return null
576
+ const frozenArgs = deepCopy(args)
577
+ const content = identityContent({
578
+ sessionId, callId, rootCallId, toolName: name, cwd, args: frozenArgs,
579
+ parentKey: parentKey === undefined ? canonicalParentKey(parent) : parentKey,
580
+ })
581
+ const snapshot = {
582
+ snapshotId: contentIdOf(content) === null
583
+ ? 'cid-unknown:' + sha256Hex(sessionId + '\u0000' + callId).slice(0, 12)
584
+ : contentIdOf(content),
585
+ sessionId,
586
+ callId,
587
+ rootCallId: content === null ? (typeof rootCallId === 'string' ? rootCallId : undefined) : content.rootCallId,
588
+ parentKey: content === null ? 'unsupported' : content.parentKey,
589
+ name: typeof name === 'string' ? name : '',
590
+ cwd: typeof cwd === 'string' && cwd !== '' ? cwd : undefined,
591
+ cwdSource: typeof cwdSource === 'string' ? cwdSource : undefined,
592
+ args: frozenArgs,
593
+ argsSha256: content === null ? null : content.argsSha256,
594
+ identityComplete: content !== null,
595
+ turn: undefined,
596
+ step: undefined,
597
+ turnSource: undefined,
598
+ stepSource: undefined,
599
+ createdAt: now,
600
+ source: 'pre-execute',
601
+ }
602
+ snapshots.set(snapKey(sessionId, callId), snapshot)
603
+ return snapshot
604
+ },
605
+
606
+ getSnapshot(sessionId, callId) {
607
+ return snapshots.get(snapKey(sessionId, callId))
608
+ },
609
+
610
+ /** 写入 turn/step:两者都必须是整数,否则拒绝(返回 null);同时固化**完整执行身份**。 */
611
+ annotateSnapshot(sessionId, callId, patch) {
612
+ const snapshot = snapshots.get(snapKey(sessionId, callId))
613
+ if (snapshot === undefined) return null
614
+ const turn = patch !== null && typeof patch === 'object' ? patch.turn : undefined
615
+ const step = patch !== null && typeof patch === 'object' ? patch.step : undefined
616
+ if (!Number.isInteger(turn) || !Number.isInteger(step)) return null
617
+ snapshot.turn = turn
618
+ snapshot.step = step
619
+ snapshot.turnSource = typeof patch.turnSource === 'string' ? patch.turnSource : 'log'
620
+ snapshot.stepSource = typeof patch.stepSource === 'string' ? patch.stepSource : 'log'
621
+ snapshot.executionId = this.executionIdentity(snapshot, { turn, step })
622
+ return snapshot
623
+ },
624
+
625
+ /** 由快照 + 给定 turn/step 求完整执行身份(null = 身份不完整/未知)。 */
626
+ executionIdentity(snapshot, turnStep) {
627
+ if (snapshot === undefined || snapshot === null) return null
628
+ return executionIdentityId({
629
+ sessionId: snapshot.sessionId,
630
+ callId: snapshot.callId,
631
+ rootCallId: snapshot.rootCallId,
632
+ parentKey: snapshot.parentKey,
633
+ toolName: snapshot.name,
634
+ cwd: snapshot.cwd,
635
+ args: snapshot.args,
636
+ turn: turnStep === undefined || turnStep === null ? snapshot.turn : turnStep.turn,
637
+ step: turnStep === undefined || turnStep === null ? snapshot.step : turnStep.step,
638
+ })
639
+ },
640
+
641
+ // ------------------------------------------------------------ 官方请求身份认领
642
+
643
+ /** 观测官方 approval/asked:建立 officialId → (session, toolName, callId) 台账。 */
644
+ observeOfficialAsked({ officialId, sessionId, toolName, callId, seenAt = Date.now() }) {
645
+ if (typeof officialId !== 'string' || officialId === '') return null
646
+ const existing = officialAsks.get(officialId)
647
+ if (existing !== undefined) return existing
648
+ const row = {
649
+ officialId,
650
+ sessionId,
651
+ toolName: typeof toolName === 'string' ? toolName : '',
652
+ callId: typeof callId === 'string' ? callId : undefined,
653
+ seenAt,
654
+ claimedBy: undefined,
655
+ }
656
+ officialAsks.set(officialId, row)
657
+ return row
658
+ },
659
+
660
+ /** 仍未认领的候选(用于"唯一认领"判定;绝不按最后/第一条近似匹配)。 */
661
+ unclaimedAskedFor(sessionId, callId, toolName) {
662
+ const out = []
663
+ for (const row of officialAsks.values()) {
664
+ if (row.claimedBy !== undefined) continue
665
+ if (row.sessionId !== sessionId) continue
666
+ if (row.callId !== callId) continue
667
+ if (row.toolName !== toolName) continue
668
+ out.push(row)
669
+ }
670
+ out.sort((a, b) => a.seenAt - b.seenAt)
671
+ return out
672
+ },
673
+
674
+ /** 把某个官方 ask 唯一认领给某条桥接 pending;已被认领则失败。 */
675
+ claimOfficialAsk(approvalKey, officialId) {
676
+ const row = officialAsks.get(officialId)
677
+ const pending = pendings.get(approvalKey)
678
+ if (row === undefined || pending === undefined) return { ok: false, reason: APPROVAL_REASONS.OFFICIAL_UNBOUND }
679
+ if (row.claimedBy !== undefined && row.claimedBy !== approvalKey) return { ok: false, reason: APPROVAL_REASONS.OFFICIAL_AMBIGUOUS }
680
+ row.claimedBy = approvalKey
681
+ pending.officialId = officialId
682
+ return { ok: true }
683
+ },
684
+
685
+ // ------------------------------------------------------------ pending
686
+
687
+ /**
688
+ * 每次官方 ask 一条独立 pending(修正 B:不再按 session+callId 合并)。
689
+ * 执行上下文在打开时刻**深拷贝冻结**,展示与决定只看它(修正 A)。
690
+ */
691
+ openPending({ execution, grant, reason, displayReason, askSeq = 1, now = Date.now() }) {
692
+ const frozen = deepCopy(execution)
693
+ // **身份不完整一律拒绝打开**(未知 cwd/工具/父身份/turn·step → unsupported,不许假装完整)
694
+ const frozenIdentity = executionIdentityId({
695
+ sessionId: frozen.sessionId,
696
+ callId: frozen.callId,
697
+ rootCallId: frozen.rootCallId,
698
+ parentKey: frozen.parentKey,
699
+ toolName: frozen.toolName,
700
+ cwd: frozen.cwd,
701
+ args: frozen.args,
702
+ turn: frozen.turn,
703
+ step: frozen.step,
704
+ })
705
+ if (frozenIdentity === null) return null
706
+ if (contentIdOf(identityContent({
707
+ sessionId: frozen.sessionId, callId: frozen.callId, rootCallId: frozen.rootCallId,
708
+ parentKey: frozen.parentKey, toolName: frozen.toolName, cwd: frozen.cwd, args: frozen.args,
709
+ })) !== frozen.snapshotId) {
710
+ // 快照内容与打开时刻传入的执行身份必须一致(接线错误/陈旧快照 → 拒绝,不猜)
711
+ return null
712
+ }
713
+ const pending = {
714
+ approvalKey: uid('apk'),
715
+ execution: frozen,
716
+ snapshotId: frozen.snapshotId,
717
+ executionId: frozenIdentity,
718
+ fingerprint: actionFingerprint({
719
+ sessionId: frozen.sessionId,
720
+ callId: frozen.callId,
721
+ toolName: frozen.toolName,
722
+ turn: frozen.turn,
723
+ step: frozen.step,
724
+ rootCallId: frozen.rootCallId,
725
+ parentKey: frozen.parentKey,
726
+ cwd: frozen.cwd,
727
+ args: frozen.args,
728
+ taskId: grant.taskId,
729
+ grantId: grant.grantId,
730
+ }),
731
+ grantId: grant.grantId,
732
+ grantVersion: grant.version,
733
+ taskId: grant.taskId,
734
+ originThreadId: grant.originThreadId,
735
+ authorization: grant.authorization,
736
+ sessionId: frozen.sessionId,
737
+ callId: frozen.callId,
738
+ toolName: frozen.toolName,
739
+ reason: typeof reason === 'string' ? sanitizePreview(reason, 300) : undefined,
740
+ displayReason: typeof displayReason === 'object' && displayReason !== null ? displayReason : undefined,
741
+ officialId: undefined,
742
+ askSeq,
743
+ state: APPROVAL_STATES.PENDING,
744
+ createdAt: now,
745
+ expiresAt: Math.min(grant.expiresAt, grant.leaseUntil),
746
+ decision: undefined,
747
+ outcome: undefined,
748
+ decidedAt: undefined,
749
+ confirmedOutcome: undefined,
750
+ idempotencyKey: undefined,
751
+ note: undefined,
752
+ }
753
+ pendings.set(pending.approvalKey, pending)
754
+ const indexKey = snapKey(pending.sessionId, pending.callId)
755
+ const list = executionIndex.get(indexKey) || []
756
+ list.push(pending.approvalKey)
757
+ executionIndex.set(indexKey, list)
758
+ store.audit({
759
+ type: 'approval', phase: 'open', approvalKey: pending.approvalKey, grantId: pending.grantId, taskId: pending.taskId,
760
+ originThreadId: pending.originThreadId, sessionId: pending.sessionId, callId: pending.callId,
761
+ toolName: pending.toolName, turn: frozen.turn, step: frozen.step, cwd: frozen.cwd,
762
+ snapshotId: pending.snapshotId, fingerprint: pending.fingerprint, askSeq,
763
+ officialId: pending.officialId,
764
+ ...auditActionSummary(frozen),
765
+ })
766
+ return pending
767
+ },
768
+
769
+ getPending(approvalKey) {
770
+ return pendings.get(approvalKey)
771
+ },
772
+
773
+ /** 同 (session, callId) 的 ask 次数(用于多 ask 场景顺序与歧义判断)。 */
774
+ askCountFor(sessionId, callId) {
775
+ const list = executionIndex.get(snapKey(sessionId, callId))
776
+ return Array.isArray(list) ? list.length : 0
777
+ },
778
+
779
+ /** 展示视图:**只从 pending 的不可变副本读**,绝不回看其它请求的当前 snapshot(修正 A)。 */
780
+ pendingView(p) {
781
+ const g = store.getGrant(p.grantId)
782
+ return {
783
+ approvalKey: p.approvalKey,
784
+ state: p.state,
785
+ sessionId: p.sessionId,
786
+ callId: p.callId,
787
+ toolName: p.toolName,
788
+ reason: p.reason,
789
+ displayReason: p.displayReason,
790
+ args: p.execution.args,
791
+ cwd: p.execution.cwd,
792
+ turn: p.execution.turn,
793
+ step: p.execution.step,
794
+ turnSource: p.execution.turnSource,
795
+ stepSource: p.execution.stepSource,
796
+ rootCallId: p.execution.rootCallId,
797
+ parentKey: p.execution.parentKey === undefined ? null : p.execution.parentKey,
798
+ cwdSource: p.execution.cwdSource,
799
+ snapshotId: p.snapshotId,
800
+ // 完整执行身份(第二轮):toolName/root/parent/cwd/args + turn/step 全部参与;
801
+ // 能打开 pending 就意味着身份完整(openPending 对不完整身份直接拒绝),故以 executionId 为准。
802
+ executionId: p.executionId,
803
+ identityComplete: p.executionId !== undefined,
804
+ identity: {
805
+ toolName: p.execution.toolName,
806
+ callId: p.execution.callId,
807
+ rootCallId: p.execution.rootCallId,
808
+ parentKey: p.execution.parentKey === undefined ? null : p.execution.parentKey,
809
+ cwd: p.execution.cwd,
810
+ cwdSource: p.execution.cwdSource,
811
+ turn: p.execution.turn,
812
+ step: p.execution.step,
813
+ argsSha256: sha256Hex(canonicalJson(p.execution.args)),
814
+ },
815
+ argsSha256: sha256Hex(canonicalJson(p.execution.args)),
816
+ fingerprint: p.fingerprint,
817
+ grantId: p.grantId,
818
+ grantVersion: p.grantVersion,
819
+ taskId: p.taskId,
820
+ originThreadId: p.originThreadId,
821
+ authorization: p.authorization,
822
+ askSeq: p.askSeq,
823
+ officialId: p.officialId,
824
+ live: p.state === APPROVAL_STATES.PENDING,
825
+ createdAt: p.createdAt,
826
+ expiresAt: p.expiresAt,
827
+ ...(p.decision === undefined ? {} : { decision: p.decision, outcome: p.outcome, decidedAt: p.decidedAt }),
828
+ ...(p.confirmedOutcome === undefined ? {} : { confirmedOutcome: p.confirmedOutcome }),
829
+ ...(p.note === undefined ? {} : { note: p.note }),
830
+ ...(g === undefined ? {} : { grant: { status: g.status, version: g.version, issuedAt: g.issuedAt, expiresAt: g.expiresAt, leaseUntil: g.leaseUntil } }),
831
+ }
832
+ },
833
+
834
+ /** 清单:必须显式给 origin(可再叠加 task/session/grant)——不给就空,绝不跨来源泄露。 */
835
+ listPending(filter = {}) {
836
+ const out = []
837
+ for (const p of pendings.values()) {
838
+ if (filter.approvalKey !== undefined && p.approvalKey !== filter.approvalKey) continue
839
+ if (filter.originThreadId !== undefined && p.originThreadId !== filter.originThreadId) continue
840
+ if (filter.taskId !== undefined && p.taskId !== filter.taskId) continue
841
+ if (filter.sessionId !== undefined && p.sessionId !== filter.sessionId) continue
842
+ if (filter.grantId !== undefined && p.grantId !== filter.grantId) continue
843
+ out.push(p)
844
+ }
845
+ out.sort((a, b) => a.createdAt - b.createdAt)
846
+ return out
847
+ },
848
+
849
+ /** 该 task 是否还有未决 pending(用于"并发时不能先把 task 设 running")。 */
850
+ liveCountForTask(taskId) {
851
+ let count = 0
852
+ for (const p of pendings.values()) {
853
+ if (p.taskId === taskId && p.state === APPROVAL_STATES.PENDING) count++
854
+ }
855
+ return count
856
+ },
857
+
858
+ /**
859
+ * 一次性决定(修正 A/B/C/D/F):
860
+ * 身份(origin/task/grant/version)+ 凭据摘要 + 租约/绝对 TTL + 官方身份已唯一认领
861
+ * + **实时快照身份仍等于** pending 的不可变副本 + 指纹一致 + 审计 intent 先行。
862
+ */
863
+ decide({ approvalKey, fingerprint, decision, idempotencyKey, credDigest, originThreadId, taskId, grantVersion, currentSnapshotId, currentTurnStep, now = Date.now() }) {
864
+ const pending = pendings.get(approvalKey)
865
+ if (pending === undefined) return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
866
+ const outcome = OUTCOME_FOR[decision]
867
+ if (outcome === undefined) return { ok: false, reason: 'unknown-decision' }
868
+
869
+ if (pending.state !== APPROVAL_STATES.PENDING) {
870
+ if (typeof idempotencyKey === 'string' && idempotencyKey !== '' && pending.idempotencyKey === idempotencyKey) {
871
+ if (pending.decision === decision) return { ok: true, state: pending.state, outcome: pending.outcome, idempotent: true }
872
+ return { ok: false, reason: APPROVAL_REASONS.IDEMPOTENCY_CONFLICT }
873
+ }
874
+ return { ok: false, reason: APPROVAL_REASONS.PENDING_ALREADY_DECIDED, state: pending.state }
875
+ }
876
+
877
+ if (originThreadId === undefined || taskId === undefined) return { ok: false, reason: originThreadId === undefined ? APPROVAL_REASONS.ORIGIN_REQUIRED : APPROVAL_REASONS.TASK_REQUIRED }
878
+ if (originThreadId !== pending.originThreadId) return { ok: false, reason: APPROVAL_REASONS.ORIGIN_MISMATCH }
879
+ if (taskId !== pending.taskId) return { ok: false, reason: APPROVAL_REASONS.GRANT_SCOPE_MISMATCH }
880
+ if (grantVersion !== undefined && grantVersion !== pending.grantVersion) return { ok: false, reason: APPROVAL_REASONS.GRANT_VERSION_MISMATCH }
881
+ if (typeof pending.officialId !== 'string' || pending.officialId === '') return { ok: false, reason: APPROVAL_REASONS.OFFICIAL_UNBOUND }
882
+
883
+ const check = store.validateGrant({
884
+ grantId: pending.grantId, taskId: pending.taskId, originThreadId: pending.originThreadId,
885
+ sessionId: pending.sessionId, credDigest, grantVersion: pending.grantVersion, now,
886
+ }, { ignoreLease: true })
887
+ if (check.ok !== true) return { ok: false, reason: check.reason }
888
+
889
+ const windowEnd = Math.min(check.grant.expiresAt, check.grant.leaseUntil)
890
+ if (now > windowEnd) {
891
+ pending.state = APPROVAL_STATES.EXPIRED
892
+ pending.note = 'grant lease/window expired before a decision arrived'
893
+ store.audit({ type: 'decision', phase: 'expired', approvalKey, grantId: pending.grantId, taskId: pending.taskId })
894
+ return { ok: false, reason: APPROVAL_REASONS.PENDING_EXPIRED }
895
+ }
896
+
897
+ // 实时执行身份必须是**同一个**(第二轮修正):完整身份 = toolName/root/parent/cwd/args + 当前日志 turn/step。
898
+ // 只比 coarse snapshotId 不够——root-only / tool-only / **step-only(仅日志新 tool/call)** 都必须被识别。
899
+ const live = store.getSnapshot(pending.sessionId, pending.callId)
900
+ if (live === undefined) return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
901
+ if (currentSnapshotId !== undefined && currentSnapshotId !== pending.snapshotId) {
902
+ return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
903
+ }
904
+ if (live.snapshotId !== pending.snapshotId) return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
905
+ if (live.identityComplete !== true) return { ok: false, reason: APPROVAL_REASONS.IDENTITY_UNKNOWN }
906
+ const turn = currentTurnStep !== null && typeof currentTurnStep === 'object' ? currentTurnStep.turn : undefined
907
+ const step = currentTurnStep !== null && typeof currentTurnStep === 'object' ? currentTurnStep.step : undefined
908
+ if (!Number.isInteger(turn) || !Number.isInteger(step)) {
909
+ // 拿不到当前 turn/step(日志不可读/无 tool/call)→ 明确 unsupported,不猜、不放行
910
+ return { ok: false, reason: APPROVAL_REASONS.IDENTITY_UNKNOWN }
911
+ }
912
+ const liveIdentity = store.executionIdentity(live, { turn, step })
913
+ if (liveIdentity === null) return { ok: false, reason: APPROVAL_REASONS.IDENTITY_UNKNOWN }
914
+ if (liveIdentity !== pending.executionId) return { ok: false, reason: APPROVAL_REASONS.CONTEXT_CHANGED }
915
+
916
+ if (typeof fingerprint !== 'string' || fingerprint !== pending.fingerprint) {
917
+ return { ok: false, reason: APPROVAL_REASONS.FINGERPRINT_MISMATCH }
918
+ }
919
+
920
+ const intentOk = store.audit({
921
+ type: 'decision', phase: 'submitted', approvalKey, grantId: pending.grantId, grantVersion: pending.grantVersion,
922
+ taskId: pending.taskId, originThreadId: pending.originThreadId, sessionId: pending.sessionId,
923
+ callId: pending.callId, toolName: pending.toolName, decision, fingerprint: pending.fingerprint,
924
+ officialId: pending.officialId, snapshotId: pending.snapshotId,
925
+ idempotencyKey: typeof idempotencyKey === 'string' ? idempotencyKey : undefined,
926
+ credDigest: typeof credDigest === 'string' ? credDigest.slice(0, 24) : undefined,
927
+ ...auditActionSummary(pending.execution),
928
+ })
929
+ if (!intentOk) return { ok: false, reason: APPROVAL_REASONS.AUDIT_WRITE_FAILED }
930
+
931
+ pending.state = APPROVAL_STATES.SUBMITTED
932
+ pending.decision = decision
933
+ pending.outcome = outcome
934
+ pending.idempotencyKey = typeof idempotencyKey === 'string' ? idempotencyKey : undefined
935
+ pending.decidedAt = now
936
+ return { ok: true, state: pending.state, outcome, approvalKey }
937
+ },
938
+
939
+ /**
940
+ * 官方 approval/decided 观测 → 按**具体 officialId** 收敛;outcome 决定状态(修正 F):
941
+ * allowed-once → confirmed;cancelled → cancelled;rejected → rejected;其它 → decision-unknown。
942
+ * 绝不因"我们返回过 allowed-once"就记 confirmed,也绝不把 cancelled/rejected 记成批准。
943
+ */
944
+ settleFromOfficial(officialId, officialOutcome) {
945
+ let target
946
+ for (const p of pendings.values()) {
947
+ if (p.officialId === officialId) { target = p; break }
948
+ }
949
+ if (target === undefined) {
950
+ orphans.push({ officialId, officialOutcome, at: Date.now() })
951
+ return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
952
+ }
953
+ if (officialOutcome === 'allowed-once') {
954
+ if (target.state === APPROVAL_STATES.CONFIRMED) return { ok: true, state: target.state, idempotent: true }
955
+ if (target.state !== APPROVAL_STATES.SUBMITTED) return { ok: false, reason: APPROVAL_REASONS.PENDING_ALREADY_DECIDED, state: target.state }
956
+ target.state = APPROVAL_STATES.CONFIRMED
957
+ target.confirmedOutcome = officialOutcome
958
+ store.audit({ type: 'decision', phase: 'confirmed', approvalKey: target.approvalKey, grantId: target.grantId, taskId: target.taskId, officialId, outcome: officialOutcome })
959
+ return { ok: true, state: target.state }
960
+ }
961
+ // cancelled 可以赢过晚到的 allowed-once;rejected 亦然——都不算批准
962
+ const mapped = officialOutcome === 'cancelled' ? APPROVAL_STATES.CANCELLED
963
+ : officialOutcome === 'rejected' ? APPROVAL_STATES.REJECTED
964
+ : APPROVAL_STATES.DECISION_UNKNOWN
965
+ if (target.state === mapped) return { ok: true, state: target.state, idempotent: true }
966
+ target.state = mapped
967
+ target.confirmedOutcome = officialOutcome
968
+ target.note = 'official outcome=' + String(officialOutcome)
969
+ store.audit({ type: 'decision', phase: mapped, approvalKey: target.approvalKey, grantId: target.grantId, taskId: target.taskId, officialId, outcome: officialOutcome })
970
+ return { ok: true, state: target.state }
971
+ },
972
+
973
+ markDecisionUnknown(approvalKey, why) {
974
+ const pending = pendings.get(approvalKey)
975
+ if (pending === undefined) return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
976
+ if (pending.state !== APPROVAL_STATES.SUBMITTED) return { ok: true, state: pending.state, idempotent: true }
977
+ pending.state = APPROVAL_STATES.DECISION_UNKNOWN
978
+ pending.note = typeof why === 'string' ? sanitizePreview(why, 200) : 'decision confirmation lost'
979
+ store.audit({ type: 'decision', phase: 'unknown', approvalKey, grantId: pending.grantId, taskId: pending.taskId })
980
+ return { ok: true, state: pending.state }
981
+ },
982
+
983
+ /** 收口(交还人工/上下文变化/取消/卸载)。只会动 PENDING 的项。 */
984
+ closePending(approvalKey, state, note) {
985
+ const pending = pendings.get(approvalKey)
986
+ if (pending === undefined) return { ok: false, reason: APPROVAL_REASONS.PENDING_MISSING }
987
+ if (pending.state !== APPROVAL_STATES.PENDING) return { ok: true, state: pending.state, idempotent: true }
988
+ pending.state = state
989
+ pending.note = typeof note === 'string' ? sanitizePreview(note, 200) : undefined
990
+ store.audit({ type: 'decision', phase: state, approvalKey, grantId: pending.grantId, taskId: pending.taskId, note: pending.note })
991
+ return { ok: true, state }
992
+ },
993
+
994
+ /** 卸载/撤销时把该 task(或全部)在途挂起项安全收口。 */
995
+ closeLiveForTask(taskId, state, note) {
996
+ let closed = 0
997
+ for (const p of pendings.values()) {
998
+ if (taskId !== undefined && p.taskId !== taskId) continue
999
+ if (p.state !== APPROVAL_STATES.PENDING) continue
1000
+ store.closePending(p.approvalKey, state, note)
1001
+ closed++
1002
+ }
1003
+ return closed
1004
+ },
1005
+
1006
+ /** 决定已提交但未确认的(供重启/收口时显式标 unknown)。 */
1007
+ submittedKeys() {
1008
+ const out = []
1009
+ for (const p of pendings.values()) if (p.state === APPROVAL_STATES.SUBMITTED) out.push(p.approvalKey)
1010
+ return out
1011
+ },
1012
+
1013
+ /**
1014
+ * 重启/恢复视图(修正 F):审计里 open 过但状态未定的项必须**明确**出现,不能静默消失:
1015
+ * - 有 submitted 无 confirmed → decision-unknown / live:false
1016
+ * - 无决定记录 → unanswerable / live:false
1017
+ * - confirmed/终态 → 不再列出
1018
+ */
1019
+ unansweredFromAudit() {
1020
+ const rows = readJsonl(auditFile)
1021
+ const opened = new Map()
1022
+ const decided = new Map()
1023
+ for (const row of rows) {
1024
+ if (row.type === 'approval' && row.phase === 'open' && typeof row.approvalKey === 'string') opened.set(row.approvalKey, row)
1025
+ if (row.type === 'decision' && typeof row.approvalKey === 'string') {
1026
+ const list = decided.get(row.approvalKey) || []
1027
+ list.push(row)
1028
+ decided.set(row.approvalKey, list)
1029
+ }
1030
+ }
1031
+ const out = []
1032
+ for (const [approvalKey, row] of opened) {
1033
+ // 第三轮修正:**本进程内存里仍有确切状态/有效 callback 的同一条绝不列为"重启残留"**
1034
+ // (真实 MCP 输出曾把同一个 pending 同时列进 approvals(live:true) 和 unanswerable)。
1035
+ // 只有真正丢过 callback 的记录(重启后新 store 里没有该 pending)才进入恢复视图。
1036
+ if (pendings.has(approvalKey)) continue
1037
+ const history = decided.get(approvalKey) || []
1038
+ const phases = history.map((r) => r.phase)
1039
+ if (phases.some((p) => ['confirmed', 'rejected', 'cancelled', 'expired', 'context-changed', 'unsupported-ambiguous'].includes(p))) continue
1040
+ const submitted = phases.includes('submitted')
1041
+ out.push({
1042
+ approvalKey,
1043
+ sessionId: row.sessionId,
1044
+ callId: row.callId,
1045
+ toolName: row.toolName,
1046
+ taskId: row.taskId,
1047
+ originThreadId: row.originThreadId,
1048
+ live: false,
1049
+ state: submitted ? APPROVAL_STATES.DECISION_UNKNOWN : 'unanswerable',
1050
+ note: submitted
1051
+ ? 'submitted but no official approval/decided was observed — confirmation unknown; do not re-send an approval'
1052
+ : 'no live callback after restart — cannot approve; wait for a fresh official request or human action',
1053
+ })
1054
+ }
1055
+ return out
1056
+ },
1057
+
1058
+ readAudit(limit = 50) {
1059
+ const rows = readJsonl(auditFile)
1060
+ return rows.slice(-limit).reverse()
1061
+ },
1062
+
1063
+ auditCount() {
1064
+ return readJsonl(auditFile).length
1065
+ },
1066
+
1067
+ /** 测试/诊断:当前在内存里的 pending 数。 */
1068
+ liveCount() {
1069
+ return pendings.size
1070
+ },
1071
+
1072
+ orphanDecided() {
1073
+ return orphans.slice()
1074
+ },
1075
+ }
1076
+
1077
+ return store
1078
+ }