trustdex 0.6.0 → 0.7.0

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,540 @@
1
+ import { sha256Json } from './canonical.mjs';
2
+
3
+ const ACTION_RANK = Object.freeze({ allow: 0, ask: 1, block: 2 });
4
+ const SNAPSHOT_KIND = 'agent-permission-posture';
5
+ const SNAPSHOT_VERSION = 1;
6
+
7
+ const CLAUDE_MODES = new Set([
8
+ null,
9
+ 'default',
10
+ 'manual',
11
+ 'acceptEdits',
12
+ 'plan',
13
+ 'dontAsk',
14
+ 'bypassPermissions',
15
+ 'auto'
16
+ ]);
17
+ const CLAUDE_CATEGORIES = new Set(['filesystem', 'mcp', 'network', 'other', 'shell', 'subagent']);
18
+ const CODEX_APPROVALS = new Set([null, 'on-request', 'never', 'granular']);
19
+ const CODEX_REVIEWERS = new Set([null, 'user', 'auto_review']);
20
+ const CODEX_SANDBOXES = new Set([null, 'read-only', 'workspace-write', 'danger-full-access']);
21
+ const CODEX_PERMISSIONS = new Set([null, ':read-only', ':workspace', ':danger-full-access', 'custom']);
22
+ const CODEX_SEARCH = new Set([null, 'disabled', 'cached', 'indexed', 'live']);
23
+ const CODEX_PROMPTS = new Set([
24
+ 'mcp_elicitations',
25
+ 'request_permissions',
26
+ 'rules',
27
+ 'sandbox_approval',
28
+ 'skill_approval'
29
+ ]);
30
+ const RUNTIME_SIGNALS = Object.freeze({
31
+ claude: new Set([
32
+ 'claude-auto-approve',
33
+ 'claude-broad-auto-approve',
34
+ 'claude-shell-auto-approve',
35
+ 'claude-filesystem-auto-approve',
36
+ 'claude-network-auto-approve',
37
+ 'claude-subagent-auto-approve',
38
+ 'claude-mcp-auto-approve',
39
+ 'claude-mcp-wildcard',
40
+ 'claude-bypass-permissions',
41
+ 'claude-auto-mode',
42
+ 'claude-additional-directories',
43
+ 'claude-outside-read-access',
44
+ 'claude-hooks',
45
+ 'claude-credential-helper',
46
+ 'claude-environment',
47
+ 'claude-sandbox-settings',
48
+ 'claude-extension-loading',
49
+ 'uninspected-claude-settings',
50
+ 'secret-env'
51
+ ]),
52
+ codex: new Set([
53
+ 'codex-no-approvals',
54
+ 'codex-auto-review',
55
+ 'codex-workspace-write',
56
+ 'codex-full-access',
57
+ 'codex-network-access',
58
+ 'codex-additional-writable-roots',
59
+ 'codex-temp-write-access',
60
+ 'codex-live-web-search',
61
+ 'codex-custom-permissions',
62
+ 'codex-subagents-enabled',
63
+ 'codex-subagent-settings',
64
+ 'codex-extension-settings',
65
+ 'uninspected-codex-settings',
66
+ 'uninspected-codex-sections'
67
+ ])
68
+ });
69
+
70
+ function requireObject(value, message) {
71
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(message);
72
+ return value;
73
+ }
74
+
75
+ function requireCount(value, field) {
76
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid permission posture count: ${field}`);
77
+ return value;
78
+ }
79
+
80
+ function requireBoolean(value, field) {
81
+ if (typeof value !== 'boolean') throw new Error(`Invalid permission posture boolean: ${field}`);
82
+ return value;
83
+ }
84
+
85
+ function requireEnum(value, allowed, field) {
86
+ if (!allowed.has(value)) throw new Error(`Invalid permission posture value: ${field}`);
87
+ return value;
88
+ }
89
+
90
+ function requireStringArray(value, allowed, field) {
91
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || !allowed.has(item))) {
92
+ throw new Error(`Invalid permission posture list: ${field}`);
93
+ }
94
+ return [...new Set(value)].sort();
95
+ }
96
+
97
+ function assertMetadataKeys(metadata, expected) {
98
+ for (const key of Object.keys(metadata)) {
99
+ if (!expected.has(key)) throw new Error(`Unsupported permission posture metadata: ${key}`);
100
+ }
101
+ }
102
+
103
+ function normalizeClaudeMetadata(metadata) {
104
+ const expected = new Set([
105
+ 'defaultMode',
106
+ 'allowRuleCount',
107
+ 'askRuleCount',
108
+ 'denyRuleCount',
109
+ 'allowCategories',
110
+ 'additionalDirectoryCount',
111
+ 'bypassDisabled',
112
+ 'autoModeDisabled',
113
+ 'outsideReadsBlocked',
114
+ 'uninspectedRootKeys'
115
+ ]);
116
+ assertMetadataKeys(metadata, expected);
117
+ if (!Array.isArray(metadata.uninspectedRootKeys) ||
118
+ metadata.uninspectedRootKeys.some((item) => typeof item !== 'string')) {
119
+ throw new Error('Invalid Claude uninspected root key metadata.');
120
+ }
121
+ return {
122
+ defaultMode: requireEnum(metadata.defaultMode, CLAUDE_MODES, 'defaultMode'),
123
+ allowRuleCount: requireCount(metadata.allowRuleCount, 'allowRuleCount'),
124
+ askRuleCount: requireCount(metadata.askRuleCount, 'askRuleCount'),
125
+ denyRuleCount: requireCount(metadata.denyRuleCount, 'denyRuleCount'),
126
+ allowCategories: requireStringArray(metadata.allowCategories, CLAUDE_CATEGORIES, 'allowCategories'),
127
+ additionalDirectoryCount: requireCount(metadata.additionalDirectoryCount, 'additionalDirectoryCount'),
128
+ bypassDisabled: requireBoolean(metadata.bypassDisabled, 'bypassDisabled'),
129
+ autoModeDisabled: requireBoolean(metadata.autoModeDisabled, 'autoModeDisabled'),
130
+ outsideReadsBlocked: requireBoolean(metadata.outsideReadsBlocked, 'outsideReadsBlocked'),
131
+ uninspectedRootKeyCount: metadata.uninspectedRootKeys.length
132
+ };
133
+ }
134
+
135
+ function normalizeCodexMetadata(metadata) {
136
+ const expected = new Set([
137
+ 'approvalPolicy',
138
+ 'granularPromptCategories',
139
+ 'approvalsReviewer',
140
+ 'sandboxMode',
141
+ 'defaultPermissionsKind',
142
+ 'webSearch',
143
+ 'networkAccess',
144
+ 'additionalWritableRootCount',
145
+ 'tempRootsExcluded',
146
+ 'subagentsEnabled',
147
+ 'uninspectedAgentKeys',
148
+ 'uninspectedRootKeys',
149
+ 'uninspectedSectionKinds'
150
+ ]);
151
+ assertMetadataKeys(metadata, expected);
152
+ for (const field of ['uninspectedAgentKeys', 'uninspectedRootKeys', 'uninspectedSectionKinds']) {
153
+ if (!Array.isArray(metadata[field]) || metadata[field].some((item) => typeof item !== 'string')) {
154
+ throw new Error(`Invalid Codex ${field} metadata.`);
155
+ }
156
+ }
157
+ return {
158
+ approvalPolicy: requireEnum(metadata.approvalPolicy, CODEX_APPROVALS, 'approvalPolicy'),
159
+ granularPromptCategories: requireStringArray(
160
+ metadata.granularPromptCategories,
161
+ CODEX_PROMPTS,
162
+ 'granularPromptCategories'
163
+ ),
164
+ approvalsReviewer: requireEnum(metadata.approvalsReviewer, CODEX_REVIEWERS, 'approvalsReviewer'),
165
+ sandboxMode: requireEnum(metadata.sandboxMode, CODEX_SANDBOXES, 'sandboxMode'),
166
+ defaultPermissionsKind: requireEnum(
167
+ metadata.defaultPermissionsKind,
168
+ CODEX_PERMISSIONS,
169
+ 'defaultPermissionsKind'
170
+ ),
171
+ webSearch: requireEnum(metadata.webSearch, CODEX_SEARCH, 'webSearch'),
172
+ networkAccess: requireBoolean(metadata.networkAccess, 'networkAccess'),
173
+ additionalWritableRootCount: requireCount(
174
+ metadata.additionalWritableRootCount,
175
+ 'additionalWritableRootCount'
176
+ ),
177
+ tempRootsExcluded: requireBoolean(metadata.tempRootsExcluded, 'tempRootsExcluded'),
178
+ subagentsEnabled: requireBoolean(metadata.subagentsEnabled, 'subagentsEnabled'),
179
+ uninspectedAgentKeyCount: metadata.uninspectedAgentKeys.length,
180
+ uninspectedRootKeyCount: metadata.uninspectedRootKeys.length,
181
+ uninspectedSectionKindCount: metadata.uninspectedSectionKinds.length
182
+ };
183
+ }
184
+
185
+ function projectAction(value) {
186
+ return Object.hasOwn(ACTION_RANK, value) ? value : value == null ? null : 'invalid';
187
+ }
188
+
189
+ function projectPolicy(policy, runtime) {
190
+ const relevantSignals = RUNTIME_SIGNALS[runtime];
191
+ const signalActions = Object.fromEntries(
192
+ Object.entries(policy?.signalActions || {})
193
+ .filter(([signal]) => relevantSignals.has(signal))
194
+ .map(([signal, action]) => [signal, projectAction(action)])
195
+ .sort(([left], [right]) => left.localeCompare(right))
196
+ );
197
+ return {
198
+ unknownAction: projectAction(policy?.unknownAction),
199
+ localAction: projectAction(policy?.localAction),
200
+ sensitiveAction: projectAction(policy?.sensitiveAction),
201
+ signalActions
202
+ };
203
+ }
204
+
205
+ function snapshotDigest(snapshot) {
206
+ return sha256Json({
207
+ schemaVersion: snapshot.schemaVersion,
208
+ kind: snapshot.kind,
209
+ runtime: snapshot.runtime,
210
+ policyDigest: snapshot.policyDigest,
211
+ posture: snapshot.posture
212
+ });
213
+ }
214
+
215
+ export function createPermissionPostureSnapshot(result, policy = {}) {
216
+ requireObject(result, 'Permission posture requires one evaluated settings result.');
217
+ if (!Object.hasOwn(ACTION_RANK, result.action)) throw new Error('Permission posture result has an invalid action.');
218
+ if (!Array.isArray(result.signals) || result.signals.some((item) => typeof item !== 'string')) {
219
+ throw new Error('Permission posture result has invalid signals.');
220
+ }
221
+ const runtime = result.kind === 'claude-settings'
222
+ ? 'claude'
223
+ : result.kind === 'codex-settings' ? 'codex' : null;
224
+ if (!runtime) throw new Error('Permission posture supports Claude or Codex settings results only.');
225
+ if (result.signals.some((signal) => !RUNTIME_SIGNALS[runtime].has(signal))) {
226
+ throw new Error('Permission posture result contains an unsupported signal.');
227
+ }
228
+
229
+ const metadata = requireObject(result.metadata, 'Permission posture result is missing metadata.');
230
+ const posture = {
231
+ action: result.action,
232
+ signals: [...new Set(result.signals)].sort(),
233
+ metadata: runtime === 'claude'
234
+ ? normalizeClaudeMetadata(metadata)
235
+ : normalizeCodexMetadata(metadata)
236
+ };
237
+ const snapshot = {
238
+ schemaVersion: SNAPSHOT_VERSION,
239
+ kind: SNAPSHOT_KIND,
240
+ createdAt: new Date().toISOString(),
241
+ runtime,
242
+ policyDigest: sha256Json(projectPolicy(policy, runtime)),
243
+ posture
244
+ };
245
+ return { ...snapshot, digest: snapshotDigest(snapshot) };
246
+ }
247
+
248
+ function validateSnapshot(snapshot) {
249
+ requireObject(snapshot, 'Invalid permission posture snapshot.');
250
+ if (snapshot.schemaVersion !== SNAPSHOT_VERSION || snapshot.kind !== SNAPSHOT_KIND) {
251
+ throw new Error('Unsupported permission posture snapshot schema.');
252
+ }
253
+ if (!['claude', 'codex'].includes(snapshot.runtime)) throw new Error('Invalid permission posture runtime.');
254
+ if (typeof snapshot.policyDigest !== 'string' || !/^[a-f0-9]{64}$/.test(snapshot.policyDigest)) {
255
+ throw new Error('Invalid permission posture policy digest.');
256
+ }
257
+ requireObject(snapshot.posture, 'Permission posture snapshot is missing posture data.');
258
+ if (!Object.hasOwn(ACTION_RANK, snapshot.posture.action)) throw new Error('Invalid permission posture action.');
259
+ if (!Array.isArray(snapshot.posture.signals) ||
260
+ snapshot.posture.signals.some((item) => typeof item !== 'string')) {
261
+ throw new Error('Invalid permission posture signals.');
262
+ }
263
+ const normalizedSignals = [...new Set(snapshot.posture.signals)].sort();
264
+ if (sha256Json(normalizedSignals) !== sha256Json(snapshot.posture.signals)) {
265
+ throw new Error('Non-canonical permission posture signals.');
266
+ }
267
+ const metadata = requireObject(snapshot.posture.metadata, 'Permission posture snapshot is missing metadata.');
268
+ if (snapshot.runtime === 'claude') {
269
+ const normalized = normalizeClaudeSnapshotMetadata(metadata);
270
+ if (sha256Json(normalized) !== sha256Json(metadata)) throw new Error('Non-canonical Claude posture metadata.');
271
+ } else {
272
+ const normalized = normalizeCodexSnapshotMetadata(metadata);
273
+ if (sha256Json(normalized) !== sha256Json(metadata)) throw new Error('Non-canonical Codex posture metadata.');
274
+ }
275
+ if (snapshot.digest !== snapshotDigest(snapshot)) throw new Error('Permission posture snapshot digest mismatch.');
276
+ return snapshot;
277
+ }
278
+
279
+ function normalizeClaudeSnapshotMetadata(metadata) {
280
+ const expected = new Set([
281
+ 'defaultMode', 'allowRuleCount', 'askRuleCount', 'denyRuleCount', 'allowCategories',
282
+ 'additionalDirectoryCount', 'bypassDisabled', 'autoModeDisabled', 'outsideReadsBlocked',
283
+ 'uninspectedRootKeyCount'
284
+ ]);
285
+ assertMetadataKeys(metadata, expected);
286
+ return {
287
+ defaultMode: requireEnum(metadata.defaultMode, CLAUDE_MODES, 'defaultMode'),
288
+ allowRuleCount: requireCount(metadata.allowRuleCount, 'allowRuleCount'),
289
+ askRuleCount: requireCount(metadata.askRuleCount, 'askRuleCount'),
290
+ denyRuleCount: requireCount(metadata.denyRuleCount, 'denyRuleCount'),
291
+ allowCategories: requireStringArray(metadata.allowCategories, CLAUDE_CATEGORIES, 'allowCategories'),
292
+ additionalDirectoryCount: requireCount(metadata.additionalDirectoryCount, 'additionalDirectoryCount'),
293
+ bypassDisabled: requireBoolean(metadata.bypassDisabled, 'bypassDisabled'),
294
+ autoModeDisabled: requireBoolean(metadata.autoModeDisabled, 'autoModeDisabled'),
295
+ outsideReadsBlocked: requireBoolean(metadata.outsideReadsBlocked, 'outsideReadsBlocked'),
296
+ uninspectedRootKeyCount: requireCount(metadata.uninspectedRootKeyCount, 'uninspectedRootKeyCount')
297
+ };
298
+ }
299
+
300
+ function normalizeCodexSnapshotMetadata(metadata) {
301
+ const expected = new Set([
302
+ 'approvalPolicy', 'granularPromptCategories', 'approvalsReviewer', 'sandboxMode',
303
+ 'defaultPermissionsKind', 'webSearch', 'networkAccess', 'additionalWritableRootCount',
304
+ 'tempRootsExcluded', 'subagentsEnabled', 'uninspectedAgentKeyCount',
305
+ 'uninspectedRootKeyCount', 'uninspectedSectionKindCount'
306
+ ]);
307
+ assertMetadataKeys(metadata, expected);
308
+ return {
309
+ approvalPolicy: requireEnum(metadata.approvalPolicy, CODEX_APPROVALS, 'approvalPolicy'),
310
+ granularPromptCategories: requireStringArray(
311
+ metadata.granularPromptCategories,
312
+ CODEX_PROMPTS,
313
+ 'granularPromptCategories'
314
+ ),
315
+ approvalsReviewer: requireEnum(metadata.approvalsReviewer, CODEX_REVIEWERS, 'approvalsReviewer'),
316
+ sandboxMode: requireEnum(metadata.sandboxMode, CODEX_SANDBOXES, 'sandboxMode'),
317
+ defaultPermissionsKind: requireEnum(
318
+ metadata.defaultPermissionsKind,
319
+ CODEX_PERMISSIONS,
320
+ 'defaultPermissionsKind'
321
+ ),
322
+ webSearch: requireEnum(metadata.webSearch, CODEX_SEARCH, 'webSearch'),
323
+ networkAccess: requireBoolean(metadata.networkAccess, 'networkAccess'),
324
+ additionalWritableRootCount: requireCount(
325
+ metadata.additionalWritableRootCount,
326
+ 'additionalWritableRootCount'
327
+ ),
328
+ tempRootsExcluded: requireBoolean(metadata.tempRootsExcluded, 'tempRootsExcluded'),
329
+ subagentsEnabled: requireBoolean(metadata.subagentsEnabled, 'subagentsEnabled'),
330
+ uninspectedAgentKeyCount: requireCount(metadata.uninspectedAgentKeyCount, 'uninspectedAgentKeyCount'),
331
+ uninspectedRootKeyCount: requireCount(metadata.uninspectedRootKeyCount, 'uninspectedRootKeyCount'),
332
+ uninspectedSectionKindCount: requireCount(
333
+ metadata.uninspectedSectionKindCount,
334
+ 'uninspectedSectionKindCount'
335
+ )
336
+ };
337
+ }
338
+
339
+ function addChange(changes, impact, type, field, before, after) {
340
+ changes.push({ impact, type, field, before, after });
341
+ }
342
+
343
+ function compareCount(changes, field, before, after, riskIncreasing = true) {
344
+ if (before === after) return;
345
+ const increased = after > before;
346
+ const impact = increased === riskIncreasing ? 'expansion' : 'restriction';
347
+ addChange(changes, impact, 'count-changed', field, before, after);
348
+ }
349
+
350
+ function compareBoolean(changes, field, before, after, trueIsRisk) {
351
+ if (before === after) return;
352
+ const impact = after === trueIsRisk ? 'expansion' : 'restriction';
353
+ addChange(changes, impact, 'boolean-changed', field, before, after);
354
+ }
355
+
356
+ function compareArray(changes, field, before, after, additionsAreRisk = true) {
357
+ const added = after.filter((item) => !before.includes(item));
358
+ const removed = before.filter((item) => !after.includes(item));
359
+ if (added.length) {
360
+ changes.push({
361
+ impact: additionsAreRisk ? 'expansion' : 'restriction',
362
+ type: 'items-added',
363
+ field,
364
+ items: added
365
+ });
366
+ }
367
+ if (removed.length) {
368
+ changes.push({
369
+ impact: additionsAreRisk ? 'restriction' : 'expansion',
370
+ type: 'items-removed',
371
+ field,
372
+ items: removed
373
+ });
374
+ }
375
+ }
376
+
377
+ function compareRanked(changes, field, before, after, ranks) {
378
+ if (before === after) return;
379
+ if (!Object.hasOwn(ranks, before) || !Object.hasOwn(ranks, after) || ranks[before] === ranks[after]) {
380
+ addChange(changes, 'review', 'value-changed', field, before, after);
381
+ return;
382
+ }
383
+ addChange(
384
+ changes,
385
+ ranks[after] > ranks[before] ? 'expansion' : 'restriction',
386
+ 'value-changed',
387
+ field,
388
+ before,
389
+ after
390
+ );
391
+ }
392
+
393
+ function compareClaudeMetadata(changes, before, after) {
394
+ compareRanked(changes, 'defaultMode', before.defaultMode, after.defaultMode, {
395
+ plan: 0,
396
+ manual: 0,
397
+ default: 1,
398
+ acceptEdits: 2,
399
+ dontAsk: 3,
400
+ auto: 4,
401
+ bypassPermissions: 5
402
+ });
403
+ compareCount(changes, 'allowRuleCount', before.allowRuleCount, after.allowRuleCount, true);
404
+ compareCount(changes, 'askRuleCount', before.askRuleCount, after.askRuleCount, false);
405
+ compareCount(changes, 'denyRuleCount', before.denyRuleCount, after.denyRuleCount, false);
406
+ compareArray(changes, 'allowCategories', before.allowCategories, after.allowCategories, true);
407
+ compareCount(
408
+ changes,
409
+ 'additionalDirectoryCount',
410
+ before.additionalDirectoryCount,
411
+ after.additionalDirectoryCount,
412
+ true
413
+ );
414
+ compareBoolean(changes, 'bypassDisabled', before.bypassDisabled, after.bypassDisabled, false);
415
+ compareBoolean(changes, 'autoModeDisabled', before.autoModeDisabled, after.autoModeDisabled, false);
416
+ compareBoolean(changes, 'outsideReadsBlocked', before.outsideReadsBlocked, after.outsideReadsBlocked, false);
417
+ compareCount(
418
+ changes,
419
+ 'uninspectedRootKeyCount',
420
+ before.uninspectedRootKeyCount,
421
+ after.uninspectedRootKeyCount,
422
+ true
423
+ );
424
+ }
425
+
426
+ function compareCodexMetadata(changes, before, after) {
427
+ compareRanked(changes, 'approvalPolicy', before.approvalPolicy, after.approvalPolicy, {
428
+ 'on-request': 0,
429
+ granular: 0,
430
+ never: 2
431
+ });
432
+ compareArray(
433
+ changes,
434
+ 'granularPromptCategories',
435
+ before.granularPromptCategories,
436
+ after.granularPromptCategories,
437
+ false
438
+ );
439
+ compareRanked(changes, 'approvalsReviewer', before.approvalsReviewer, after.approvalsReviewer, {
440
+ user: 0,
441
+ auto_review: 1
442
+ });
443
+ compareRanked(changes, 'sandboxMode', before.sandboxMode, after.sandboxMode, {
444
+ 'read-only': 0,
445
+ 'workspace-write': 1,
446
+ 'danger-full-access': 2
447
+ });
448
+ compareRanked(changes, 'defaultPermissionsKind', before.defaultPermissionsKind, after.defaultPermissionsKind, {
449
+ ':read-only': 0,
450
+ ':workspace': 1,
451
+ custom: 2,
452
+ ':danger-full-access': 3
453
+ });
454
+ compareRanked(changes, 'webSearch', before.webSearch, after.webSearch, {
455
+ disabled: 0,
456
+ cached: 1,
457
+ indexed: 1,
458
+ live: 2
459
+ });
460
+ compareBoolean(changes, 'networkAccess', before.networkAccess, after.networkAccess, true);
461
+ compareCount(
462
+ changes,
463
+ 'additionalWritableRootCount',
464
+ before.additionalWritableRootCount,
465
+ after.additionalWritableRootCount,
466
+ true
467
+ );
468
+ compareBoolean(changes, 'tempRootsExcluded', before.tempRootsExcluded, after.tempRootsExcluded, false);
469
+ compareBoolean(changes, 'subagentsEnabled', before.subagentsEnabled, after.subagentsEnabled, true);
470
+ compareCount(
471
+ changes,
472
+ 'uninspectedAgentKeyCount',
473
+ before.uninspectedAgentKeyCount,
474
+ after.uninspectedAgentKeyCount,
475
+ true
476
+ );
477
+ compareCount(
478
+ changes,
479
+ 'uninspectedRootKeyCount',
480
+ before.uninspectedRootKeyCount,
481
+ after.uninspectedRootKeyCount,
482
+ true
483
+ );
484
+ compareCount(
485
+ changes,
486
+ 'uninspectedSectionKindCount',
487
+ before.uninspectedSectionKindCount,
488
+ after.uninspectedSectionKindCount,
489
+ true
490
+ );
491
+ }
492
+
493
+ export function diffPermissionPostureSnapshots(beforeInput, afterInput) {
494
+ const before = validateSnapshot(beforeInput);
495
+ const after = validateSnapshot(afterInput);
496
+ if (before.runtime !== after.runtime) throw new Error('Cannot compare different permission posture runtimes.');
497
+
498
+ const changes = [];
499
+ if (before.policyDigest !== after.policyDigest) {
500
+ changes.push({ impact: 'review', type: 'policy-changed', field: 'policyDigest' });
501
+ }
502
+
503
+ compareArray(changes, 'signals', before.posture.signals, after.posture.signals, true);
504
+ if (before.posture.action !== after.posture.action) {
505
+ addChange(
506
+ changes,
507
+ ACTION_RANK[after.posture.action] > ACTION_RANK[before.posture.action]
508
+ ? 'expansion'
509
+ : 'restriction',
510
+ 'decision-changed',
511
+ 'action',
512
+ before.posture.action,
513
+ after.posture.action
514
+ );
515
+ }
516
+
517
+ if (before.runtime === 'claude') {
518
+ compareClaudeMetadata(changes, before.posture.metadata, after.posture.metadata);
519
+ } else {
520
+ compareCodexMetadata(changes, before.posture.metadata, after.posture.metadata);
521
+ }
522
+
523
+ const summary = {
524
+ expansions: changes.filter((change) => change.impact === 'expansion').length,
525
+ restrictions: changes.filter((change) => change.impact === 'restriction').length,
526
+ reviews: changes.filter((change) => change.impact === 'review').length
527
+ };
528
+ const status = summary.expansions || summary.reviews
529
+ ? 'needs-review'
530
+ : summary.restrictions ? 'restricted' : 'unchanged';
531
+ return {
532
+ schemaVersion: SNAPSHOT_VERSION,
533
+ runtime: before.runtime,
534
+ status,
535
+ baselineDigest: before.digest,
536
+ currentDigest: after.digest,
537
+ summary,
538
+ changes
539
+ };
540
+ }