trustdex 0.8.0 → 0.10.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 (37) hide show
  1. package/README.ko.md +65 -5
  2. package/README.md +67 -5
  3. package/bin/trustdex.mjs +182 -3
  4. package/docs/ARCHITECTURE.ko.md +17 -1
  5. package/docs/ARCHITECTURE.md +17 -1
  6. package/docs/CLAUDE.ko.md +33 -1
  7. package/docs/CLAUDE.md +32 -1
  8. package/docs/CODEX.ko.md +34 -4
  9. package/docs/CODEX.md +35 -4
  10. package/docs/DEFENSIVE_USE_CASE.ko.md +12 -0
  11. package/docs/DEFENSIVE_USE_CASE.md +13 -0
  12. package/docs/EVALUATION.ko.md +35 -0
  13. package/docs/EVALUATION.md +35 -0
  14. package/docs/ROADMAP.ko.md +9 -5
  15. package/docs/ROADMAP.md +9 -5
  16. package/docs/THREAT_MODEL.ko.md +8 -0
  17. package/docs/THREAT_MODEL.md +8 -0
  18. package/examples/adversarial/README.ko.md +2 -0
  19. package/examples/adversarial/README.md +2 -0
  20. package/examples/adversarial/bindings/README.ko.md +32 -0
  21. package/examples/adversarial/bindings/README.md +33 -0
  22. package/examples/adversarial/bindings/claude.mcp.json +12 -0
  23. package/examples/adversarial/bindings/claude.settings.json +9 -0
  24. package/examples/adversarial/bindings/codex.toml +9 -0
  25. package/examples/adversarial/bindings/expected.json +27 -0
  26. package/examples/adversarial/bindings/runtime-after.json +21 -0
  27. package/examples/adversarial/bindings/runtime-before.json +13 -0
  28. package/examples/adversarial/bindings/trust-store.json +15 -0
  29. package/package.json +3 -3
  30. package/schemas/effective-runtime-tools-diff-v1.schema.json +50 -0
  31. package/schemas/effective-runtime-tools-v1.schema.json +68 -0
  32. package/schemas/runtime-tool-inventory-v1.schema.json +27 -0
  33. package/schemas/tool-permission-bindings-diff-v1.schema.json +70 -0
  34. package/schemas/tool-permission-bindings-v1.schema.json +94 -0
  35. package/src/bindings.mjs +358 -0
  36. package/src/claude-settings.mjs +49 -0
  37. package/src/runtime-evidence.mjs +318 -0
@@ -252,3 +252,52 @@ export function inspectClaudeSettingsStack(layers) {
252
252
  }
253
253
  };
254
254
  }
255
+
256
+ export function inspectClaudeMcpPermissionBindings(layers, serverNames) {
257
+ const normalized = normalizeClaudeLayers(layers);
258
+ if (!Array.isArray(serverNames) ||
259
+ serverNames.some((name) => typeof name !== 'string' || !name.trim()) ||
260
+ new Set(serverNames).size !== serverNames.length) {
261
+ throw new Error('Claude MCP permission bindings require unique non-empty server names.');
262
+ }
263
+
264
+ const merged = mergeClaudeSettings(normalized);
265
+ const { rules } = validatePermissions(merged.permissions);
266
+ const grants = new Map(serverNames.map((name) => [name, {
267
+ name,
268
+ automaticGrant: 'none',
269
+ grantRuleCount: 0,
270
+ toolScopeWildcard: false
271
+ }]));
272
+ let unboundAllowRuleCount = 0;
273
+ let ambiguousAllowRuleCount = 0;
274
+
275
+ for (const rule of rules.allow.filter((entry) => entry.tool.startsWith('mcp__'))) {
276
+ const matches = serverNames.filter((name) =>
277
+ rule.tool === `mcp__${name}` || rule.tool.startsWith(`mcp__${name}__`)
278
+ );
279
+ if (matches.length !== 1) {
280
+ if (matches.length > 1) ambiguousAllowRuleCount += 1;
281
+ else unboundAllowRuleCount += 1;
282
+ continue;
283
+ }
284
+
285
+ const name = matches[0];
286
+ const grant = grants.get(name);
287
+ const prefix = `mcp__${name}`;
288
+ const tool = rule.tool === prefix ? '*' : rule.tool.slice(`${prefix}__`.length);
289
+ grant.grantRuleCount += 1;
290
+ if (tool === '*' || !tool) {
291
+ grant.automaticGrant = 'all';
292
+ grant.toolScopeWildcard = true;
293
+ } else if (grant.automaticGrant === 'none') {
294
+ grant.automaticGrant = 'scoped';
295
+ }
296
+ }
297
+
298
+ return {
299
+ grants: [...grants.values()].sort((left, right) => left.name.localeCompare(right.name)),
300
+ unboundAllowRuleCount,
301
+ ambiguousAllowRuleCount
302
+ };
303
+ }
@@ -0,0 +1,318 @@
1
+ import { sha256Json } from './canonical.mjs';
2
+ import { validateToolPermissionBindingSnapshot } from './bindings.mjs';
3
+
4
+ const RUNTIMES = new Set(['claude', 'codex']);
5
+ const CAPTURE_METHODS = new Set(['runtime-export', 'runtime-adapter']);
6
+ const EVIDENCE_STATUSES = new Set(['matched', 'restricted', 'needs-review', 'block']);
7
+ const SERVER_STATUSES = new Set(['matched', 'not-observed', 'needs-review', 'blocked', 'unexpected']);
8
+ const KIND = 'agent-effective-tool-boundary';
9
+ const INVENTORY_KIND = 'agent-runtime-tool-inventory';
10
+
11
+ function requireObject(value, message) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(message);
13
+ return value;
14
+ }
15
+
16
+ function requireExactKeys(value, expected, message) {
17
+ const keys = Object.keys(requireObject(value, message));
18
+ if (keys.length !== expected.size || keys.some((key) => !expected.has(key))) throw new Error(message);
19
+ return value;
20
+ }
21
+
22
+ function requireCount(value, field) {
23
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid runtime evidence count: ${field}`);
24
+ return value;
25
+ }
26
+
27
+ function requireDigest(value, field) {
28
+ if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
29
+ throw new Error(`Invalid runtime evidence digest: ${field}`);
30
+ }
31
+ return value;
32
+ }
33
+
34
+ function requireTimestamp(value, field) {
35
+ if (typeof value !== 'string' || Number.isNaN(Date.parse(value)) ||
36
+ new Date(value).toISOString() !== value) {
37
+ throw new Error(`Invalid runtime evidence timestamp: ${field}`);
38
+ }
39
+ return value;
40
+ }
41
+
42
+ function normalizeInventory(input) {
43
+ const inventory = requireExactKeys(input, new Set([
44
+ 'schemaVersion', 'kind', 'runtime', 'capturedAt', 'captureMethod', 'servers'
45
+ ]), 'Invalid runtime tool inventory.');
46
+ if (inventory.schemaVersion !== 1 || inventory.kind !== INVENTORY_KIND) {
47
+ throw new Error('Unsupported runtime tool inventory schema.');
48
+ }
49
+ if (!RUNTIMES.has(inventory.runtime)) throw new Error('Invalid runtime tool inventory runtime.');
50
+ requireTimestamp(inventory.capturedAt, 'capturedAt');
51
+ if (!CAPTURE_METHODS.has(inventory.captureMethod)) throw new Error('Invalid runtime tool inventory capture method.');
52
+ if (!Array.isArray(inventory.servers)) throw new Error('Runtime tool inventory is missing servers.');
53
+
54
+ const servers = inventory.servers.map((server) => {
55
+ requireExactKeys(server, new Set(['name', 'toolCount']), 'Invalid runtime tool inventory server.');
56
+ if (typeof server.name !== 'string' || !server.name.trim()) {
57
+ throw new Error('Runtime tool inventory server is missing a name.');
58
+ }
59
+ return { name: server.name, toolCount: requireCount(server.toolCount, 'toolCount') };
60
+ }).sort((left, right) => left.name.localeCompare(right.name));
61
+ if (new Set(servers.map((server) => server.name)).size !== servers.length) {
62
+ throw new Error('Runtime tool inventory server names must be unique.');
63
+ }
64
+ return {
65
+ schemaVersion: 1,
66
+ kind: INVENTORY_KIND,
67
+ runtime: inventory.runtime,
68
+ capturedAt: inventory.capturedAt,
69
+ captureMethod: inventory.captureMethod,
70
+ servers
71
+ };
72
+ }
73
+
74
+ function serverStatus(binding, observed) {
75
+ if (!binding) return 'unexpected';
76
+ if (!observed) return 'not-observed';
77
+ if (binding.status === 'blocked' || binding.sourceDecision === 'block' ||
78
+ binding.provenanceStatus === 'mismatch') return 'blocked';
79
+ if (binding.status === 'needs-review' || binding.sourceDecision === 'ask') return 'needs-review';
80
+ return 'matched';
81
+ }
82
+
83
+ function createSummary(servers) {
84
+ const observedToolCount = servers.reduce((total, server) => total + (server.toolCount || 0), 0);
85
+ requireCount(observedToolCount, 'observedToolCount');
86
+ return {
87
+ configuredServerCount: servers.filter((server) => server.configured).length,
88
+ observedServerCount: servers.filter((server) => server.observed).length,
89
+ observedToolCount,
90
+ matchedCount: servers.filter((server) => server.status === 'matched').length,
91
+ reviewCount: servers.filter((server) => server.status === 'needs-review').length,
92
+ blockedCount: servers.filter((server) => server.status === 'blocked').length,
93
+ unexpectedCount: servers.filter((server) => server.status === 'unexpected').length,
94
+ notObservedCount: servers.filter((server) => server.status === 'not-observed').length
95
+ };
96
+ }
97
+
98
+ function evidenceStatus(summary) {
99
+ if (summary.blockedCount || summary.unexpectedCount) return 'block';
100
+ if (summary.reviewCount) return 'needs-review';
101
+ if (summary.notObservedCount) return 'restricted';
102
+ return 'matched';
103
+ }
104
+
105
+ function evidenceDigest(snapshot) {
106
+ return sha256Json({
107
+ schemaVersion: snapshot.schemaVersion,
108
+ kind: snapshot.kind,
109
+ runtime: snapshot.runtime,
110
+ capturedAt: snapshot.capturedAt,
111
+ captureMethod: snapshot.captureMethod,
112
+ bindingDigest: snapshot.bindingDigest,
113
+ inventoryDigest: snapshot.inventoryDigest,
114
+ status: snapshot.status,
115
+ summary: snapshot.summary,
116
+ servers: snapshot.servers
117
+ });
118
+ }
119
+
120
+ function inventoryFromEvidence(snapshot) {
121
+ return {
122
+ schemaVersion: 1,
123
+ kind: INVENTORY_KIND,
124
+ runtime: snapshot.runtime,
125
+ capturedAt: snapshot.capturedAt,
126
+ captureMethod: snapshot.captureMethod,
127
+ servers: snapshot.servers
128
+ .filter((server) => server.observed)
129
+ .map((server) => ({ name: server.name, toolCount: server.toolCount }))
130
+ };
131
+ }
132
+
133
+ export function createEffectiveRuntimeEvidence(bindingInput, inventoryInput) {
134
+ const bindings = validateToolPermissionBindingSnapshot(bindingInput);
135
+ const inventory = normalizeInventory(inventoryInput);
136
+ if (bindings.runtime !== inventory.runtime) {
137
+ throw new Error('Runtime inventory does not match the binding runtime.');
138
+ }
139
+
140
+ const configured = new Map(bindings.bindings.map((binding) => [binding.name, binding]));
141
+ const observed = new Map(inventory.servers.map((server) => [server.name, server]));
142
+ const names = [...new Set([...configured.keys(), ...observed.keys()])].sort();
143
+ const servers = names.map((name) => {
144
+ const binding = configured.get(name) || null;
145
+ const runtimeServer = observed.get(name) || null;
146
+ return {
147
+ name,
148
+ configured: binding !== null,
149
+ observed: runtimeServer !== null,
150
+ toolCount: runtimeServer ? runtimeServer.toolCount : null,
151
+ sourceDecision: binding ? binding.sourceDecision : null,
152
+ provenanceStatus: binding ? binding.provenanceStatus : null,
153
+ automaticGrant: binding ? binding.automaticGrant : null,
154
+ bindingStatus: binding ? binding.status : null,
155
+ status: serverStatus(binding, runtimeServer)
156
+ };
157
+ });
158
+ const summary = createSummary(servers);
159
+ const snapshot = {
160
+ schemaVersion: 1,
161
+ kind: KIND,
162
+ createdAt: new Date().toISOString(),
163
+ runtime: inventory.runtime,
164
+ capturedAt: inventory.capturedAt,
165
+ captureMethod: inventory.captureMethod,
166
+ bindingDigest: bindings.digest,
167
+ inventoryDigest: sha256Json(inventory),
168
+ status: evidenceStatus(summary),
169
+ summary,
170
+ servers
171
+ };
172
+ return { ...snapshot, digest: evidenceDigest(snapshot) };
173
+ }
174
+
175
+ function validateServer(server) {
176
+ requireExactKeys(server, new Set([
177
+ 'name', 'configured', 'observed', 'toolCount', 'sourceDecision', 'provenanceStatus',
178
+ 'automaticGrant', 'bindingStatus', 'status'
179
+ ]), 'Invalid effective runtime server evidence.');
180
+ if (typeof server.name !== 'string' || !server.name) throw new Error('Runtime server evidence is missing a name.');
181
+ if (typeof server.configured !== 'boolean' || typeof server.observed !== 'boolean') {
182
+ throw new Error('Invalid runtime server configured/observed state.');
183
+ }
184
+ if (server.observed) requireCount(server.toolCount, 'toolCount');
185
+ else if (server.toolCount !== null) throw new Error('Unobserved runtime server must have a null tool count.');
186
+ if (!server.configured) {
187
+ for (const field of ['sourceDecision', 'provenanceStatus', 'automaticGrant', 'bindingStatus']) {
188
+ if (server[field] !== null) throw new Error('Unexpected runtime server has binding metadata.');
189
+ }
190
+ } else {
191
+ if (!['allow', 'ask', 'block'].includes(server.sourceDecision) ||
192
+ !['unknown', 'verified', 'mismatch'].includes(server.provenanceStatus) ||
193
+ !['none', 'scoped', 'all'].includes(server.automaticGrant) ||
194
+ !['not-granted', 'bound', 'needs-review', 'blocked'].includes(server.bindingStatus)) {
195
+ throw new Error('Invalid configured runtime server binding metadata.');
196
+ }
197
+ }
198
+ if (!SERVER_STATUSES.has(server.status) || server.status !== serverStatus(
199
+ server.configured ? {
200
+ status: server.bindingStatus,
201
+ sourceDecision: server.sourceDecision,
202
+ provenanceStatus: server.provenanceStatus
203
+ } : null,
204
+ server.observed ? server : null
205
+ )) throw new Error('Invalid effective runtime server status.');
206
+ return server;
207
+ }
208
+
209
+ function validateEvidence(snapshot) {
210
+ requireExactKeys(snapshot, new Set([
211
+ 'schemaVersion', 'kind', 'createdAt', 'runtime', 'capturedAt', 'captureMethod',
212
+ 'bindingDigest', 'inventoryDigest', 'status', 'summary', 'servers', 'digest'
213
+ ]), 'Invalid effective runtime evidence snapshot.');
214
+ if (snapshot.schemaVersion !== 1 || snapshot.kind !== KIND) {
215
+ throw new Error('Unsupported effective runtime evidence schema.');
216
+ }
217
+ if (!RUNTIMES.has(snapshot.runtime)) throw new Error('Invalid effective runtime evidence runtime.');
218
+ requireTimestamp(snapshot.createdAt, 'createdAt');
219
+ requireTimestamp(snapshot.capturedAt, 'capturedAt');
220
+ if (!CAPTURE_METHODS.has(snapshot.captureMethod)) throw new Error('Invalid effective runtime capture method.');
221
+ requireDigest(snapshot.bindingDigest, 'bindingDigest');
222
+ requireDigest(snapshot.inventoryDigest, 'inventoryDigest');
223
+ requireDigest(snapshot.digest, 'digest');
224
+ if (!EVIDENCE_STATUSES.has(snapshot.status)) throw new Error('Invalid effective runtime evidence status.');
225
+ if (!Array.isArray(snapshot.servers)) throw new Error('Effective runtime evidence is missing servers.');
226
+ for (const server of snapshot.servers) validateServer(server);
227
+ const names = snapshot.servers.map((server) => server.name);
228
+ if (new Set(names).size !== names.length || [...names].sort().join('\0') !== names.join('\0')) {
229
+ throw new Error('Effective runtime server evidence is not canonical.');
230
+ }
231
+ const summaryFields = [
232
+ 'configuredServerCount', 'observedServerCount', 'observedToolCount', 'matchedCount',
233
+ 'reviewCount', 'blockedCount', 'unexpectedCount', 'notObservedCount'
234
+ ];
235
+ requireExactKeys(snapshot.summary, new Set(summaryFields), 'Invalid effective runtime evidence summary.');
236
+ for (const field of summaryFields) requireCount(snapshot.summary[field], field);
237
+ const summary = createSummary(snapshot.servers);
238
+ if (sha256Json(summary) !== sha256Json(snapshot.summary)) throw new Error('Effective runtime evidence summary mismatch.');
239
+ if (snapshot.inventoryDigest !== sha256Json(inventoryFromEvidence(snapshot))) {
240
+ throw new Error('Effective runtime inventory digest mismatch.');
241
+ }
242
+ if (snapshot.status !== evidenceStatus(summary)) throw new Error('Effective runtime evidence status mismatch.');
243
+ if (snapshot.digest !== evidenceDigest(snapshot)) throw new Error('Effective runtime evidence digest mismatch.');
244
+ return snapshot;
245
+ }
246
+
247
+ export function validateEffectiveRuntimeEvidence(snapshot) {
248
+ return validateEvidence(snapshot);
249
+ }
250
+
251
+ function addChange(changes, impact, type, name, field, before, after) {
252
+ changes.push({ impact, type, name, field, before, after });
253
+ }
254
+
255
+ export function diffEffectiveRuntimeEvidence(beforeInput, afterInput) {
256
+ const before = validateEvidence(beforeInput);
257
+ const after = validateEvidence(afterInput);
258
+ if (before.runtime !== after.runtime) throw new Error('Cannot compare different runtime evidence types.');
259
+ const changes = [];
260
+ if (before.bindingDigest !== after.bindingDigest) {
261
+ addChange(changes, 'review', 'bindings-changed', null, 'bindingDigest', null, null);
262
+ }
263
+ if (before.captureMethod !== after.captureMethod) {
264
+ addChange(changes, 'review', 'capture-method-changed', null, 'captureMethod', before.captureMethod, after.captureMethod);
265
+ }
266
+
267
+ const left = new Map(before.servers.map((server) => [server.name, server]));
268
+ const right = new Map(after.servers.map((server) => [server.name, server]));
269
+ const risk = { 'not-observed': 0, matched: 1, 'needs-review': 2, blocked: 3, unexpected: 3 };
270
+ for (const [name, current] of right) {
271
+ const previous = left.get(name);
272
+ if (!previous) {
273
+ addChange(changes, current.observed ? 'expansion' : 'review', 'server-added', name, 'server', null, current.status);
274
+ continue;
275
+ }
276
+ if (previous.observed !== current.observed) {
277
+ addChange(changes, current.observed ? 'expansion' : 'restriction', 'observation-changed', name, 'observed', previous.observed, current.observed);
278
+ }
279
+ const beforeCount = previous.toolCount || 0;
280
+ const afterCount = current.toolCount || 0;
281
+ if (beforeCount !== afterCount) {
282
+ addChange(changes, afterCount > beforeCount ? 'expansion' : 'restriction', 'tool-count-changed', name, 'toolCount', previous.toolCount, current.toolCount);
283
+ }
284
+ for (const field of ['configured', 'sourceDecision', 'provenanceStatus', 'automaticGrant', 'bindingStatus']) {
285
+ if (previous[field] !== current[field]) {
286
+ addChange(changes, 'review', 'binding-state-changed', name, field, previous[field], current[field]);
287
+ }
288
+ }
289
+ if (previous.status !== current.status) {
290
+ addChange(
291
+ changes,
292
+ risk[current.status] > risk[previous.status] ? 'expansion' :
293
+ risk[current.status] < risk[previous.status] ? 'restriction' : 'review',
294
+ 'server-status-changed', name, 'status', previous.status, current.status
295
+ );
296
+ }
297
+ }
298
+ for (const [name, previous] of left) {
299
+ if (!right.has(name)) {
300
+ addChange(changes, previous.observed ? 'restriction' : 'review', 'server-removed', name, 'server', previous.status, null);
301
+ }
302
+ }
303
+ const summary = {
304
+ expansions: changes.filter((change) => change.impact === 'expansion').length,
305
+ restrictions: changes.filter((change) => change.impact === 'restriction').length,
306
+ reviews: changes.filter((change) => change.impact === 'review').length
307
+ };
308
+ return {
309
+ schemaVersion: 1,
310
+ runtime: before.runtime,
311
+ status: summary.expansions || summary.reviews ? 'needs-review' :
312
+ summary.restrictions ? 'restricted' : 'unchanged',
313
+ baselineDigest: before.digest,
314
+ currentDigest: after.digest,
315
+ summary,
316
+ changes
317
+ };
318
+ }