trustdex 0.7.0 → 0.9.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.
- package/README.ko.md +71 -8
- package/README.md +75 -8
- package/bin/trustdex.mjs +204 -11
- package/docs/ARCHITECTURE.ko.md +10 -2
- package/docs/ARCHITECTURE.md +15 -4
- package/docs/CLAUDE.ko.md +47 -2
- package/docs/CLAUDE.md +54 -5
- package/docs/CODEX.ko.md +49 -5
- package/docs/CODEX.md +59 -11
- package/docs/DEFENSIVE_USE_CASE.ko.md +8 -2
- package/docs/DEFENSIVE_USE_CASE.md +11 -3
- package/docs/EVALUATION.ko.md +29 -1
- package/docs/EVALUATION.md +29 -1
- package/docs/ROADMAP.ko.md +7 -4
- package/docs/ROADMAP.md +7 -4
- package/docs/THREAT_MODEL.ko.md +7 -1
- package/docs/THREAT_MODEL.md +7 -1
- package/examples/adversarial/README.ko.md +2 -0
- package/examples/adversarial/README.md +2 -0
- package/examples/adversarial/bindings/README.ko.md +24 -0
- package/examples/adversarial/bindings/README.md +25 -0
- package/examples/adversarial/bindings/claude.mcp.json +12 -0
- package/examples/adversarial/bindings/claude.settings.json +9 -0
- package/examples/adversarial/bindings/codex.toml +9 -0
- package/examples/adversarial/bindings/expected.json +15 -0
- package/examples/adversarial/bindings/trust-store.json +15 -0
- package/package.json +3 -3
- package/schemas/permission-posture-diff-v2.schema.json +37 -0
- package/schemas/permission-posture-v2.schema.json +117 -0
- package/schemas/tool-permission-bindings-diff-v1.schema.json +70 -0
- package/schemas/tool-permission-bindings-v1.schema.json +94 -0
- package/src/bindings.mjs +354 -0
- package/src/claude-settings.mjs +130 -0
- package/src/codex-settings.mjs +81 -3
- package/src/posture.mjs +69 -8
package/src/bindings.mjs
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { sha256Json } from './canonical.mjs';
|
|
2
|
+
|
|
3
|
+
const ACTIONS = new Set(['allow', 'ask', 'block']);
|
|
4
|
+
const GRANTS = new Set(['none', 'scoped', 'all']);
|
|
5
|
+
const PROVENANCE = new Set(['unknown', 'verified', 'mismatch']);
|
|
6
|
+
const BINDING_STATUSES = new Set(['not-granted', 'bound', 'needs-review', 'blocked']);
|
|
7
|
+
const RUNTIMES = new Set(['claude', 'codex']);
|
|
8
|
+
const SNAPSHOT_KIND = 'agent-tool-permission-bindings';
|
|
9
|
+
const SNAPSHOT_VERSION = 1;
|
|
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))) {
|
|
19
|
+
throw new Error(message);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function requireCount(value, field) {
|
|
25
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid binding count: ${field}`);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function requireDigest(value, field, nullable = false) {
|
|
30
|
+
if (nullable && value === null) return null;
|
|
31
|
+
if (typeof value !== 'string' || !/^[a-f0-9]{64}$/.test(value)) {
|
|
32
|
+
throw new Error(`Invalid binding digest: ${field}`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeGrant(grant) {
|
|
38
|
+
requireObject(grant, 'Invalid tool permission grant.');
|
|
39
|
+
if (typeof grant.name !== 'string' || !grant.name) throw new Error('Tool permission grant is missing a name.');
|
|
40
|
+
if (!GRANTS.has(grant.automaticGrant)) throw new Error('Unsupported automatic tool grant mode.');
|
|
41
|
+
if (typeof grant.toolScopeWildcard !== 'boolean') throw new Error('Invalid tool permission wildcard state.');
|
|
42
|
+
return {
|
|
43
|
+
name: grant.name,
|
|
44
|
+
automaticGrant: grant.automaticGrant,
|
|
45
|
+
grantRuleCount: requireCount(grant.grantRuleCount, 'grantRuleCount'),
|
|
46
|
+
toolScopeWildcard: grant.toolScopeWildcard
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bindingStatus(sourceDecision, provenanceStatus, grant) {
|
|
51
|
+
if (grant.automaticGrant === 'none') return 'not-granted';
|
|
52
|
+
if (sourceDecision === 'block' || provenanceStatus === 'mismatch') return 'blocked';
|
|
53
|
+
if (sourceDecision === 'ask' || grant.automaticGrant === 'all' || grant.toolScopeWildcard) {
|
|
54
|
+
return 'needs-review';
|
|
55
|
+
}
|
|
56
|
+
return 'bound';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function snapshotDigest(snapshot) {
|
|
60
|
+
return sha256Json({
|
|
61
|
+
schemaVersion: snapshot.schemaVersion,
|
|
62
|
+
kind: snapshot.kind,
|
|
63
|
+
runtime: snapshot.runtime,
|
|
64
|
+
policyDigest: snapshot.policyDigest,
|
|
65
|
+
trustStoreDigest: snapshot.trustStoreDigest,
|
|
66
|
+
summary: snapshot.summary,
|
|
67
|
+
bindings: snapshot.bindings
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createToolPermissionBindingSnapshot({
|
|
72
|
+
runtime,
|
|
73
|
+
results,
|
|
74
|
+
grants,
|
|
75
|
+
policy,
|
|
76
|
+
trustStore = null,
|
|
77
|
+
unboundAutomaticGrantCount = 0,
|
|
78
|
+
ambiguousAutomaticGrantCount = 0
|
|
79
|
+
}) {
|
|
80
|
+
if (!RUNTIMES.has(runtime)) throw new Error('Unsupported tool permission binding runtime.');
|
|
81
|
+
if (!Array.isArray(results) || !Array.isArray(grants)) {
|
|
82
|
+
throw new Error('Tool permission bindings require result and grant arrays.');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const resultMap = new Map();
|
|
86
|
+
for (const result of results) {
|
|
87
|
+
if (!result || typeof result.name !== 'string' || resultMap.has(result.name)) {
|
|
88
|
+
throw new Error('Tool permission binding results require unique names.');
|
|
89
|
+
}
|
|
90
|
+
if (!ACTIONS.has(result.action)) throw new Error('Tool permission binding result has an invalid action.');
|
|
91
|
+
resultMap.set(result.name, result);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const grantMap = new Map();
|
|
95
|
+
for (const input of grants) {
|
|
96
|
+
const grant = normalizeGrant(input);
|
|
97
|
+
if (grantMap.has(grant.name)) throw new Error('Tool permission grants require unique names.');
|
|
98
|
+
grantMap.set(grant.name, grant);
|
|
99
|
+
}
|
|
100
|
+
if (resultMap.size !== grantMap.size || [...resultMap.keys()].some((name) => !grantMap.has(name))) {
|
|
101
|
+
throw new Error('Tool permission grants do not match inspected MCP servers.');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const bindings = [...resultMap.entries()].map(([name, result]) => {
|
|
105
|
+
const grant = grantMap.get(name);
|
|
106
|
+
const provenanceStatus = result.provenance?.status || 'unknown';
|
|
107
|
+
if (!PROVENANCE.has(provenanceStatus)) throw new Error('Unsupported tool provenance status.');
|
|
108
|
+
return {
|
|
109
|
+
name,
|
|
110
|
+
sourceDecision: result.action,
|
|
111
|
+
provenanceStatus,
|
|
112
|
+
integrityVerified: result.provenance?.integrityVerified === true,
|
|
113
|
+
automaticGrant: grant.automaticGrant,
|
|
114
|
+
grantRuleCount: grant.grantRuleCount,
|
|
115
|
+
toolScopeWildcard: grant.toolScopeWildcard,
|
|
116
|
+
status: bindingStatus(result.action, provenanceStatus, grant)
|
|
117
|
+
};
|
|
118
|
+
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
119
|
+
|
|
120
|
+
const unbound = requireCount(unboundAutomaticGrantCount, 'unboundAutomaticGrantCount');
|
|
121
|
+
const ambiguous = requireCount(ambiguousAutomaticGrantCount, 'ambiguousAutomaticGrantCount');
|
|
122
|
+
const summary = {
|
|
123
|
+
serverCount: bindings.length,
|
|
124
|
+
automaticGrantCount: bindings.filter((binding) => binding.automaticGrant !== 'none').length,
|
|
125
|
+
boundCount: bindings.filter((binding) => binding.status === 'bound').length,
|
|
126
|
+
reviewCount: bindings.filter((binding) => binding.status === 'needs-review').length,
|
|
127
|
+
blockedCount: bindings.filter((binding) => binding.status === 'blocked').length,
|
|
128
|
+
unboundAutomaticGrantCount: unbound,
|
|
129
|
+
ambiguousAutomaticGrantCount: ambiguous
|
|
130
|
+
};
|
|
131
|
+
const status = summary.blockedCount
|
|
132
|
+
? 'block'
|
|
133
|
+
: summary.reviewCount || unbound || ambiguous
|
|
134
|
+
? 'needs-review'
|
|
135
|
+
: summary.boundCount ? 'bound' : 'clear';
|
|
136
|
+
const snapshot = {
|
|
137
|
+
schemaVersion: SNAPSHOT_VERSION,
|
|
138
|
+
kind: SNAPSHOT_KIND,
|
|
139
|
+
createdAt: new Date().toISOString(),
|
|
140
|
+
runtime,
|
|
141
|
+
policyDigest: sha256Json(policy || {}),
|
|
142
|
+
trustStoreDigest: trustStore ? sha256Json(trustStore) : null,
|
|
143
|
+
status,
|
|
144
|
+
summary,
|
|
145
|
+
bindings
|
|
146
|
+
};
|
|
147
|
+
return { ...snapshot, digest: snapshotDigest(snapshot) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateBinding(binding) {
|
|
151
|
+
requireExactKeys(binding, new Set([
|
|
152
|
+
'name', 'sourceDecision', 'provenanceStatus', 'integrityVerified', 'automaticGrant',
|
|
153
|
+
'grantRuleCount', 'toolScopeWildcard', 'status'
|
|
154
|
+
]), 'Invalid tool permission binding.');
|
|
155
|
+
if (typeof binding.name !== 'string' || !binding.name) throw new Error('Tool permission binding is missing a name.');
|
|
156
|
+
if (!ACTIONS.has(binding.sourceDecision)) throw new Error('Invalid binding source decision.');
|
|
157
|
+
if (!PROVENANCE.has(binding.provenanceStatus)) throw new Error('Invalid binding provenance status.');
|
|
158
|
+
if (typeof binding.integrityVerified !== 'boolean') throw new Error('Invalid binding integrity state.');
|
|
159
|
+
if (!GRANTS.has(binding.automaticGrant)) throw new Error('Invalid binding grant mode.');
|
|
160
|
+
requireCount(binding.grantRuleCount, 'grantRuleCount');
|
|
161
|
+
if (typeof binding.toolScopeWildcard !== 'boolean') throw new Error('Invalid binding wildcard state.');
|
|
162
|
+
if (!BINDING_STATUSES.has(binding.status)) throw new Error('Invalid tool permission binding status.');
|
|
163
|
+
if (binding.status !== bindingStatus(binding.sourceDecision, binding.provenanceStatus, binding)) {
|
|
164
|
+
throw new Error('Tool permission binding status is inconsistent.');
|
|
165
|
+
}
|
|
166
|
+
return binding;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function validateSnapshot(snapshot) {
|
|
170
|
+
requireExactKeys(snapshot, new Set([
|
|
171
|
+
'schemaVersion', 'kind', 'createdAt', 'runtime', 'policyDigest', 'trustStoreDigest',
|
|
172
|
+
'status', 'summary', 'bindings', 'digest'
|
|
173
|
+
]), 'Invalid tool permission binding snapshot.');
|
|
174
|
+
if (snapshot.schemaVersion !== SNAPSHOT_VERSION || snapshot.kind !== SNAPSHOT_KIND) {
|
|
175
|
+
throw new Error('Unsupported tool permission binding snapshot schema.');
|
|
176
|
+
}
|
|
177
|
+
if (!RUNTIMES.has(snapshot.runtime)) throw new Error('Invalid tool permission binding runtime.');
|
|
178
|
+
if (typeof snapshot.createdAt !== 'string' ||
|
|
179
|
+
Number.isNaN(Date.parse(snapshot.createdAt)) ||
|
|
180
|
+
new Date(snapshot.createdAt).toISOString() !== snapshot.createdAt) {
|
|
181
|
+
throw new Error('Invalid tool permission binding creation time.');
|
|
182
|
+
}
|
|
183
|
+
requireDigest(snapshot.policyDigest, 'policyDigest');
|
|
184
|
+
requireDigest(snapshot.trustStoreDigest, 'trustStoreDigest', true);
|
|
185
|
+
requireDigest(snapshot.digest, 'digest');
|
|
186
|
+
if (!['clear', 'bound', 'needs-review', 'block'].includes(snapshot.status)) {
|
|
187
|
+
throw new Error('Invalid tool permission binding snapshot status.');
|
|
188
|
+
}
|
|
189
|
+
if (!Array.isArray(snapshot.bindings)) throw new Error('Tool permission binding snapshot is missing bindings.');
|
|
190
|
+
for (const binding of snapshot.bindings) validateBinding(binding);
|
|
191
|
+
const names = snapshot.bindings.map((binding) => binding.name);
|
|
192
|
+
if (new Set(names).size !== names.length || [...names].sort().join('\0') !== names.join('\0')) {
|
|
193
|
+
throw new Error('Tool permission bindings are not canonical.');
|
|
194
|
+
}
|
|
195
|
+
const summaryFields = [
|
|
196
|
+
'serverCount', 'automaticGrantCount', 'boundCount', 'reviewCount', 'blockedCount',
|
|
197
|
+
'unboundAutomaticGrantCount', 'ambiguousAutomaticGrantCount'
|
|
198
|
+
];
|
|
199
|
+
const summary = requireExactKeys(
|
|
200
|
+
snapshot.summary,
|
|
201
|
+
new Set(summaryFields),
|
|
202
|
+
'Tool permission binding snapshot has invalid summary fields.'
|
|
203
|
+
);
|
|
204
|
+
for (const field of summaryFields) requireCount(summary[field], field);
|
|
205
|
+
const expected = createSummary(snapshot.bindings, summary);
|
|
206
|
+
if (sha256Json(expected) !== sha256Json(summary)) throw new Error('Tool permission binding summary mismatch.');
|
|
207
|
+
const expectedStatus = expected.blockedCount
|
|
208
|
+
? 'block'
|
|
209
|
+
: expected.reviewCount || expected.unboundAutomaticGrantCount || expected.ambiguousAutomaticGrantCount
|
|
210
|
+
? 'needs-review'
|
|
211
|
+
: expected.boundCount ? 'bound' : 'clear';
|
|
212
|
+
if (snapshot.status !== expectedStatus) throw new Error('Tool permission binding snapshot status mismatch.');
|
|
213
|
+
if (snapshot.digest !== snapshotDigest(snapshot)) throw new Error('Tool permission binding snapshot digest mismatch.');
|
|
214
|
+
return snapshot;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function createSummary(bindings, input) {
|
|
218
|
+
return {
|
|
219
|
+
serverCount: bindings.length,
|
|
220
|
+
automaticGrantCount: bindings.filter((binding) => binding.automaticGrant !== 'none').length,
|
|
221
|
+
boundCount: bindings.filter((binding) => binding.status === 'bound').length,
|
|
222
|
+
reviewCount: bindings.filter((binding) => binding.status === 'needs-review').length,
|
|
223
|
+
blockedCount: bindings.filter((binding) => binding.status === 'blocked').length,
|
|
224
|
+
unboundAutomaticGrantCount: input.unboundAutomaticGrantCount,
|
|
225
|
+
ambiguousAutomaticGrantCount: input.ambiguousAutomaticGrantCount
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function addChange(changes, impact, type, name, field, before, after) {
|
|
230
|
+
changes.push({ impact, type, name, field, before, after });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function diffToolPermissionBindingSnapshots(beforeInput, afterInput) {
|
|
234
|
+
const before = validateSnapshot(beforeInput);
|
|
235
|
+
const after = validateSnapshot(afterInput);
|
|
236
|
+
if (before.runtime !== after.runtime) throw new Error('Cannot compare different binding runtimes.');
|
|
237
|
+
|
|
238
|
+
const changes = [];
|
|
239
|
+
if (before.policyDigest !== after.policyDigest) {
|
|
240
|
+
addChange(changes, 'review', 'policy-changed', null, 'policyDigest', null, null);
|
|
241
|
+
}
|
|
242
|
+
if (before.trustStoreDigest !== after.trustStoreDigest) {
|
|
243
|
+
addChange(changes, 'review', 'trust-store-changed', null, 'trustStoreDigest', null, null);
|
|
244
|
+
}
|
|
245
|
+
for (const field of ['unboundAutomaticGrantCount', 'ambiguousAutomaticGrantCount']) {
|
|
246
|
+
const previous = before.summary[field];
|
|
247
|
+
const current = after.summary[field];
|
|
248
|
+
if (previous !== current) {
|
|
249
|
+
addChange(changes, current > previous ? 'expansion' : 'restriction', 'count-changed', null, field, previous, current);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const left = new Map(before.bindings.map((binding) => [binding.name, binding]));
|
|
254
|
+
const right = new Map(after.bindings.map((binding) => [binding.name, binding]));
|
|
255
|
+
const grantRank = { none: 0, scoped: 1, all: 2 };
|
|
256
|
+
|
|
257
|
+
for (const [name, current] of right) {
|
|
258
|
+
const previous = left.get(name);
|
|
259
|
+
if (!previous) {
|
|
260
|
+
addChange(
|
|
261
|
+
changes,
|
|
262
|
+
current.automaticGrant === 'none' ? 'review' : 'expansion',
|
|
263
|
+
'binding-added',
|
|
264
|
+
name,
|
|
265
|
+
'binding',
|
|
266
|
+
null,
|
|
267
|
+
current.status
|
|
268
|
+
);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (previous.sourceDecision !== current.sourceDecision) {
|
|
272
|
+
addChange(changes, 'review', 'source-decision-changed', name, 'sourceDecision', previous.sourceDecision, current.sourceDecision);
|
|
273
|
+
}
|
|
274
|
+
for (const field of ['provenanceStatus', 'integrityVerified']) {
|
|
275
|
+
if (previous[field] !== current[field]) {
|
|
276
|
+
addChange(changes, 'review', 'provenance-changed', name, field, previous[field], current[field]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (previous.automaticGrant !== current.automaticGrant) {
|
|
280
|
+
addChange(
|
|
281
|
+
changes,
|
|
282
|
+
grantRank[current.automaticGrant] > grantRank[previous.automaticGrant] ? 'expansion' : 'restriction',
|
|
283
|
+
'grant-changed',
|
|
284
|
+
name,
|
|
285
|
+
'automaticGrant',
|
|
286
|
+
previous.automaticGrant,
|
|
287
|
+
current.automaticGrant
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (previous.grantRuleCount !== current.grantRuleCount) {
|
|
291
|
+
addChange(
|
|
292
|
+
changes,
|
|
293
|
+
current.grantRuleCount > previous.grantRuleCount ? 'expansion' : 'restriction',
|
|
294
|
+
'count-changed',
|
|
295
|
+
name,
|
|
296
|
+
'grantRuleCount',
|
|
297
|
+
previous.grantRuleCount,
|
|
298
|
+
current.grantRuleCount
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (previous.toolScopeWildcard !== current.toolScopeWildcard) {
|
|
302
|
+
addChange(
|
|
303
|
+
changes,
|
|
304
|
+
current.toolScopeWildcard ? 'expansion' : 'restriction',
|
|
305
|
+
'wildcard-changed',
|
|
306
|
+
name,
|
|
307
|
+
'toolScopeWildcard',
|
|
308
|
+
previous.toolScopeWildcard,
|
|
309
|
+
current.toolScopeWildcard
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const [name, previous] of left) {
|
|
314
|
+
if (!right.has(name)) {
|
|
315
|
+
addChange(
|
|
316
|
+
changes,
|
|
317
|
+
previous.automaticGrant === 'none' ? 'review' : 'restriction',
|
|
318
|
+
'binding-removed',
|
|
319
|
+
name,
|
|
320
|
+
'binding',
|
|
321
|
+
previous.status,
|
|
322
|
+
null
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const summary = {
|
|
328
|
+
expansions: changes.filter((change) => change.impact === 'expansion').length,
|
|
329
|
+
restrictions: changes.filter((change) => change.impact === 'restriction').length,
|
|
330
|
+
reviews: changes.filter((change) => change.impact === 'review').length
|
|
331
|
+
};
|
|
332
|
+
const status = summary.expansions || summary.reviews
|
|
333
|
+
? 'needs-review'
|
|
334
|
+
: summary.restrictions ? 'restricted' : 'unchanged';
|
|
335
|
+
return {
|
|
336
|
+
schemaVersion: 1,
|
|
337
|
+
runtime: before.runtime,
|
|
338
|
+
status,
|
|
339
|
+
baselineDigest: before.digest,
|
|
340
|
+
currentDigest: after.digest,
|
|
341
|
+
summary,
|
|
342
|
+
changes
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function codexAutomaticGrantProjection(results) {
|
|
347
|
+
return results.map((result) => ({
|
|
348
|
+
name: result.name,
|
|
349
|
+
automaticGrant: result.signals?.includes('auto-approve-tools') ? 'all' : 'none',
|
|
350
|
+
grantRuleCount: result.signals?.includes('auto-approve-tools') ? 1 : 0,
|
|
351
|
+
toolScopeWildcard: result.signals?.includes('wildcard-tool-scope') || false
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
|
package/src/claude-settings.mjs
CHANGED
|
@@ -19,6 +19,14 @@ const MODES = new Set([
|
|
|
19
19
|
'auto'
|
|
20
20
|
]);
|
|
21
21
|
const NON_PERMISSION_ROOT_KEYS = new Set(['$schema', 'permissions']);
|
|
22
|
+
const CLAUDE_SCOPE_RANK = Object.freeze({
|
|
23
|
+
user: 0,
|
|
24
|
+
project: 1,
|
|
25
|
+
local: 2,
|
|
26
|
+
session: 3,
|
|
27
|
+
managed: 4
|
|
28
|
+
});
|
|
29
|
+
const MERGED_PERMISSION_LISTS = new Set(['allow', 'ask', 'deny', 'additionalDirectories']);
|
|
22
30
|
|
|
23
31
|
function isObject(value) {
|
|
24
32
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -171,3 +179,125 @@ export function inspectClaudeSettings(settings, sourceName = 'settings.json') {
|
|
|
171
179
|
envKeys
|
|
172
180
|
};
|
|
173
181
|
}
|
|
182
|
+
|
|
183
|
+
function normalizeClaudeLayers(layers) {
|
|
184
|
+
if (!Array.isArray(layers) || !layers.length) {
|
|
185
|
+
throw new Error('Claude settings stack requires at least one settings source.');
|
|
186
|
+
}
|
|
187
|
+
const seen = new Set();
|
|
188
|
+
return layers.map((layer, index) => {
|
|
189
|
+
if (!isObject(layer) || !Object.hasOwn(CLAUDE_SCOPE_RANK, layer.scope)) {
|
|
190
|
+
throw new Error('Unsupported Claude settings source scope.');
|
|
191
|
+
}
|
|
192
|
+
if (seen.has(layer.scope)) {
|
|
193
|
+
throw new Error(`Claude settings stack accepts only one ${layer.scope} source.`);
|
|
194
|
+
}
|
|
195
|
+
seen.add(layer.scope);
|
|
196
|
+
if (!isObject(layer.settings)) throw new Error('Claude settings source must contain a JSON object.');
|
|
197
|
+
inspectClaudeSettings(layer.settings, 'Claude settings source');
|
|
198
|
+
return { ...layer, index };
|
|
199
|
+
}).sort((left, right) =>
|
|
200
|
+
CLAUDE_SCOPE_RANK[left.scope] - CLAUDE_SCOPE_RANK[right.scope] || left.index - right.index
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function mergeClaudeSettings(layers) {
|
|
205
|
+
const merged = {};
|
|
206
|
+
const permissions = {};
|
|
207
|
+
|
|
208
|
+
for (const { scope, settings } of layers) {
|
|
209
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
210
|
+
if (key === 'permissions') continue;
|
|
211
|
+
if (Array.isArray(value) && Array.isArray(merged[key])) merged[key] = [...merged[key], ...value];
|
|
212
|
+
else merged[key] = value;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const current = settings.permissions || {};
|
|
216
|
+
for (const [key, value] of Object.entries(current)) {
|
|
217
|
+
if (MERGED_PERMISSION_LISTS.has(key)) {
|
|
218
|
+
permissions[key] = [...(permissions[key] || []), ...value];
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (key === 'defaultMode' && ['project', 'local'].includes(scope) &&
|
|
222
|
+
['auto', 'bypassPermissions'].includes(value)) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
permissions[key] = value;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (Object.keys(permissions).length) merged.permissions = permissions;
|
|
230
|
+
return merged;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function inspectClaudeSettingsStack(layers) {
|
|
234
|
+
const normalized = normalizeClaudeLayers(layers);
|
|
235
|
+
const merged = mergeClaudeSettings(normalized);
|
|
236
|
+
const result = inspectClaudeSettings(merged, 'merged Claude settings');
|
|
237
|
+
const sourceCounts = Object.fromEntries(Object.keys(CLAUDE_SCOPE_RANK).map((scope) => [scope, 0]));
|
|
238
|
+
let activeSourceCount = 0;
|
|
239
|
+
for (const layer of normalized) {
|
|
240
|
+
sourceCounts[layer.scope] += 1;
|
|
241
|
+
if (Object.keys(layer.settings).length) activeSourceCount += 1;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
...result,
|
|
245
|
+
name: 'merged Claude settings',
|
|
246
|
+
kind: 'claude-settings-stack',
|
|
247
|
+
source: { type: 'local', format: 'claude-settings-stack', scopes: sourceCounts },
|
|
248
|
+
settingsContext: {
|
|
249
|
+
sourceCount: normalized.length,
|
|
250
|
+
activeSourceCount,
|
|
251
|
+
sourceCounts
|
|
252
|
+
}
|
|
253
|
+
};
|
|
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
|
+
}
|
package/src/codex-settings.mjs
CHANGED
|
@@ -25,6 +25,14 @@ const INSPECTED_ROOT_KEYS = new Set([
|
|
|
25
25
|
'default_permissions',
|
|
26
26
|
'web_search'
|
|
27
27
|
]);
|
|
28
|
+
const CODEX_SCOPE_RANK = Object.freeze({
|
|
29
|
+
system: 0,
|
|
30
|
+
managed: 1,
|
|
31
|
+
user: 2,
|
|
32
|
+
profile: 3,
|
|
33
|
+
project: 4,
|
|
34
|
+
cli: 5
|
|
35
|
+
});
|
|
28
36
|
|
|
29
37
|
function isObject(value) {
|
|
30
38
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -154,8 +162,7 @@ function validateSandbox(sandbox) {
|
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
|
|
157
|
-
|
|
158
|
-
const { root, sandbox, agents, sections } = parseTomlAssignments(text);
|
|
165
|
+
function inspectParsedCodexSettings({ root, sandbox, agents, sections }, sourceName, kind) {
|
|
159
166
|
validateSandbox(sandbox);
|
|
160
167
|
|
|
161
168
|
const signals = new Set();
|
|
@@ -222,7 +229,7 @@ export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
|
|
|
222
229
|
|
|
223
230
|
return {
|
|
224
231
|
name: sourceName,
|
|
225
|
-
kind
|
|
232
|
+
kind,
|
|
226
233
|
source: { type: 'local', format: 'codex-settings', path: sourceName },
|
|
227
234
|
metadata: {
|
|
228
235
|
approvalPolicy: approval.mode,
|
|
@@ -245,3 +252,74 @@ export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
|
|
|
245
252
|
envKeys: []
|
|
246
253
|
};
|
|
247
254
|
}
|
|
255
|
+
|
|
256
|
+
export function inspectCodexSettingsText(text, sourceName = 'config.toml') {
|
|
257
|
+
return inspectParsedCodexSettings(
|
|
258
|
+
parseTomlAssignments(text),
|
|
259
|
+
sourceName,
|
|
260
|
+
'codex-settings'
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function normalizeCodexLayers(layers) {
|
|
265
|
+
if (!Array.isArray(layers) || !layers.length) {
|
|
266
|
+
throw new Error('Codex settings stack requires at least one config source.');
|
|
267
|
+
}
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
return layers.map((layer, index) => {
|
|
270
|
+
if (!isObject(layer) || !Object.hasOwn(CODEX_SCOPE_RANK, layer.scope)) {
|
|
271
|
+
throw new Error('Unsupported Codex config source scope.');
|
|
272
|
+
}
|
|
273
|
+
if (layer.scope !== 'project' && seen.has(layer.scope)) {
|
|
274
|
+
throw new Error(`Codex settings stack accepts only one ${layer.scope} source.`);
|
|
275
|
+
}
|
|
276
|
+
seen.add(layer.scope);
|
|
277
|
+
if (typeof layer.text !== 'string') throw new Error('Codex config source must contain TOML text.');
|
|
278
|
+
const parsed = parseTomlAssignments(layer.text);
|
|
279
|
+
inspectParsedCodexSettings(parsed, 'Codex settings source', 'codex-settings');
|
|
280
|
+
return { ...layer, parsed, index };
|
|
281
|
+
}).sort((left, right) =>
|
|
282
|
+
CODEX_SCOPE_RANK[left.scope] - CODEX_SCOPE_RANK[right.scope] || left.index - right.index
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function inspectCodexSettingsStack(layers) {
|
|
287
|
+
const normalized = normalizeCodexLayers(layers);
|
|
288
|
+
const merged = {
|
|
289
|
+
root: Object.create(null),
|
|
290
|
+
sandbox: Object.create(null),
|
|
291
|
+
agents: Object.create(null),
|
|
292
|
+
sections: []
|
|
293
|
+
};
|
|
294
|
+
const sections = new Set();
|
|
295
|
+
const sourceCounts = Object.fromEntries(Object.keys(CODEX_SCOPE_RANK).map((scope) => [scope, 0]));
|
|
296
|
+
let activeSourceCount = 0;
|
|
297
|
+
|
|
298
|
+
for (const layer of normalized) {
|
|
299
|
+
sourceCounts[layer.scope] += 1;
|
|
300
|
+
Object.assign(merged.root, layer.parsed.root);
|
|
301
|
+
Object.assign(merged.sandbox, layer.parsed.sandbox);
|
|
302
|
+
Object.assign(merged.agents, layer.parsed.agents);
|
|
303
|
+
for (const section of layer.parsed.sections) sections.add(section);
|
|
304
|
+
if (Object.keys(layer.parsed.root).length || Object.keys(layer.parsed.sandbox).length ||
|
|
305
|
+
Object.keys(layer.parsed.agents).length || layer.parsed.sections.length) {
|
|
306
|
+
activeSourceCount += 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
merged.sections = [...sections].sort();
|
|
310
|
+
|
|
311
|
+
const result = inspectParsedCodexSettings(
|
|
312
|
+
merged,
|
|
313
|
+
'merged Codex settings',
|
|
314
|
+
'codex-settings-stack'
|
|
315
|
+
);
|
|
316
|
+
return {
|
|
317
|
+
...result,
|
|
318
|
+
source: { type: 'local', format: 'codex-settings-stack', scopes: sourceCounts },
|
|
319
|
+
settingsContext: {
|
|
320
|
+
sourceCount: normalized.length,
|
|
321
|
+
activeSourceCount,
|
|
322
|
+
sourceCounts
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
}
|