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