trustdex 0.4.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/LICENSE +21 -0
- package/README.md +321 -0
- package/SECURITY.md +47 -0
- package/bin/trustdex.mjs +498 -0
- package/docs/ARCHITECTURE.md +43 -0
- package/docs/RELEASING.md +53 -0
- package/docs/ROADMAP.md +33 -0
- package/docs/THREAT_MODEL.md +47 -0
- package/docs/TRUST_STORE.md +89 -0
- package/package.json +47 -0
- package/src/canonical.mjs +17 -0
- package/src/codex-config.mjs +308 -0
- package/src/extensions.mjs +112 -0
- package/src/gate.mjs +39 -0
- package/src/inspect.mjs +121 -0
- package/src/policy-packs.mjs +87 -0
- package/src/policy.mjs +102 -0
- package/src/provenance-online.mjs +394 -0
- package/src/provenance.mjs +194 -0
- package/src/review.mjs +39 -0
- package/src/snapshot.mjs +76 -0
- package/src/trust-record.mjs +59 -0
package/bin/trustdex.mjs
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
|
|
6
|
+
import { inspectMcpConfig } from '../src/inspect.mjs';
|
|
7
|
+
import { parseCodexMcpToml, gateCodexToml } from '../src/codex-config.mjs';
|
|
8
|
+
import { inspectSkillText, inspectPluginManifest } from '../src/extensions.mjs';
|
|
9
|
+
import { evaluateAll } from '../src/policy.mjs';
|
|
10
|
+
import { getPolicyPack, mergePolicy } from '../src/policy-packs.mjs';
|
|
11
|
+
import { parseTrustStore, attachProvenanceAll } from '../src/provenance.mjs';
|
|
12
|
+
import {
|
|
13
|
+
inspectGitHubRepository,
|
|
14
|
+
inspectNpmPackage,
|
|
15
|
+
inspectGitHubReleaseSignature,
|
|
16
|
+
inspectMcpRegistryServer,
|
|
17
|
+
provenanceObservationToClaim,
|
|
18
|
+
upsertTrustStoreClaim,
|
|
19
|
+
verifyArtifactBytes
|
|
20
|
+
} from '../src/provenance-online.mjs';
|
|
21
|
+
import { createSnapshot, diffSnapshots } from '../src/snapshot.mjs';
|
|
22
|
+
import { gateMcpConfig } from '../src/gate.mjs';
|
|
23
|
+
import { assessReview } from '../src/review.mjs';
|
|
24
|
+
import {
|
|
25
|
+
generateTrustKeyPair,
|
|
26
|
+
createTrustRecord,
|
|
27
|
+
verifyTrustRecord
|
|
28
|
+
} from '../src/trust-record.mjs';
|
|
29
|
+
|
|
30
|
+
const VERSION = '0.4.0';
|
|
31
|
+
|
|
32
|
+
function usage() {
|
|
33
|
+
console.log(`TrustDex v${VERSION}
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
trustdex inspect <mcp.json> [--pack strict|official-first|development]
|
|
37
|
+
[--policy policy.json] [--trust-store trust-store.json] [--json]
|
|
38
|
+
|
|
39
|
+
trustdex inspect-skill <SKILL.md> [--pack ...] [--policy policy.json] [--json]
|
|
40
|
+
trustdex inspect-plugin <plugin.json> [--pack ...] [--policy policy.json] [--json]
|
|
41
|
+
trustdex inspect-codex <config.toml> [--pack ...] [--policy policy.json]
|
|
42
|
+
[--trust-store trust-store.json] [--json]
|
|
43
|
+
|
|
44
|
+
trustdex gate <mcp.json> --out gated.json [--pack ...] [--policy policy.json]
|
|
45
|
+
[--trust-store trust-store.json] [--include-ask] [--json]
|
|
46
|
+
trustdex gate-codex <config.toml> --out gated.toml [--pack ...] [--policy policy.json]
|
|
47
|
+
[--trust-store trust-store.json] [--include-ask]
|
|
48
|
+
|
|
49
|
+
trustdex provenance github <owner/repo> [--json]
|
|
50
|
+
trustdex provenance github-release <owner/repo> [--json]
|
|
51
|
+
trustdex provenance npm <package> [--json]
|
|
52
|
+
trustdex provenance mcp <namespace/server> [--json]
|
|
53
|
+
trustdex trust-source github <owner/repo> --publisher <name> --out trust-store.json
|
|
54
|
+
trustdex trust-source github-release <owner/repo> --publisher <name> --out trust-store.json
|
|
55
|
+
trustdex trust-source npm <package> --publisher <name> --out trust-store.json
|
|
56
|
+
[--artifact downloaded-package.tgz]
|
|
57
|
+
trustdex trust-source mcp <namespace/server> --publisher <name> --out trust-store.json
|
|
58
|
+
[--artifact downloaded-package]
|
|
59
|
+
|
|
60
|
+
trustdex approve <mcp.json> --private trustdex-private.pem --dir .trustdex/review
|
|
61
|
+
[--pack ...] [--policy policy.json] [--trust-store trust-store.json]
|
|
62
|
+
trustdex recheck <mcp.json> --review-dir .trustdex/review --public trustdex-public.pem [--json]
|
|
63
|
+
|
|
64
|
+
trustdex snapshot <mcp.json> --out snapshot.json [--pack ...]
|
|
65
|
+
[--policy policy.json] [--trust-store trust-store.json]
|
|
66
|
+
|
|
67
|
+
trustdex diff <before.json> <after.json> [--json]
|
|
68
|
+
trustdex policy <strict|official-first|development>
|
|
69
|
+
|
|
70
|
+
trustdex keygen --private trustdex-private.pem --public trustdex-public.pem
|
|
71
|
+
trustdex sign <snapshot.json> --private trustdex-private.pem --out trust-record.json
|
|
72
|
+
[--policy policy.json] [--source-ref <ref>]
|
|
73
|
+
trustdex verify <trust-record.json> --public trustdex-public.pem
|
|
74
|
+
[--snapshot snapshot.json] [--policy policy.json]
|
|
75
|
+
|
|
76
|
+
Most commands are local-only. The provenance and trust-source commands perform explicit
|
|
77
|
+
network lookups to GitHub or npm when you invoke them.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function readJson(file) {
|
|
81
|
+
return JSON.parse(await fs.readFile(file, 'utf8'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readJsonIfExists(file, fallback = null) {
|
|
85
|
+
try {
|
|
86
|
+
return await readJson(file);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error?.code === 'ENOENT') return fallback;
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function writeFileSafe(file, content, options = {}) {
|
|
94
|
+
const directory = path.dirname(path.resolve(file));
|
|
95
|
+
await fs.mkdir(directory, { recursive: true });
|
|
96
|
+
await fs.writeFile(file, content, options);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getFlag(args, name) {
|
|
100
|
+
const index = args.indexOf(name);
|
|
101
|
+
return index >= 0 ? args[index + 1] : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasFlag(args, name) {
|
|
105
|
+
return args.includes(name);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function label(action) {
|
|
109
|
+
if (action === 'allow') return 'ALLOW';
|
|
110
|
+
if (action === 'ask') return 'ASK';
|
|
111
|
+
return 'BLOCK';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function loadPolicy(args) {
|
|
115
|
+
const pack = getPolicyPack(getFlag(args, '--pack') || 'strict');
|
|
116
|
+
const policyPath = getFlag(args, '--policy');
|
|
117
|
+
return policyPath ? mergePolicy(pack, await readJson(policyPath)) : pack;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function loadTrustStore(args) {
|
|
121
|
+
const file = getFlag(args, '--trust-store');
|
|
122
|
+
return file ? parseTrustStore(await readJson(file)) : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function printResults(results, asJson = false) {
|
|
126
|
+
if (asJson) {
|
|
127
|
+
console.log(JSON.stringify(results, null, 2));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const result of results) {
|
|
132
|
+
console.log(`${label(result.action).padEnd(5)} ${result.name}`);
|
|
133
|
+
console.log(` source: ${JSON.stringify(result.source)}`);
|
|
134
|
+
if (result.fingerprint) console.log(` fingerprint: ${result.fingerprint}`);
|
|
135
|
+
if (result.provenance) console.log(` provenance: ${JSON.stringify(result.provenance)}`);
|
|
136
|
+
if (result.signals?.length) console.log(` signals: ${result.signals.join(', ')}`);
|
|
137
|
+
for (const reason of result.reasons || []) console.log(` - ${reason}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setInspectExitCode(results) {
|
|
142
|
+
if (results.some((result) => result.action === 'block')) process.exitCode = 2;
|
|
143
|
+
else if (results.some((result) => result.action === 'ask')) process.exitCode = 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function evaluateMcp(configPath, args) {
|
|
147
|
+
const config = await readJson(configPath);
|
|
148
|
+
const inspected = inspectMcpConfig(config);
|
|
149
|
+
const store = await loadTrustStore(args);
|
|
150
|
+
const withProvenance = store ? attachProvenanceAll(inspected, store) : inspected;
|
|
151
|
+
const policy = await loadPolicy(args);
|
|
152
|
+
return {
|
|
153
|
+
config,
|
|
154
|
+
policy,
|
|
155
|
+
store,
|
|
156
|
+
results: evaluateAll(withProvenance, policy)
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function evaluateCodex(configPath, args) {
|
|
161
|
+
const text = await fs.readFile(configPath, 'utf8');
|
|
162
|
+
const config = parseCodexMcpToml(text);
|
|
163
|
+
const inspected = inspectMcpConfig(config);
|
|
164
|
+
const store = await loadTrustStore(args);
|
|
165
|
+
const withProvenance = store ? attachProvenanceAll(inspected, store) : inspected;
|
|
166
|
+
const policy = await loadPolicy(args);
|
|
167
|
+
return {
|
|
168
|
+
text,
|
|
169
|
+
config,
|
|
170
|
+
policy,
|
|
171
|
+
store,
|
|
172
|
+
results: evaluateAll(withProvenance, policy)
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function inspectSingle(item, args) {
|
|
177
|
+
const results = evaluateAll([item], await loadPolicy(args));
|
|
178
|
+
printResults(results, hasFlag(args, '--json'));
|
|
179
|
+
setInspectExitCode(results);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function printObservation(observation, asJson) {
|
|
183
|
+
if (asJson) {
|
|
184
|
+
console.log(JSON.stringify(observation, null, 2));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
console.log(`${observation.adapter.toUpperCase()} ${observation.subject}`);
|
|
188
|
+
console.log(`Publisher hint: ${observation.publisherHint || 'none'}`);
|
|
189
|
+
console.log(`Evidence: ${observation.evidence.kind} ${observation.evidence.reference}`);
|
|
190
|
+
console.log('Observation only. Nothing is trusted until you explicitly create a trust-store claim.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function lookupProvenance(adapter, subject) {
|
|
194
|
+
if (adapter === 'github') return inspectGitHubRepository(subject);
|
|
195
|
+
if (adapter === 'github-release') return inspectGitHubReleaseSignature(subject);
|
|
196
|
+
if (adapter === 'npm') return inspectNpmPackage(subject);
|
|
197
|
+
if (adapter === 'mcp') return inspectMcpRegistryServer(subject);
|
|
198
|
+
throw new Error('provenance adapter must be "github", "github-release", "npm", or "mcp".');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function printChanges(changes) {
|
|
202
|
+
for (const change of changes) {
|
|
203
|
+
console.log(`${change.type}: ${change.name}${change.signals ? ` (${change.signals.join(', ')})` : ''}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function main() {
|
|
208
|
+
const args = process.argv.slice(2);
|
|
209
|
+
const command = args[0];
|
|
210
|
+
|
|
211
|
+
if (!command || command === '-h' || command === '--help') {
|
|
212
|
+
usage();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (command === '--version' || command === '-V') {
|
|
217
|
+
console.log(VERSION);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (command === 'policy') {
|
|
222
|
+
console.log(JSON.stringify(getPolicyPack(args[1] || 'strict'), null, 2));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (command === 'inspect') {
|
|
227
|
+
const configPath = args[1];
|
|
228
|
+
if (!configPath) throw new Error('inspect requires a path to an MCP JSON file.');
|
|
229
|
+
|
|
230
|
+
const { results } = await evaluateMcp(configPath, args);
|
|
231
|
+
printResults(results, hasFlag(args, '--json'));
|
|
232
|
+
setInspectExitCode(results);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (command === 'inspect-skill') {
|
|
237
|
+
const skillPath = args[1];
|
|
238
|
+
if (!skillPath) throw new Error('inspect-skill requires a path to SKILL.md.');
|
|
239
|
+
await inspectSingle(inspectSkillText(await fs.readFile(skillPath, 'utf8'), skillPath), args);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (command === 'inspect-plugin') {
|
|
244
|
+
const pluginPath = args[1];
|
|
245
|
+
if (!pluginPath) throw new Error('inspect-plugin requires a path to plugin.json.');
|
|
246
|
+
await inspectSingle(inspectPluginManifest(await readJson(pluginPath), pluginPath), args);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (command === 'inspect-codex') {
|
|
251
|
+
const configPath = args[1];
|
|
252
|
+
if (!configPath) throw new Error('inspect-codex requires a path to Codex config.toml.');
|
|
253
|
+
const { results } = await evaluateCodex(configPath, args);
|
|
254
|
+
printResults(results, hasFlag(args, '--json'));
|
|
255
|
+
setInspectExitCode(results);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (command === 'gate-codex') {
|
|
260
|
+
const configPath = args[1];
|
|
261
|
+
const out = getFlag(args, '--out');
|
|
262
|
+
if (!configPath || !out) throw new Error('gate-codex requires <config.toml> and --out <gated.toml>.');
|
|
263
|
+
const { text, results } = await evaluateCodex(configPath, args);
|
|
264
|
+
const gated = gateCodexToml(text, results, { includeAsk: hasFlag(args, '--include-ask') });
|
|
265
|
+
await writeFileSafe(out, gated);
|
|
266
|
+
console.log(`Wrote ${out}`);
|
|
267
|
+
const allowed = results.filter((result) => result.action === 'allow').map((result) => result.name);
|
|
268
|
+
const review = results.filter((result) => result.action === 'ask').map((result) => result.name);
|
|
269
|
+
const blocked = results.filter((result) => result.action === 'block').map((result) => result.name);
|
|
270
|
+
console.log(`Allowed: ${allowed.length ? allowed.join(', ') : 'none'}`);
|
|
271
|
+
if (review.length) console.log(`Needs review: ${review.join(', ')}`);
|
|
272
|
+
if (blocked.length) console.log(`Blocked: ${blocked.join(', ')}`);
|
|
273
|
+
if (review.length && !hasFlag(args, '--include-ask')) process.exitCode = 1;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (command === 'provenance') {
|
|
278
|
+
const adapter = args[1];
|
|
279
|
+
const subject = args[2];
|
|
280
|
+
if (!adapter || !subject) throw new Error('provenance requires <github|github-release|npm|mcp> <subject>.');
|
|
281
|
+
printObservation(await lookupProvenance(adapter, subject), hasFlag(args, '--json'));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (command === 'trust-source') {
|
|
286
|
+
const adapter = args[1];
|
|
287
|
+
const subject = args[2];
|
|
288
|
+
const publisher = getFlag(args, '--publisher');
|
|
289
|
+
const out = getFlag(args, '--out');
|
|
290
|
+
if (!adapter || !subject || !publisher || !out) {
|
|
291
|
+
throw new Error('trust-source requires <github|github-release|npm|mcp> <subject>, --publisher <name>, and --out <trust-store.json>.');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const observation = await lookupProvenance(adapter, subject);
|
|
295
|
+
const artifactPath = getFlag(args, '--artifact');
|
|
296
|
+
const artifactVerification = artifactPath
|
|
297
|
+
? verifyArtifactBytes(observation, await fs.readFile(artifactPath))
|
|
298
|
+
: null;
|
|
299
|
+
const claim = provenanceObservationToClaim(observation, publisher, { artifactVerification });
|
|
300
|
+
const existing = await readJsonIfExists(out, { version: 1, claims: [] });
|
|
301
|
+
const store = upsertTrustStoreClaim(existing, claim);
|
|
302
|
+
parseTrustStore(store);
|
|
303
|
+
await writeFileSafe(out, `${JSON.stringify(store, null, 2)}\n`);
|
|
304
|
+
console.log(`Trusted ${claim.type} ${claim.subject} as "${claim.publisher}" using ${claim.evidence.kind} evidence.`);
|
|
305
|
+
if (artifactVerification) {
|
|
306
|
+
console.log(`Verified artifact integrity: ${artifactVerification.algorithm} ${artifactVerification.digest}`);
|
|
307
|
+
}
|
|
308
|
+
console.log(`Wrote ${out}`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (command === 'gate') {
|
|
313
|
+
const configPath = args[1];
|
|
314
|
+
const out = getFlag(args, '--out');
|
|
315
|
+
if (!configPath || !out) throw new Error('gate requires <mcp.json> and --out <gated.json>.');
|
|
316
|
+
|
|
317
|
+
const { config, results } = await evaluateMcp(configPath, args);
|
|
318
|
+
const gated = gateMcpConfig(config, results, { includeAsk: hasFlag(args, '--include-ask') });
|
|
319
|
+
await writeFileSafe(out, `${JSON.stringify(gated.config, null, 2)}\n`);
|
|
320
|
+
|
|
321
|
+
if (hasFlag(args, '--json')) console.log(JSON.stringify(gated.summary, null, 2));
|
|
322
|
+
else {
|
|
323
|
+
console.log(`Wrote ${out}`);
|
|
324
|
+
console.log(`Exposed ${gated.summary.exposed}/${gated.summary.total} MCP servers.`);
|
|
325
|
+
if (gated.summary.needsReview.length) console.log(`Needs review: ${gated.summary.needsReview.join(', ')}`);
|
|
326
|
+
if (gated.summary.blocked.length) console.log(`Blocked: ${gated.summary.blocked.join(', ')}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (gated.summary.needsReview.length && !hasFlag(args, '--include-ask')) process.exitCode = 1;
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (command === 'approve') {
|
|
334
|
+
const configPath = args[1];
|
|
335
|
+
const privatePath = getFlag(args, '--private');
|
|
336
|
+
const reviewDir = getFlag(args, '--dir');
|
|
337
|
+
if (!configPath || !privatePath || !reviewDir) {
|
|
338
|
+
throw new Error('approve requires <mcp.json>, --private <key.pem>, and --dir <review-directory>.');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const { policy, store, results } = await evaluateMcp(configPath, args);
|
|
342
|
+
if (results.some((result) => result.action === 'block')) {
|
|
343
|
+
throw new Error('Refusing to approve a configuration containing BLOCK decisions. Fix the policy or source first.');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const snapshot = createSnapshot(results);
|
|
347
|
+
const record = createTrustRecord(snapshot, await fs.readFile(privatePath, 'utf8'), {
|
|
348
|
+
policy,
|
|
349
|
+
sourceRef: path.resolve(configPath)
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await writeFileSafe(path.join(reviewDir, 'snapshot.json'), `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
353
|
+
await writeFileSafe(path.join(reviewDir, 'trust-record.json'), `${JSON.stringify(record, null, 2)}\n`);
|
|
354
|
+
await writeFileSafe(path.join(reviewDir, 'effective-policy.json'), `${JSON.stringify(policy, null, 2)}\n`);
|
|
355
|
+
if (store) {
|
|
356
|
+
await writeFileSafe(path.join(reviewDir, 'trust-store.json'), `${JSON.stringify(store, null, 2)}\n`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
console.log(`APPROVED ${configPath}`);
|
|
360
|
+
console.log(`Digest: ${snapshot.digest}`);
|
|
361
|
+
console.log(`Review bundle: ${reviewDir}`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (command === 'recheck') {
|
|
366
|
+
const configPath = args[1];
|
|
367
|
+
const reviewDir = getFlag(args, '--review-dir');
|
|
368
|
+
const publicPath = getFlag(args, '--public');
|
|
369
|
+
if (!configPath || !reviewDir || !publicPath) {
|
|
370
|
+
throw new Error('recheck requires <mcp.json>, --review-dir <directory>, and --public <key.pem>.');
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const baseline = await readJson(path.join(reviewDir, 'snapshot.json'));
|
|
374
|
+
const record = await readJson(path.join(reviewDir, 'trust-record.json'));
|
|
375
|
+
const policy = await readJson(path.join(reviewDir, 'effective-policy.json'));
|
|
376
|
+
const storedTrustStore = path.join(reviewDir, 'trust-store.json');
|
|
377
|
+
const trustStoreExists = await readJsonIfExists(storedTrustStore, null);
|
|
378
|
+
|
|
379
|
+
const evalArgs = [...args, '--policy', path.join(reviewDir, 'effective-policy.json')];
|
|
380
|
+
if (trustStoreExists && !getFlag(args, '--trust-store')) {
|
|
381
|
+
evalArgs.push('--trust-store', storedTrustStore);
|
|
382
|
+
}
|
|
383
|
+
const { results } = await evaluateMcp(configPath, evalArgs);
|
|
384
|
+
const current = createSnapshot(results);
|
|
385
|
+
const verdict = assessReview({
|
|
386
|
+
record,
|
|
387
|
+
publicKey: await fs.readFile(publicPath, 'utf8'),
|
|
388
|
+
baseline,
|
|
389
|
+
current,
|
|
390
|
+
policy
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
if (hasFlag(args, '--json')) {
|
|
394
|
+
console.log(JSON.stringify({ ...verdict, currentDigest: current.digest, approvedDigest: baseline.digest }, null, 2));
|
|
395
|
+
} else if (verdict.status === 'approved') {
|
|
396
|
+
console.log('APPROVED: current state matches the signed review.');
|
|
397
|
+
console.log(`Digest: ${current.digest}`);
|
|
398
|
+
} else if (verdict.status === 'needs-review') {
|
|
399
|
+
console.log('NEEDS_REVIEW: trust-relevant state changed.');
|
|
400
|
+
printChanges(verdict.changes);
|
|
401
|
+
} else {
|
|
402
|
+
console.log(`INVALID: ${verdict.reason}`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (verdict.status === 'needs-review') process.exitCode = 1;
|
|
406
|
+
if (verdict.status === 'invalid') process.exitCode = 2;
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (command === 'snapshot') {
|
|
411
|
+
const configPath = args[1];
|
|
412
|
+
const out = getFlag(args, '--out');
|
|
413
|
+
if (!configPath || !out) throw new Error('snapshot requires <mcp.json> and --out <snapshot.json>.');
|
|
414
|
+
|
|
415
|
+
const { results } = await evaluateMcp(configPath, args);
|
|
416
|
+
const snapshot = createSnapshot(results);
|
|
417
|
+
await writeFileSafe(out, `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
418
|
+
console.log(`Wrote ${out}\nDigest: ${snapshot.digest}`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (command === 'diff') {
|
|
423
|
+
const beforePath = args[1];
|
|
424
|
+
const afterPath = args[2];
|
|
425
|
+
if (!beforePath || !afterPath) throw new Error('diff requires <before.json> <after.json>.');
|
|
426
|
+
|
|
427
|
+
const changes = diffSnapshots(await readJson(beforePath), await readJson(afterPath));
|
|
428
|
+
if (hasFlag(args, '--json')) console.log(JSON.stringify(changes, null, 2));
|
|
429
|
+
else if (!changes.length) console.log('No trust-relevant changes detected.');
|
|
430
|
+
else printChanges(changes);
|
|
431
|
+
|
|
432
|
+
if (changes.some((change) =>
|
|
433
|
+
['server-added', 'signals-added', 'decision-changed', 'source-changed', 'provenance-changed', 'fingerprint-changed'].includes(change.type)
|
|
434
|
+
)) process.exitCode = 1;
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (command === 'keygen') {
|
|
439
|
+
const privatePath = getFlag(args, '--private');
|
|
440
|
+
const publicPath = getFlag(args, '--public');
|
|
441
|
+
if (!privatePath || !publicPath) throw new Error('keygen requires --private <file> and --public <file>.');
|
|
442
|
+
|
|
443
|
+
const keys = generateTrustKeyPair();
|
|
444
|
+
await writeFileSafe(privatePath, keys.privateKey, { mode: 0o600 });
|
|
445
|
+
await writeFileSafe(publicPath, keys.publicKey);
|
|
446
|
+
console.log(`Wrote ${privatePath} and ${publicPath}`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (command === 'sign') {
|
|
451
|
+
const snapshotPath = args[1];
|
|
452
|
+
const privatePath = getFlag(args, '--private');
|
|
453
|
+
const out = getFlag(args, '--out');
|
|
454
|
+
if (!snapshotPath || !privatePath || !out) {
|
|
455
|
+
throw new Error('sign requires <snapshot.json>, --private <key.pem>, and --out <record.json>.');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const policyPath = getFlag(args, '--policy');
|
|
459
|
+
const record = createTrustRecord(
|
|
460
|
+
await readJson(snapshotPath),
|
|
461
|
+
await fs.readFile(privatePath, 'utf8'),
|
|
462
|
+
{
|
|
463
|
+
policy: policyPath ? await readJson(policyPath) : null,
|
|
464
|
+
sourceRef: getFlag(args, '--source-ref')
|
|
465
|
+
}
|
|
466
|
+
);
|
|
467
|
+
await writeFileSafe(out, `${JSON.stringify(record, null, 2)}\n`);
|
|
468
|
+
console.log(`Wrote ${out}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (command === 'verify') {
|
|
473
|
+
const recordPath = args[1];
|
|
474
|
+
const publicPath = getFlag(args, '--public');
|
|
475
|
+
if (!recordPath || !publicPath) throw new Error('verify requires <record.json> and --public <key.pem>.');
|
|
476
|
+
|
|
477
|
+
const snapshotPath = getFlag(args, '--snapshot');
|
|
478
|
+
const policyPath = getFlag(args, '--policy');
|
|
479
|
+
const verdict = verifyTrustRecord(
|
|
480
|
+
await readJson(recordPath),
|
|
481
|
+
await fs.readFile(publicPath, 'utf8'),
|
|
482
|
+
{
|
|
483
|
+
snapshot: snapshotPath ? await readJson(snapshotPath) : null,
|
|
484
|
+
policy: policyPath ? await readJson(policyPath) : null
|
|
485
|
+
}
|
|
486
|
+
);
|
|
487
|
+
console.log(verdict.valid ? 'VALID' : `INVALID: ${verdict.reason}`);
|
|
488
|
+
if (!verdict.valid) process.exitCode = 2;
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
throw new Error(`Unknown command: ${command}`);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
main().catch((error) => {
|
|
496
|
+
console.error(`trustdex: ${error.message}`);
|
|
497
|
+
process.exitCode = 2;
|
|
498
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
TrustDex is a dependency-free Node.js CLI that separates observation, policy, gating, and review evidence.
|
|
4
|
+
|
|
5
|
+
## Data flow
|
|
6
|
+
|
|
7
|
+
1. An adapter reads an MCP JSON file, a focused Codex TOML subset, a skill, or a plugin manifest.
|
|
8
|
+
2. Inspection normalizes the source and emits observable signals. Secret values are not retained in reports.
|
|
9
|
+
3. Optional trust-store claims attach explicit provenance evidence.
|
|
10
|
+
4. The policy engine produces ALLOW, ASK, or BLOCK.
|
|
11
|
+
5. A gate emits only the approved configuration.
|
|
12
|
+
6. Snapshots and signed trust records make later drift reviewable.
|
|
13
|
+
|
|
14
|
+
The gate is the enforcement boundary. Reports alone do not prevent an agent from loading an unreviewed extension.
|
|
15
|
+
|
|
16
|
+
## Modules
|
|
17
|
+
|
|
18
|
+
| Area | Responsibility |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| src/inspect.mjs | MCP source classification, redaction, fingerprinting, and signals |
|
|
21
|
+
| src/codex-config.mjs | Focused fail-closed Codex MCP parsing and section filtering |
|
|
22
|
+
| src/extensions.mjs | Skill and plugin manifest inspection |
|
|
23
|
+
| src/provenance.mjs | Explicit local trust-store claims |
|
|
24
|
+
| src/provenance-online.mjs | User-invoked GitHub, npm, and MCP Registry observations plus local artifact-digest verification |
|
|
25
|
+
| src/policy.mjs | Monotonic policy evaluation; signals may raise severity |
|
|
26
|
+
| src/gate.mjs | Output only approved MCP entries |
|
|
27
|
+
| src/snapshot.mjs | Deterministic trust-relevant state and drift |
|
|
28
|
+
| src/trust-record.mjs | Ed25519 review-record signing and verification |
|
|
29
|
+
| src/review.mjs | Signed-baseline comparison |
|
|
30
|
+
|
|
31
|
+
## Security invariants
|
|
32
|
+
|
|
33
|
+
- Inspection must not execute the inspected extension.
|
|
34
|
+
- Secret values must not be printed or stored in snapshots.
|
|
35
|
+
- Unknown evidence must not become trust.
|
|
36
|
+
- A blocking signal must not be lowered by provenance.
|
|
37
|
+
- Unsupported security-relevant syntax must fail closed.
|
|
38
|
+
- Network lookup is explicit and observation is not approval.
|
|
39
|
+
- ALLOW is a policy result, not a safety certification.
|
|
40
|
+
|
|
41
|
+
## Dependency policy
|
|
42
|
+
|
|
43
|
+
Runtime code currently uses only Node.js built-ins. A new dependency requires an issue explaining why the feature cannot reasonably be implemented with the platform, the package's maintenance and security posture, and the effect on the supply-chain boundary.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Releasing TrustDex
|
|
2
|
+
|
|
3
|
+
TrustDex uses two small GitHub Actions workflows:
|
|
4
|
+
|
|
5
|
+
- `release.yml` creates a GitHub tag and release after tests pass.
|
|
6
|
+
- `publish.yml` publishes that exact tag to npm.
|
|
7
|
+
|
|
8
|
+
This split keeps GitHub release creation separate from registry credentials.
|
|
9
|
+
|
|
10
|
+
## First npm publication
|
|
11
|
+
|
|
12
|
+
npm trusted publishing can only be configured after a package already exists in the npm registry.
|
|
13
|
+
|
|
14
|
+
For the first publication:
|
|
15
|
+
|
|
16
|
+
1. Make sure the `trustdex` package name is still available on npm.
|
|
17
|
+
2. Create an npm granular access token that is allowed to publish the package and store it as the repository secret `NPM_TOKEN`.
|
|
18
|
+
3. Run **Create GitHub Release** for the current package version, such as `v0.4.0`.
|
|
19
|
+
4. Run **Publish Package to npmjs** for the same tag only after the GitHub release succeeds.
|
|
20
|
+
5. Delete the publishing token after the first successful publication.
|
|
21
|
+
|
|
22
|
+
Do not paste npm credentials into issues, pull requests, source files, logs, or chat.
|
|
23
|
+
|
|
24
|
+
## Switch to npm trusted publishing
|
|
25
|
+
|
|
26
|
+
After the package exists, configure npm trusted publishing for:
|
|
27
|
+
|
|
28
|
+
- GitHub user/organization: `grigent`
|
|
29
|
+
- repository: `trustdex`
|
|
30
|
+
- workflow filename: `publish.yml`
|
|
31
|
+
- allowed action: direct `npm publish`
|
|
32
|
+
|
|
33
|
+
The workflow grants only `contents: read` and `id-token: write`. npm can then authenticate the GitHub-hosted job with OIDC instead of a long-lived write token.
|
|
34
|
+
|
|
35
|
+
Once trusted publishing works, remove the `NPM_TOKEN` repository secret. The workflow deliberately continues to use the standard `npm publish` command so npm can select OIDC authentication.
|
|
36
|
+
|
|
37
|
+
## Provenance
|
|
38
|
+
|
|
39
|
+
The repository is public and the package is public, so GitHub-hosted publishing can emit npm provenance. The workflow also passes `--provenance` explicitly.
|
|
40
|
+
|
|
41
|
+
Provenance links the published artifact to its source/build environment. It does not prove that the package contains no malicious behavior.
|
|
42
|
+
|
|
43
|
+
## Version checklist
|
|
44
|
+
|
|
45
|
+
Before each release:
|
|
46
|
+
|
|
47
|
+
1. update `package.json` version
|
|
48
|
+
2. move the corresponding changelog section out of `Unreleased`
|
|
49
|
+
3. make sure CI is green
|
|
50
|
+
4. run the release workflow with exactly `v<package version>`
|
|
51
|
+
5. run the publish workflow for that exact release tag
|
|
52
|
+
|
|
53
|
+
The publish workflow checks out the tag SHA rather than the latest branch head so the npm artifact cannot silently include later commits.
|
package/docs/ROADMAP.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
TrustDex is early-stage. This roadmap communicates direction and is not a release commitment.
|
|
4
|
+
|
|
5
|
+
## Now: harden the boundary
|
|
6
|
+
|
|
7
|
+
- expand adversarial fixtures for MCP JSON, Codex TOML, skills, and plugin manifests
|
|
8
|
+
- verify that all human-readable output remains free of secret values
|
|
9
|
+
- improve cross-platform source and path normalization
|
|
10
|
+
- document stable policy and snapshot schema rules
|
|
11
|
+
- keep CI, CodeQL, and release automation reproducible and reviewable
|
|
12
|
+
|
|
13
|
+
## Next: prove integrity
|
|
14
|
+
|
|
15
|
+
- verify immutable package artifacts when registries expose cryptographic hashes
|
|
16
|
+
- connect registry observations to exact package versions and repository identities
|
|
17
|
+
- publish checksums or stronger provenance for TrustDex releases
|
|
18
|
+
- add fixtures for ownership transfer, deleted releases, yanked packages, and registry drift
|
|
19
|
+
|
|
20
|
+
## Later: expand interoperability
|
|
21
|
+
|
|
22
|
+
- add adapters for additional agent runtimes and registries
|
|
23
|
+
- publish a compatibility matrix backed by tests
|
|
24
|
+
- define machine-readable decision output for CI and policy tooling
|
|
25
|
+
- explore organization-managed trust stores and review-key rotation
|
|
26
|
+
|
|
27
|
+
## Adoption goals
|
|
28
|
+
|
|
29
|
+
The project is seeking pilot users willing to share non-sensitive feedback about false positives, false negatives, configuration formats, and maintainer workflow. Usage claims will be documented only when they can be verified; stars, branding, and package names are never treated as security evidence.
|
|
30
|
+
|
|
31
|
+
## How to help
|
|
32
|
+
|
|
33
|
+
Open a focused issue describing the use case and redacted configuration shape. For security-sensitive proposals, include the expected trust boundary and bypass cases.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Threat model
|
|
2
|
+
|
|
3
|
+
TrustDex addresses one narrow question:
|
|
4
|
+
|
|
5
|
+
> **Should an agent be allowed to see and invoke this extension under the user's policy?**
|
|
6
|
+
|
|
7
|
+
## In scope
|
|
8
|
+
|
|
9
|
+
- unknown or unapproved MCP sources
|
|
10
|
+
- floating package versions that can change without review
|
|
11
|
+
- source changes between reviewed snapshots
|
|
12
|
+
- provenance assertions changing or disappearing
|
|
13
|
+
- newly observed shell, filesystem, network, install, wildcard-tool, or secret-environment signals
|
|
14
|
+
- unsafe tool exposure caused by policy regressions
|
|
15
|
+
- substitution of package bytes that do not match reviewed registry integrity metadata
|
|
16
|
+
- configured package versions drifting from an integrity-verified artifact version
|
|
17
|
+
- tampering with a reviewed snapshot when a signed trust record is available
|
|
18
|
+
|
|
19
|
+
## Trust boundaries
|
|
20
|
+
|
|
21
|
+
TrustDex trusts:
|
|
22
|
+
|
|
23
|
+
- the local host enough to read its own policy/configuration
|
|
24
|
+
- the user's explicitly configured policy
|
|
25
|
+
- provenance evidence only to the extent that the trust-store maintainer reviewed it
|
|
26
|
+
- a public key only when the user obtained that key through a trusted channel
|
|
27
|
+
|
|
28
|
+
TrustDex does **not** treat a familiar project name, star count, package name, or logo as provenance.
|
|
29
|
+
|
|
30
|
+
## Out of scope
|
|
31
|
+
|
|
32
|
+
- proving arbitrary third-party code is non-malicious
|
|
33
|
+
- sandboxing tool execution
|
|
34
|
+
- detecting every prompt-injection technique
|
|
35
|
+
- vulnerability scanning of all transitive dependencies
|
|
36
|
+
- proving that integrity-matching package bytes are non-malicious
|
|
37
|
+
- independently proving publisher ownership from a local trust-store claim
|
|
38
|
+
- protecting a fully compromised host
|
|
39
|
+
- protecting a stolen signing private key
|
|
40
|
+
|
|
41
|
+
## Important failure mode
|
|
42
|
+
|
|
43
|
+
A valid signature proves that a trust record was signed by the corresponding private key and that its bound snapshot digest has not changed. It does not prove that the original review was correct.
|
|
44
|
+
|
|
45
|
+
## Design principle
|
|
46
|
+
|
|
47
|
+
TrustDex should never convert missing evidence into trust. Unknown provenance is a policy input, not a reason to guess.
|