@passioncode-ai/fabric-agent-adapter 0.29.0 → 0.31.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/CHANGELOG.md +84 -0
- package/README.md +12 -0
- package/bin/fabric-agent-adapter.js +16 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +24 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +26 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +182 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs
ADDED
|
@@ -0,0 +1,590 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// fabric-package/1 (contract DEC-0050, DEC-0051, DEC-0071, DEC-0074; FAA-27a): pack an agent or a skill set as one folder
|
|
3
|
+
// with fabric.package.json, sign it, and verify it. The twin of fabric_package.py; both write the same bytes.
|
|
4
|
+
//
|
|
5
|
+
// node fabric-package.mjs pack <folder> [--apply]
|
|
6
|
+
// node fabric-package.mjs sign <folder> --key <private key file> [--kid <kid>] [--role publisher|organization] [--apply]
|
|
7
|
+
// node fabric-package.mjs verify <folder> --trust <trust.json> [--countersigned]
|
|
8
|
+
//
|
|
9
|
+
// pack reads the author's fabric.package.json, lists every other file with its SHA-256, derives agentRef, computes the
|
|
10
|
+
// digest and checks the result against the contract's schema, FAC-SEM-064…066/102 and the folder check. sign adds an
|
|
11
|
+
// Ed25519 signature (EdDSA, the one algorithm DEC-0051 admits) from a key file the caller names; it never makes or keeps
|
|
12
|
+
// a key. verify is the contract's verifyPackage (PK-1…PK-10) plus the folder check. Without --apply nothing is written.
|
|
13
|
+
// No dependencies; the answer is JSON on stdout, exit 0 when it holds, 1 when it does not, 2 on a usage error.
|
|
14
|
+
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
19
|
+
import { isSmallOrderKey, jcs } from './fabric-delegation.mjs';
|
|
20
|
+
import { secretArgument, secretValue } from './fabric-secrets.mjs';
|
|
21
|
+
|
|
22
|
+
export const PACKAGE_FILE = 'fabric.package.json';
|
|
23
|
+
export const PLUGIN_MANIFEST = '.claude-plugin/plugin.json';
|
|
24
|
+
export const SIGNATURE_STATEMENT = 'fabric-package-signature/1';
|
|
25
|
+
/** DEC-0051: ES256 joins when Fabric records its decision D2; until then a verifier refuses it as unknown-algorithm. */
|
|
26
|
+
export const PACKAGE_ALGORITHMS = Object.freeze(['EdDSA']);
|
|
27
|
+
const SCHEMA_FILE = path.join(path.dirname(fileURLToPath(import.meta.url)), 'fabric-package.schema.json');
|
|
28
|
+
// What pack refuses to list: version-control and cache litter, and files that are credentials by their name.
|
|
29
|
+
const JUNK = /^(\.git|__pycache__|\.DS_Store|.*\.pyc)$/;
|
|
30
|
+
const SECRET_FILE = /^(\.env(\..+)?|.*\.(pem|key|p12|pfx|jks|keystore)|id_(rsa|dsa|ecdsa|ed25519)|\.(npmrc|netrc|pypirc))$/;
|
|
31
|
+
const SECRET_FILE_OK = /^\.env\.(example|sample|template)$/;
|
|
32
|
+
const B64URL = /^[A-Za-z0-9_-]+$/;
|
|
33
|
+
const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
|
|
34
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
35
|
+
const isString = (v) => typeof v === 'string';
|
|
36
|
+
const objects = (v) => (Array.isArray(v) ? v.filter(isObject) : []);
|
|
37
|
+
|
|
38
|
+
/** A refusal a person can act on; the message names what to change. */
|
|
39
|
+
export class PackageError extends Error {}
|
|
40
|
+
|
|
41
|
+
// --- digest, statement, identity -----------------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** urn:fabric:agent:<publisher>:<id> — one agent across its versions, derived and never chosen (FAC-SEM-066). */
|
|
44
|
+
export const agentRefOf = (publisher, id) => `urn:fabric:agent:${publisher}:${id}`;
|
|
45
|
+
|
|
46
|
+
/** sha256: and the hex SHA-256 of some bytes (a string is taken as UTF-8). */
|
|
47
|
+
export const sha256Of = (data) => `sha256:${crypto.createHash('sha256').update(data).digest('hex')}`;
|
|
48
|
+
|
|
49
|
+
/** SHA-256 over the UTF-8 RFC 8785 form of the package without digest and signatures (DEC-0050). */
|
|
50
|
+
export function packageDigest(pkg) {
|
|
51
|
+
const { digest: _d, signatures: _s, ...covered } = pkg;
|
|
52
|
+
return sha256Of(Buffer.from(jcs(covered), 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The bytes a package signature covers (DEC-0051); the role is inside, so no signature stands for another role. */
|
|
56
|
+
export const packageSigningInput = (digest, role) => Buffer.from(jcs({ type: SIGNATURE_STATEMENT, role, digest }), 'utf8');
|
|
57
|
+
|
|
58
|
+
// --- keys ---------------------------------------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
const privateKeyOf = (seed) => crypto.createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, seed]), format: 'der', type: 'pkcs8' });
|
|
61
|
+
|
|
62
|
+
/** The 32-byte public key of a 32-byte Ed25519 seed. */
|
|
63
|
+
export function ed25519PublicKey(seed) {
|
|
64
|
+
if (seed.length !== 32) throw new PackageError('An Ed25519 private key is 32 bytes.');
|
|
65
|
+
return Buffer.from(crypto.createPublicKey(privateKeyOf(seed)).export({ format: 'jwk' }).x, 'base64url');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A deterministic Ed25519 signature; the same seed and message give the bytes the Python signer gives. */
|
|
69
|
+
export const ed25519Sign = (seed, message) => crypto.sign(null, message, privateKeyOf(seed));
|
|
70
|
+
|
|
71
|
+
/** The public JWK an installing machine pins for this key (the package names a publisher, never a key). */
|
|
72
|
+
export const publicJwk = (seed, kid) => ({ kty: 'OKP', crv: 'Ed25519', x: ed25519PublicKey(seed).toString('base64url'), kid, alg: 'EdDSA', use: 'sig' });
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* { seed, kid } from a private key file: a JWK (kty OKP, crv Ed25519, d; kid and alg EdDSA if named) or a PKCS#8 PEM
|
|
76
|
+
* (openssl genpkey -algorithm ed25519, then --kid). The file must be readable by its owner only; its bytes are never
|
|
77
|
+
* echoed.
|
|
78
|
+
*/
|
|
79
|
+
export function readSigningKey(file, kid) {
|
|
80
|
+
let stat;
|
|
81
|
+
let raw;
|
|
82
|
+
try {
|
|
83
|
+
stat = fs.statSync(file);
|
|
84
|
+
raw = fs.readFileSync(file);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw new PackageError(`Cannot read the key file ${file}: ${error.code ?? error.message}.`);
|
|
87
|
+
}
|
|
88
|
+
if (process.platform !== 'win32' && (stat.mode & 0o077)) throw new PackageError(`The key file ${file} is readable by others (mode ${(stat.mode & 0o777).toString(8)}); chmod 600 it first.`);
|
|
89
|
+
const text = raw.toString('utf8').trim();
|
|
90
|
+
let seed;
|
|
91
|
+
if (text.startsWith('{')) {
|
|
92
|
+
let jwk;
|
|
93
|
+
try { jwk = JSON.parse(text); } catch { throw new PackageError('The key file is neither a JWK nor a PEM private key.'); }
|
|
94
|
+
if (!isObject(jwk) || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !isString(jwk.d)) throw new PackageError('The key file must be an Ed25519 private JWK (kty OKP, crv Ed25519, with d).');
|
|
95
|
+
if ((jwk.alg ?? 'EdDSA') !== 'EdDSA') throw new PackageError(`The key names alg ${JSON.stringify(jwk.alg)}; a package signature is EdDSA (DEC-0051).`);
|
|
96
|
+
seed = B64URL.test(jwk.d) ? Buffer.from(jwk.d, 'base64url') : Buffer.alloc(0);
|
|
97
|
+
if (seed.length !== 32) throw new PackageError("The key's d is not a 32-byte Ed25519 private key.");
|
|
98
|
+
if (isString(jwk.x) && jwk.x !== ed25519PublicKey(seed).toString('base64url')) throw new PackageError("The key's x is not the public key of its d.");
|
|
99
|
+
if (kid !== undefined && jwk.kid !== undefined && kid !== jwk.kid) throw new PackageError(`--kid ${kid} disagrees with the key's own kid ${jwk.kid}.`);
|
|
100
|
+
kid = kid ?? jwk.kid;
|
|
101
|
+
} else if (text.includes(`-----BEGIN ${'PRIVATE KEY'}-----`)) {
|
|
102
|
+
const body = text.split(/\r?\n/).filter((line) => line && !line.startsWith('-----')).join('');
|
|
103
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(body)) throw new PackageError('The PEM key is not base64.');
|
|
104
|
+
const der = Buffer.from(body, 'base64');
|
|
105
|
+
if (der.length !== 48 || !der.subarray(0, 16).equals(PKCS8_ED25519)) throw new PackageError('The PEM key is not an Ed25519 private key (PKCS#8).');
|
|
106
|
+
seed = der.subarray(16);
|
|
107
|
+
} else {
|
|
108
|
+
throw new PackageError('The key file is neither a JWK nor a PEM private key.');
|
|
109
|
+
}
|
|
110
|
+
if (!isString(kid) || !/^[A-Za-z0-9._:-]{1,128}$/.test(kid)) throw new PackageError('Name the key with --kid (1-128 of A-Z a-z 0-9 . _ : -): the installing machine pins it by that name.');
|
|
111
|
+
return { seed, kid };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- the schema (bundled from the contract at the pin) ---------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
let schemaCache;
|
|
117
|
+
const schema = () => (schemaCache ??= JSON.parse(fs.readFileSync(SCHEMA_FILE, 'utf8')));
|
|
118
|
+
|
|
119
|
+
function jsonEqual(a, b) {
|
|
120
|
+
if (Array.isArray(a) || Array.isArray(b)) return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x, i) => jsonEqual(x, b[i]));
|
|
121
|
+
if (isObject(a) || isObject(b)) {
|
|
122
|
+
if (!isObject(a) || !isObject(b)) return false;
|
|
123
|
+
const ka = Object.keys(a);
|
|
124
|
+
return ka.length === Object.keys(b).length && ka.every((k) => Object.hasOwn(b, k) && jsonEqual(a[k], b[k]));
|
|
125
|
+
}
|
|
126
|
+
return a === b;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const typeOf = (v) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v === 'number' ? (Number.isInteger(v) ? 'integer' : 'number') : typeof v);
|
|
130
|
+
const DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(\.\d+)?([Zz]|[+-](\d{2}):(\d{2}))$/;
|
|
131
|
+
|
|
132
|
+
function formatOk(format, value) {
|
|
133
|
+
if (!isString(value)) return true;
|
|
134
|
+
if (format === 'date-time') {
|
|
135
|
+
const m = DATE_TIME.exec(value);
|
|
136
|
+
if (!m) return false;
|
|
137
|
+
const [y, mo, d, h, mi, s] = m.slice(1, 7).map(Number);
|
|
138
|
+
const days = new Date(Date.UTC(y, mo, 0)).getUTCDate();
|
|
139
|
+
if (mo < 1 || mo > 12 || d < 1 || d > days || h > 23 || mi > 59 || s > 59) return false;
|
|
140
|
+
return m[8] === 'Z' || m[8] === 'z' || (Number(m[9]) < 24 && Number(m[10]) < 60);
|
|
141
|
+
}
|
|
142
|
+
if (format === 'uri') return /^[A-Za-z][A-Za-z0-9+.-]*:\S*$/.test(value);
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const pointer = (token) => String(token).replace(/~/g, '~0').replace(/\//g, '~1');
|
|
147
|
+
const codePoints = (s) => [...s].length; // JSON Schema counts code points, not UTF-16 units
|
|
148
|
+
|
|
149
|
+
function validate(node, value, at, out) {
|
|
150
|
+
if (node === true) return;
|
|
151
|
+
if (node === false) { out.push({ instancePath: at, message: 'is not allowed here' }); return; }
|
|
152
|
+
if (node.$ref) {
|
|
153
|
+
const target = node.$ref.replace(/^#\//, '').split('/').reduce((n, t) => n[t.replace(/~1/g, '/').replace(/~0/g, '~')], schema());
|
|
154
|
+
validate(target, value, at, out);
|
|
155
|
+
}
|
|
156
|
+
const kind = typeOf(value);
|
|
157
|
+
if (node.type !== undefined) {
|
|
158
|
+
const allowed = Array.isArray(node.type) ? node.type : [node.type];
|
|
159
|
+
if (!(allowed.includes(kind) || (kind === 'integer' && allowed.includes('number')))) { out.push({ instancePath: at, message: `must be ${allowed.join(' or ')}` }); return; }
|
|
160
|
+
}
|
|
161
|
+
if ('const' in node && !jsonEqual(node.const, value)) out.push({ instancePath: at, message: `must be ${JSON.stringify(node.const)}` });
|
|
162
|
+
if (node.enum && !node.enum.some((item) => jsonEqual(item, value))) out.push({ instancePath: at, message: `must be one of ${node.enum.map((i) => JSON.stringify(i)).join(', ')}` });
|
|
163
|
+
if (kind === 'string') {
|
|
164
|
+
const length = codePoints(value);
|
|
165
|
+
if (length < (node.minLength ?? 0)) out.push({ instancePath: at, message: `is shorter than ${node.minLength}` });
|
|
166
|
+
if (node.maxLength !== undefined && length > node.maxLength) out.push({ instancePath: at, message: `is longer than ${node.maxLength}` });
|
|
167
|
+
if (node.pattern !== undefined && !new RegExp(node.pattern, 'u').test(value)) out.push({ instancePath: at, message: `does not match ${node.pattern}` });
|
|
168
|
+
if (node.format !== undefined && !formatOk(node.format, value)) out.push({ instancePath: at, message: `is not a ${node.format}` });
|
|
169
|
+
}
|
|
170
|
+
if ((kind === 'integer' || kind === 'number') && node.minimum !== undefined && value < node.minimum) out.push({ instancePath: at, message: `is below ${node.minimum}` });
|
|
171
|
+
if (kind === 'array') {
|
|
172
|
+
if (value.length < (node.minItems ?? 0)) out.push({ instancePath: at, message: `has fewer than ${node.minItems} items` });
|
|
173
|
+
if (node.maxItems !== undefined && value.length > node.maxItems) out.push({ instancePath: at, message: `has more than ${node.maxItems} items` });
|
|
174
|
+
if (node.uniqueItems && value.some((item, i) => value.slice(0, i).some((other) => jsonEqual(item, other)))) out.push({ instancePath: at, message: 'has duplicate items' });
|
|
175
|
+
if (node.items !== undefined) value.forEach((item, i) => validate(node.items, item, `${at}/${i}`, out));
|
|
176
|
+
if (node.contains !== undefined) {
|
|
177
|
+
const matches = value.filter((item) => problemsOf(node.contains, item).length === 0).length;
|
|
178
|
+
if (matches < (node.minContains ?? 1)) out.push({ instancePath: at, message: `has fewer than ${node.minContains ?? 1} matching items` });
|
|
179
|
+
if (node.maxContains !== undefined && matches > node.maxContains) out.push({ instancePath: at, message: `has more than ${node.maxContains} matching items` });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (kind === 'object') {
|
|
183
|
+
for (const name of node.required ?? []) if (!Object.hasOwn(value, name)) out.push({ instancePath: at, message: `must have ${name}` });
|
|
184
|
+
const properties = node.properties ?? {};
|
|
185
|
+
for (const [name, member] of Object.entries(value)) {
|
|
186
|
+
const where = `${at}/${pointer(name)}`;
|
|
187
|
+
if (Object.hasOwn(properties, name)) validate(properties[name], member, where, out);
|
|
188
|
+
else if (node.additionalProperties === false) out.push({ instancePath: where, message: 'is not a member of this object' });
|
|
189
|
+
else if (node.additionalProperties !== undefined) validate(node.additionalProperties, member, where, out);
|
|
190
|
+
if (node.propertyNames !== undefined) validate(node.propertyNames, name, where, out);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const sub of node.allOf ?? []) validate(sub, value, at, out);
|
|
194
|
+
if (node.not !== undefined && problemsOf(node.not, value).length === 0) out.push({ instancePath: at, message: 'must not match' });
|
|
195
|
+
if (node.if !== undefined) {
|
|
196
|
+
const branch = problemsOf(node.if, value).length === 0 ? node.then : node.else;
|
|
197
|
+
if (branch !== undefined) validate(branch, value, at, out);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function problemsOf(node, value) {
|
|
202
|
+
const out = [];
|
|
203
|
+
validate(node, value, '', out);
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Where the package breaks fabric-package.schema.json (the contract's, bundled at the pin). */
|
|
208
|
+
export const schemaProblems = (pkg) => problemsOf(schema(), pkg);
|
|
209
|
+
|
|
210
|
+
// --- the contract's rules (FAC-SEM-064…066, FAC-SEM-102), ported line for line from src/package-rules.ts --------------
|
|
211
|
+
|
|
212
|
+
const mcpRequirements = (value) => (isObject(value.requires) && Array.isArray(value.requires.mcp) ? value.requires.mcp.filter(isObject) : []);
|
|
213
|
+
|
|
214
|
+
function whole(value) {
|
|
215
|
+
const findings = [];
|
|
216
|
+
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-064', instancePath, message });
|
|
217
|
+
try {
|
|
218
|
+
const digest = packageDigest(value);
|
|
219
|
+
if (digest !== value.digest) flag('/digest', `the digest is ${digest}: the package changed after it was packed, or was packed over another form than RFC 8785`);
|
|
220
|
+
} catch {
|
|
221
|
+
flag('/digest', 'the package has no RFC 8785 form, so it has no digest');
|
|
222
|
+
}
|
|
223
|
+
const sha = new Map();
|
|
224
|
+
objects(value.files).forEach((entry, index) => {
|
|
225
|
+
const p = String(entry.path);
|
|
226
|
+
if (sha.has(p)) flag(`/files/${index}/path`, `${p} is listed twice`);
|
|
227
|
+
if (p === PACKAGE_FILE) flag(`/files/${index}/path`, `${PACKAGE_FILE} carries the digest and is never listed in files[]`);
|
|
228
|
+
sha.set(p, entry.sha256);
|
|
229
|
+
});
|
|
230
|
+
const named = (at, p, what) => { if (isString(p) && !sha.has(p)) flag(at, `${what} ${p} is not listed in files[], so the digest does not cover it`); };
|
|
231
|
+
named('/provider', value.provider, 'the provider manifest');
|
|
232
|
+
if (isObject(value.service) && Array.isArray(value.service.entry)) named('/service/entry/0', value.service.entry[0], "the service's entry");
|
|
233
|
+
if (isObject(value.evals)) {
|
|
234
|
+
named('/evals/suite', value.evals.suite, 'the evals suite');
|
|
235
|
+
const receipt = isObject(value.evals.receipt) ? value.evals.receipt : undefined;
|
|
236
|
+
if (receipt && isString(value.evals.suite) && sha.has(value.evals.suite) && receipt.suite !== sha.get(value.evals.suite)) flag('/evals/receipt/suite', 'the receipt was written for another suite than the one packed');
|
|
237
|
+
}
|
|
238
|
+
objects(value.dependencies).forEach((dependency, index) => {
|
|
239
|
+
if (!isString(dependency.path)) return;
|
|
240
|
+
named(`/dependencies/${index}/path`, dependency.path, 'the vendored dependency');
|
|
241
|
+
if (sha.has(dependency.path) && sha.get(dependency.path) !== dependency.sha256) flag(`/dependencies/${index}/sha256`, `the vendored ${String(dependency.name)} is not the file packed at ${dependency.path}`);
|
|
242
|
+
});
|
|
243
|
+
const paths = new Set();
|
|
244
|
+
objects(value.state).forEach((entry, index) => {
|
|
245
|
+
if (paths.has(entry.path)) flag(`/state/${index}/path`, `state ${String(entry.path)} is declared twice`);
|
|
246
|
+
paths.add(entry.path);
|
|
247
|
+
});
|
|
248
|
+
if (value.kind === 'skill-set' && !sha.has(PLUGIN_MANIFEST)) flag('/files', `a skill set is one Claude Code plugin folder and lists ${PLUGIN_MANIFEST}`);
|
|
249
|
+
if (value.kind === 'agent' && value.provider === undefined && mcpRequirements(value).length > 0 && !sha.has(PLUGIN_MANIFEST)) flag('/files', `a consumer agent is one Claude Code plugin folder and lists ${PLUGIN_MANIFEST}`);
|
|
250
|
+
return findings;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function noSecret(value) {
|
|
254
|
+
const findings = [];
|
|
255
|
+
const flag = (instancePath, message) => findings.push({ code: 'FAC-SEM-065', instancePath, message });
|
|
256
|
+
const names = new Set();
|
|
257
|
+
objects(value.secrets).forEach((slot, index) => {
|
|
258
|
+
if (names.has(slot.name)) flag(`/secrets/${index}/name`, `secret slot ${String(slot.name)} is declared twice`);
|
|
259
|
+
names.add(slot.name);
|
|
260
|
+
for (const field of ['name', 'purpose']) if (isString(slot[field]) && secretValue(slot[field])) flag(`/secrets/${index}/${field}`, 'a secret slot is named, never filled: this looks like a credential');
|
|
261
|
+
});
|
|
262
|
+
if (isObject(value.requires) && Array.isArray(value.requires.mcp)) {
|
|
263
|
+
value.requires.mcp.forEach((requirement, index) => {
|
|
264
|
+
if (!isObject(requirement)) return;
|
|
265
|
+
const texts = [['role', requirement.role], ['contract', requirement.contract], ...(Array.isArray(requirement.tools) ? requirement.tools.map((tool, t) => [`tools/${t}`, tool]) : [])];
|
|
266
|
+
for (const [field, text] of texts) if (isString(text) && secretValue(text)) flag(`/requires/mcp/${index}/${field}`, 'a required MCP server is named by role and tools, never by a credential');
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (isObject(value.service) && Array.isArray(value.service.entry)) {
|
|
270
|
+
value.service.entry.forEach((arg, index) => { if (isString(arg) && secretArgument(arg)) flag(`/service/entry/${index}`, 'a launch argument never carries a credential: the secret reaches the service from its slot'); });
|
|
271
|
+
}
|
|
272
|
+
return findings;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function agentRefRule(value) {
|
|
276
|
+
if (value.kind !== 'agent' || !isString(value.publisher) || !isString(value.id)) return [];
|
|
277
|
+
const expected = agentRefOf(value.publisher, value.id);
|
|
278
|
+
return value.agentRef === expected ? [] : [{ code: 'FAC-SEM-066', instancePath: '/agentRef', message: `the agent is ${expected}: agentRef is derived from publisher and id` }];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function mcpRoles(value) {
|
|
282
|
+
const seen = new Set();
|
|
283
|
+
const declared = new Set(objects(value.secrets).map((slot) => slot.name).filter(isString));
|
|
284
|
+
const feeds = new Map();
|
|
285
|
+
const findings = [];
|
|
286
|
+
mcpRequirements(value).forEach((requirement, index) => {
|
|
287
|
+
if (!isString(requirement.role)) return;
|
|
288
|
+
if (seen.has(requirement.role)) findings.push({ code: 'FAC-SEM-102', instancePath: `/requires/mcp/${index}/role`, message: `role ${requirement.role} is required twice: one role, one server` });
|
|
289
|
+
seen.add(requirement.role);
|
|
290
|
+
(Array.isArray(requirement.secrets) ? requirement.secrets : []).forEach((slot, s) => {
|
|
291
|
+
if (!isString(slot)) return;
|
|
292
|
+
const at = `/requires/mcp/${index}/secrets/${s}`;
|
|
293
|
+
if (!declared.has(slot)) findings.push({ code: 'FAC-SEM-102', instancePath: at, message: `slot ${slot} is not declared in secrets[]` });
|
|
294
|
+
const other = feeds.get(slot);
|
|
295
|
+
if (other !== undefined && other !== requirement.role) findings.push({ code: 'FAC-SEM-102', instancePath: at, message: `slot ${slot} already feeds role ${other}: one slot, one role` });
|
|
296
|
+
feeds.set(slot, other ?? requirement.role);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
return findings;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** FAC-SEM-064 (whole), 065 (no secret), 066 (agentRef derived) and 102 (one role, one server), in that order. */
|
|
303
|
+
export const packageFindings = (pkg) => [...whole(pkg), ...noSecret(pkg), ...agentRefRule(pkg), ...mcpRoles(pkg)];
|
|
304
|
+
|
|
305
|
+
// --- the folder check and the verifier -------------------------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
const parsed = (bytes) => {
|
|
308
|
+
if (bytes === undefined) return undefined;
|
|
309
|
+
try {
|
|
310
|
+
const value = JSON.parse(Buffer.from(bytes).toString('utf8'));
|
|
311
|
+
return isObject(value) ? value : undefined;
|
|
312
|
+
} catch {
|
|
313
|
+
return undefined;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* DEC-0050's folder check: every listed file present with its SHA-256, nothing unlisted but fabric.package.json, the
|
|
319
|
+
* plugin manifest naming the package's id and version, and the provider manifest carrying the package's cost presets or
|
|
320
|
+
* none. `files` maps each forward-slash path to its bytes.
|
|
321
|
+
*/
|
|
322
|
+
export function checkPackageFolder(pkg, files) {
|
|
323
|
+
const problems = [];
|
|
324
|
+
const listed = new Map(objects(pkg.files).map((entry) => [String(entry.path), entry.sha256]));
|
|
325
|
+
for (const [p, sha256] of listed) {
|
|
326
|
+
const bytes = files.get(p);
|
|
327
|
+
if (bytes === undefined) problems.push({ problem: 'file-missing', path: p });
|
|
328
|
+
else if (sha256Of(bytes) !== sha256) problems.push({ problem: 'file-digest', path: p });
|
|
329
|
+
}
|
|
330
|
+
for (const p of files.keys()) if (p !== PACKAGE_FILE && !listed.has(p)) problems.push({ problem: 'file-unlisted', path: p });
|
|
331
|
+
if (files.has(PLUGIN_MANIFEST)) {
|
|
332
|
+
const plugin = parsed(files.get(PLUGIN_MANIFEST));
|
|
333
|
+
if (!plugin || plugin.name !== pkg.id || plugin.version !== pkg.version) problems.push({ problem: 'plugin-manifest', path: PLUGIN_MANIFEST });
|
|
334
|
+
}
|
|
335
|
+
if (isString(pkg.provider)) {
|
|
336
|
+
const provider = parsed(files.get(pkg.provider));
|
|
337
|
+
if (files.has(pkg.provider) && (!provider || (provider.costPresets !== undefined && !jsonEqual(provider.costPresets, pkg.costPresets)))) problems.push({ problem: 'provider-manifest', path: pkg.provider });
|
|
338
|
+
}
|
|
339
|
+
return problems;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function keyFor(keys, kid, alg) {
|
|
343
|
+
const matches = keys.filter((candidate) => isObject(candidate) && candidate.kid === kid);
|
|
344
|
+
if (matches.length > 1) return { problem: 'duplicate-kid' };
|
|
345
|
+
const jwk = matches[0];
|
|
346
|
+
if (!jwk) return { problem: 'unknown-key' };
|
|
347
|
+
if (jwk.alg !== alg) return { problem: 'key-algorithm-mismatch' };
|
|
348
|
+
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || !isString(jwk.x) || !B64URL.test(jwk.x) || jwk.d !== undefined || (jwk.use !== undefined && jwk.use !== 'sig')) return { problem: 'unusable-key' };
|
|
349
|
+
const raw = Buffer.from(jwk.x, 'base64url');
|
|
350
|
+
if (raw.length !== 32) return { problem: 'unusable-key' };
|
|
351
|
+
if (isSmallOrderKey(raw)) return { problem: 'small-order-key' }; // DEC-0045: refused before any verification
|
|
352
|
+
return { key: crypto.createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, format: 'jwk' }) };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** The contract's verifyPackage (DEC-0051), in PK-1…PK-10's order. */
|
|
356
|
+
export function verifyPackage(pkg, trust, options = {}) {
|
|
357
|
+
if (!isObject(pkg) || !isString(pkg.digest)) return { ok: false, problem: 'malformed' };
|
|
358
|
+
let digest;
|
|
359
|
+
try { digest = packageDigest(pkg); } catch { return { ok: false, problem: 'malformed' }; }
|
|
360
|
+
if (digest !== pkg.digest) return { ok: false, problem: 'digest-mismatch' };
|
|
361
|
+
if (pkg.signatures === undefined || (Array.isArray(pkg.signatures) && pkg.signatures.length === 0)) return { ok: false, problem: 'unsigned' };
|
|
362
|
+
if (!Array.isArray(pkg.signatures)) return { ok: false, problem: 'malformed' };
|
|
363
|
+
if (pkg.signatures.filter((e) => isObject(e) && e.role === 'publisher').length !== 1) return { ok: false, problem: 'no-publisher-signature' };
|
|
364
|
+
const signers = [];
|
|
365
|
+
const unverified = [];
|
|
366
|
+
const pinned = isObject(trust) ? trust : {};
|
|
367
|
+
for (const [index, entry] of pkg.signatures.entries()) {
|
|
368
|
+
if (!isObject(entry) || (entry.role !== 'publisher' && entry.role !== 'organization') || !isString(entry.alg) || !isString(entry.kid) || !isString(entry.sig) || !B64URL.test(entry.sig)) return { ok: false, problem: 'malformed', signature: index };
|
|
369
|
+
if (!PACKAGE_ALGORITHMS.includes(entry.alg)) return { ok: false, problem: 'unknown-algorithm', signature: index };
|
|
370
|
+
const set = (entry.role === 'publisher' ? pinned.publisher : pinned.organization) ?? [];
|
|
371
|
+
const found = keyFor(Array.isArray(set) ? set : [], entry.kid, entry.alg);
|
|
372
|
+
if (found.problem === 'unknown-key' && entry.role === 'organization' && options.countersigned !== true) { unverified.push({ role: entry.role, kid: entry.kid }); continue; }
|
|
373
|
+
if (found.problem) return { ok: false, problem: found.problem, signature: index };
|
|
374
|
+
const signature = Buffer.from(entry.sig, 'base64url');
|
|
375
|
+
if (signature.length !== 64 || !crypto.verify(null, packageSigningInput(digest, entry.role), found.key, signature)) return { ok: false, problem: 'bad-signature', signature: index };
|
|
376
|
+
signers.push({ role: entry.role, kid: entry.kid });
|
|
377
|
+
}
|
|
378
|
+
if (options.countersigned === true && !signers.some((s) => s.role === 'organization')) return { ok: false, problem: 'no-countersignature' };
|
|
379
|
+
return unverified.length ? { ok: true, digest, signers, unverified } : { ok: true, digest, signers };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** What a placement lacks for requires.mcp (DEC-0071): `configured` maps each registered role to its tools. */
|
|
383
|
+
export function consumerPlacement(pkg, configured) {
|
|
384
|
+
if (!isObject(pkg)) return [];
|
|
385
|
+
const roles = mcpRequirements(pkg).filter((r) => isString(r.role));
|
|
386
|
+
if (!(configured instanceof Map)) return roles.map((r) => ({ problem: 'missing-role', role: r.role }));
|
|
387
|
+
const problems = [];
|
|
388
|
+
for (const requirement of roles) {
|
|
389
|
+
const registered = configured.get(requirement.role);
|
|
390
|
+
if (registered === undefined) { problems.push({ problem: 'missing-role', role: requirement.role }); continue; }
|
|
391
|
+
const tools = Array.isArray(registered) ? registered : [];
|
|
392
|
+
for (const tool of Array.isArray(requirement.tools) ? requirement.tools : []) if (isString(tool) && !tools.includes(tool)) problems.push({ problem: 'missing-tool', role: requirement.role, tool });
|
|
393
|
+
}
|
|
394
|
+
return problems;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// --- the folder on disk ----------------------------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
const litter = (name) => (JUNK.test(name) ? 'litter' : SECRET_FILE.test(name) && !SECRET_FILE_OK.test(name) ? 'a credential file' : null);
|
|
400
|
+
const utf16Order = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // JavaScript compares strings by UTF-16 code units
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Every regular file under `folder` by its forward-slash path. Refuses a symlink, a special file, version-control or
|
|
404
|
+
* cache litter, and a file that is a credential by its name — each named, so the person can remove it or pack a clean
|
|
405
|
+
* copy.
|
|
406
|
+
*/
|
|
407
|
+
export function readFolder(folder) {
|
|
408
|
+
let stat;
|
|
409
|
+
try { stat = fs.statSync(folder); } catch { stat = null; }
|
|
410
|
+
if (!stat?.isDirectory()) throw new PackageError(`${folder} is not a folder.`);
|
|
411
|
+
const files = new Map();
|
|
412
|
+
const refused = [];
|
|
413
|
+
const walk = (dir, rel) => {
|
|
414
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => utf16Order(a.name, b.name))) {
|
|
415
|
+
const where = rel ? `${rel}/${entry.name}` : entry.name;
|
|
416
|
+
const why = entry.isSymbolicLink() ? 'a symlink' : litter(entry.name) ?? (entry.isDirectory() || entry.isFile() ? null : 'not a regular file');
|
|
417
|
+
if (why) { refused.push(`${where} (${why})`); continue; }
|
|
418
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), where);
|
|
419
|
+
else files.set(where, fs.readFileSync(path.join(dir, entry.name)));
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
walk(folder, '');
|
|
423
|
+
if (refused.length) throw new PackageError(`Not packed: remove these or pack a clean copy — ${refused.sort().join('; ')}`);
|
|
424
|
+
return files;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** JSON.parse that refuses a member named twice in one object, as the Python kit's reader does. */
|
|
428
|
+
function parseStrict(text, what) {
|
|
429
|
+
let value;
|
|
430
|
+
try { value = JSON.parse(text); } catch (error) { throw new PackageError(`${what} is not JSON: ${error.message}`); }
|
|
431
|
+
const stack = [];
|
|
432
|
+
let i = 0;
|
|
433
|
+
const skipString = () => { i += 1; let s = ''; while (text[i] !== '"') { if (text[i] === '\\') { s += text.slice(i, i + 2); i += 2; } else { s += text[i]; i += 1; } } i += 1; return JSON.parse(`"${s}"`); };
|
|
434
|
+
while (i < text.length) {
|
|
435
|
+
const c = text[i];
|
|
436
|
+
if (c === '{') { stack.push({ keys: new Set(), expectKey: true }); i += 1; } else if (c === '[') { stack.push(null); i += 1; } else if (c === '}' || c === ']') { stack.pop(); i += 1; } else if (c === ',') { const top = stack.at(-1); if (top) top.expectKey = true; i += 1; } else if (c === '"') {
|
|
437
|
+
const top = stack.at(-1);
|
|
438
|
+
const s = skipString();
|
|
439
|
+
if (top && top.expectKey) {
|
|
440
|
+
if (top.keys.has(s)) throw new PackageError(`${what} names ${JSON.stringify(s)} twice in one object.`);
|
|
441
|
+
top.keys.add(s);
|
|
442
|
+
top.expectKey = false;
|
|
443
|
+
}
|
|
444
|
+
} else i += 1;
|
|
445
|
+
}
|
|
446
|
+
if (!isObject(value)) throw new PackageError(`${what} is not a JSON object.`);
|
|
447
|
+
return value;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** JSON.stringify(value, null, 2), the bytes both kits write. */
|
|
451
|
+
export const dumps = (value) => JSON.stringify(value, null, 2);
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The packed document: the author's fields in their order, agentRef derived after publisher for an agent, then files[]
|
|
455
|
+
* (every file but this one, by path) and digest. Old signatures are dropped.
|
|
456
|
+
*/
|
|
457
|
+
export function compose(draft, files) {
|
|
458
|
+
const notes = [];
|
|
459
|
+
const agent = draft.kind === 'agent' && isString(draft.publisher) && isString(draft.id);
|
|
460
|
+
if (agent && Object.hasOwn(draft, 'agentRef') && draft.agentRef !== agentRefOf(draft.publisher, draft.id)) {
|
|
461
|
+
throw new PackageError(`agentRef is derived, never chosen: it is ${agentRefOf(draft.publisher, draft.id)} for this publisher and id (FAC-SEM-066). Remove agentRef or fix publisher/id.`);
|
|
462
|
+
}
|
|
463
|
+
const pkg = {};
|
|
464
|
+
for (const [key, value] of Object.entries(draft)) {
|
|
465
|
+
if (key === 'files' || key === 'digest' || key === 'signatures' || (agent && key === 'agentRef')) continue;
|
|
466
|
+
pkg[key] = value;
|
|
467
|
+
if (agent && key === 'publisher') pkg.agentRef = agentRefOf(draft.publisher, draft.id);
|
|
468
|
+
}
|
|
469
|
+
if (Array.isArray(draft.signatures) && draft.signatures.length) notes.push(`dropped ${draft.signatures.length} signature(s): a repacked package is signed again`);
|
|
470
|
+
pkg.files = [...files.keys()].filter((p) => p !== PACKAGE_FILE).sort(utf16Order).map((p) => ({ path: p, sha256: sha256Of(files.get(p)) }));
|
|
471
|
+
pkg.digest = packageDigest(pkg);
|
|
472
|
+
return { pkg, notes };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function allProblems(pkg, files) {
|
|
476
|
+
return [
|
|
477
|
+
...schemaProblems(pkg).map((p) => ({ ...p, rule: 'schema' })),
|
|
478
|
+
...packageFindings(pkg).map((p) => ({ ...p, rule: p.code })),
|
|
479
|
+
...checkPackageFolder(pkg, files).map((p) => ({ rule: 'folder', problem: p.problem, instancePath: p.path, message: p.problem })),
|
|
480
|
+
];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Write through a temporary file in the same folder and rename it over, so a reader never sees half a file. */
|
|
484
|
+
function writeAtomic(file, text) {
|
|
485
|
+
const temp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${process.pid}`);
|
|
486
|
+
fs.writeFileSync(temp, text);
|
|
487
|
+
fs.renameSync(temp, file);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Pack `folder`; with `apply` and no problem, fabric.package.json is rewritten. */
|
|
491
|
+
export function pack(folder, { apply = false } = {}) {
|
|
492
|
+
const files = readFolder(folder);
|
|
493
|
+
if (!files.has(PACKAGE_FILE)) {
|
|
494
|
+
throw new PackageError(`Write ${PACKAGE_FILE} in the folder first: schema, kind, id, version, publisher, licence and, for an agent, resultSignal and evals with its receipt. pack fills in agentRef, files[] and digest.`);
|
|
495
|
+
}
|
|
496
|
+
const draft = parseStrict(files.get(PACKAGE_FILE).toString('utf8'), PACKAGE_FILE);
|
|
497
|
+
const { pkg, notes } = compose(draft, files);
|
|
498
|
+
const problems = allProblems(pkg, files);
|
|
499
|
+
const suite = isObject(pkg.evals) ? pkg.evals.suite : undefined;
|
|
500
|
+
if (isString(suite) && files.has(suite) && problems.some((p) => p.instancePath === '/evals/receipt/suite')) notes.push(`the suite packed is ${sha256Of(files.get(suite))}: run it again and write that receipt`);
|
|
501
|
+
const answer = { ok: problems.length === 0, applied: false, digest: pkg.digest, files: pkg.files.length, problems, notes };
|
|
502
|
+
if (pkg.kind === 'agent' && Object.hasOwn(pkg, 'agentRef')) answer.agentRef = pkg.agentRef;
|
|
503
|
+
if (apply && problems.length === 0) {
|
|
504
|
+
writeAtomic(path.join(folder, PACKAGE_FILE), `${dumps(pkg)}\n`);
|
|
505
|
+
answer.applied = true;
|
|
506
|
+
}
|
|
507
|
+
answer.package = pkg;
|
|
508
|
+
return answer;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function packed(folder) {
|
|
512
|
+
const files = readFolder(folder);
|
|
513
|
+
if (!files.has(PACKAGE_FILE)) throw new PackageError(`${folder} has no ${PACKAGE_FILE}: pack it first.`);
|
|
514
|
+
return { pkg: parseStrict(files.get(PACKAGE_FILE).toString('utf8'), PACKAGE_FILE), files };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Add `role`'s Ed25519 signature over the packed digest. Only a package that is whole and matches its folder is signed.
|
|
519
|
+
* The signature of the same role and kid is replaced; a publisher signature is always one.
|
|
520
|
+
*/
|
|
521
|
+
export function sign(folder, keyFile, { role = 'publisher', kid, apply = false } = {}) {
|
|
522
|
+
if (role !== 'publisher' && role !== 'organization') throw new PackageError('role is publisher or organization.');
|
|
523
|
+
const { pkg, files } = packed(folder);
|
|
524
|
+
const problems = allProblems(pkg, files);
|
|
525
|
+
if (problems.length) return { ok: false, applied: false, problems, notes: ['pack the folder again before it is signed'] };
|
|
526
|
+
const key = readSigningKey(keyFile, kid);
|
|
527
|
+
const entry = { role, alg: 'EdDSA', kid: key.kid, sig: ed25519Sign(key.seed, packageSigningInput(pkg.digest, role)).toString('base64url') };
|
|
528
|
+
const kept = (Array.isArray(pkg.signatures) ? pkg.signatures : []).filter((s) => isObject(s) && !(s.role === role && (role === 'publisher' || s.kid === key.kid)));
|
|
529
|
+
const signed = { ...pkg, signatures: role === 'publisher' ? [entry, ...kept] : [...kept, entry] };
|
|
530
|
+
const jwk = publicJwk(key.seed, key.kid);
|
|
531
|
+
if (role === 'publisher') {
|
|
532
|
+
const check = verifyPackage(signed, { publisher: [jwk] });
|
|
533
|
+
if (!check.ok) throw new PackageError(`The signature does not verify under its own key (${check.problem}); nothing was written.`);
|
|
534
|
+
}
|
|
535
|
+
const answer = { ok: true, applied: false, digest: pkg.digest, signature: entry, publicKey: jwk, problems: [], notes: [] };
|
|
536
|
+
if (apply) {
|
|
537
|
+
writeAtomic(path.join(folder, PACKAGE_FILE), `${dumps(signed)}\n`);
|
|
538
|
+
answer.applied = true;
|
|
539
|
+
}
|
|
540
|
+
return answer;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** The folder check, the contract's rules, then verifyPackage against the pinned `trust`. */
|
|
544
|
+
export function verify(folder, trust, { countersigned = false } = {}) {
|
|
545
|
+
const { pkg, files } = packed(folder);
|
|
546
|
+
const problems = allProblems(pkg, files);
|
|
547
|
+
const verdict = verifyPackage(pkg, trust, { countersigned });
|
|
548
|
+
return { ok: Boolean(verdict.ok) && problems.length === 0, verdict, problems };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const USAGE = `usage: fabric-package.mjs pack <folder> [--apply]
|
|
552
|
+
fabric-package.mjs sign <folder> --key <file> [--kid <kid>] [--role publisher|organization] [--apply]
|
|
553
|
+
fabric-package.mjs verify <folder> --trust <trust.json> [--countersigned]`;
|
|
554
|
+
|
|
555
|
+
/** The command line; returns the exit code. `print` receives each line of output. */
|
|
556
|
+
export function main(argv, print = (line) => process.stdout.write(`${line}\n`)) {
|
|
557
|
+
const [command, folder, ...rest] = argv;
|
|
558
|
+
const flags = {};
|
|
559
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
560
|
+
const name = rest[i];
|
|
561
|
+
if (['--apply', '--dry-run', '--countersigned'].includes(name)) flags[name.slice(2)] = true;
|
|
562
|
+
else if (['--key', '--kid', '--role', '--trust'].includes(name) && rest[i + 1] !== undefined) { flags[name.slice(2)] = rest[i + 1]; i += 1; } else { process.stderr.write(`${USAGE}\nunknown argument ${name}\n`); return 2; }
|
|
563
|
+
}
|
|
564
|
+
if (!['pack', 'sign', 'verify'].includes(command) || !folder || (flags.apply && flags['dry-run'])
|
|
565
|
+
|| (command === 'sign' && !flags.key) || (command === 'verify' && !flags.trust) || (flags.role && !['publisher', 'organization'].includes(flags.role))) {
|
|
566
|
+
process.stderr.write(`${USAGE}\n`);
|
|
567
|
+
return 2;
|
|
568
|
+
}
|
|
569
|
+
let answer;
|
|
570
|
+
try {
|
|
571
|
+
if (command === 'pack') {
|
|
572
|
+
answer = pack(folder, { apply: Boolean(flags.apply) });
|
|
573
|
+
if (flags.apply) delete answer.package;
|
|
574
|
+
} else if (command === 'sign') {
|
|
575
|
+
answer = sign(folder, flags.key, { role: flags.role ?? 'publisher', kid: flags.kid, apply: Boolean(flags.apply) });
|
|
576
|
+
} else {
|
|
577
|
+
let trustText;
|
|
578
|
+
try { trustText = fs.readFileSync(flags.trust, 'utf8'); } catch (error) { throw new PackageError(`Cannot read ${flags.trust}: ${error.code ?? error.message}.`); }
|
|
579
|
+
answer = verify(folder, parseStrict(trustText, flags.trust), { countersigned: Boolean(flags.countersigned) });
|
|
580
|
+
}
|
|
581
|
+
} catch (error) {
|
|
582
|
+
if (!(error instanceof PackageError)) throw error;
|
|
583
|
+
print(JSON.stringify({ ok: false, error: error.message }));
|
|
584
|
+
return 1;
|
|
585
|
+
}
|
|
586
|
+
print(dumps(answer));
|
|
587
|
+
return answer.ok ? 0 : 1;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) process.exitCode = main(process.argv.slice(2));
|