@knolo/cli 6.0.0 → 6.1.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.md +11 -2
- package/bin/compose.mjs +739 -0
- package/bin/knolo.mjs +12 -0
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -11,6 +11,14 @@ decompress` restores ordinary required segments. VQF preserves logical roots;
|
|
|
11
11
|
the format is still draft and compressed object/event images require a VQF-aware
|
|
12
12
|
reader.
|
|
13
13
|
|
|
14
|
+
`knolo compose`, `knolo attest`, and `knolo verify-attestation` cite local
|
|
15
|
+
Knowledge Images and write an attestation sidecar. `@knolo/cli` `6.1.0`
|
|
16
|
+
depends on `@knolo/core` `^6.1.0`. They do not fetch Hub. Verification
|
|
17
|
+
recomputes `compositionRoot` and checks bundled excerpt bytes. A keyring key
|
|
18
|
+
must fall inside `notBefore`, `notAfter`, and `revokedAt`. `used` means a
|
|
19
|
+
query or evaluation ran. It does not mean `SATISFIED`, and it does not mean a
|
|
20
|
+
claim is true or false. Hub `attested: true` is a listing flag.
|
|
21
|
+
|
|
14
22
|
The official CLI for building `.knolo` knowledge packs.
|
|
15
23
|
|
|
16
24
|
It indexes structured content and produces a deterministic, local-first knowledge bundle for use with `@knolo/core`.
|
|
@@ -267,8 +275,9 @@ This ensures:
|
|
|
267
275
|
## 🗺 Roadmap
|
|
268
276
|
|
|
269
277
|
The active roadmap is maintained in [`../../docs/ROADMAP.md`](../../docs/ROADMAP.md).
|
|
270
|
-
The current release
|
|
271
|
-
read-only Studio management
|
|
278
|
+
The current release is `6.1.0`. It preserves the V4 CLI, keeps V5 verification,
|
|
279
|
+
migration, diagnostics, and read-only Studio management, and adds offline
|
|
280
|
+
composition and attestation.
|
|
272
281
|
|
|
273
282
|
---
|
|
274
283
|
|
package/bin/compose.mjs
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knolo compose / knolo attest
|
|
3
|
+
*
|
|
4
|
+
* Offline composition and attestation. This file does not fetch Hub.
|
|
5
|
+
* `used` means a query or a KAR evaluation ran. It is not a truth verdict.
|
|
6
|
+
* Hub `attested: true` is a listing flag and is not an attestation root.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { homedir, tmpdir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
|
+
import { KAR_CLI_MAX_IMAGE_BYTES, readBounded, readBoundedJson } from './kar-limits.mjs';
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
const COMPOSE_USAGE = `Usage:
|
|
18
|
+
knolo compose create --members members.json --out composition/ [--host knowledge.knolo] [--host-knowledge-root <digest>] [--availability material|digest] [--policy policy.json]
|
|
19
|
+
knolo compose verify <composition/> [--require-material] [--member alias=path]... [--host knowledge.knolo]
|
|
20
|
+
knolo compose excerpt --image knowledge.knolo --object <id> [--object <id>] --out excerpt.json
|
|
21
|
+
knolo compose query --composition composition/ --eql "..." [--question "..."] [--json]
|
|
22
|
+
|
|
23
|
+
Members use composition-members-1. Paths stay in that file and in bundle.json. They are not part of compositionRoot.
|
|
24
|
+
availability material requires bytes. digest commits to digests only.
|
|
25
|
+
A composition does not change the host image. statement used is not SATISFIED and is not a claim verdict.
|
|
26
|
+
Hub attested: true is unrelated.`;
|
|
27
|
+
|
|
28
|
+
const ATTEST_USAGE = `Usage:
|
|
29
|
+
knolo attest create --composition composition/composition.json --subject composition --statement mount|used|cited --out attestation.json [--alias <alias>] [--kar-root <digest> --knowledge-root <digest> --state-root <digest>] [--sign-key key.json --principal <id> --key-id <id> --issued-at <ms> --expires-at <ms> --keyring keyring.json]
|
|
30
|
+
knolo attest verify <attestation.json> [--composition composition.json] [--public-key key.json] [--keyring keyring.json] [--now <ms>] [--content-only]
|
|
31
|
+
knolo verify-attestation <attestation.json> [same verify flags]
|
|
32
|
+
|
|
33
|
+
statement used means a query or KAR evaluation ran. It does not mean the claim is true or false.
|
|
34
|
+
Hub attested: true is a listing flag, not this attestation root.
|
|
35
|
+
Signing requires caller-supplied issued-at and expires-at. The CLI does not read the clock.`;
|
|
36
|
+
|
|
37
|
+
export async function runComposeCli(command, args) {
|
|
38
|
+
if (command === 'verify-attestation') return attestVerify(parseFlags(args));
|
|
39
|
+
if (command === 'attest') {
|
|
40
|
+
const sub = args[0];
|
|
41
|
+
if (sub === 'create') return attestCreate(parseFlags(args.slice(1)));
|
|
42
|
+
if (sub === 'verify') return attestVerify(parseFlags(args.slice(1)));
|
|
43
|
+
throw new Error(`Unknown attest command.\n${ATTEST_USAGE}`);
|
|
44
|
+
}
|
|
45
|
+
if (command !== 'compose') throw new Error(`Unknown command ${command}`);
|
|
46
|
+
const sub = args[0];
|
|
47
|
+
if (sub === 'create') return composeCreate(parseFlags(args.slice(1)));
|
|
48
|
+
if (sub === 'verify') return composeVerify(parseFlags(args.slice(1)));
|
|
49
|
+
if (sub === 'excerpt') return composeExcerpt(parseFlags(args.slice(1)));
|
|
50
|
+
if (sub === 'query') return composeQuery(parseFlags(args.slice(1)));
|
|
51
|
+
throw new Error(`Unknown compose command.\n${COMPOSE_USAGE}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function composeCreate(flags) {
|
|
55
|
+
const api = await loadComposition();
|
|
56
|
+
const out = resolve(required(flags, 'out'));
|
|
57
|
+
if (existsSync(path.join(out, 'composition.json'))) throw new Error(`Refusing to overwrite ${path.join(out, 'composition.json')}`);
|
|
58
|
+
const availability = flags.availability ?? 'material';
|
|
59
|
+
if (availability !== 'material' && availability !== 'digest') throw new Error('--availability must be material or digest.');
|
|
60
|
+
const membersFile = readBoundedJson(required(flags, 'members'));
|
|
61
|
+
if (!membersFile || membersFile.format !== 'composition-members-1' || membersFile.version !== 1 || !Array.isArray(membersFile.members)) {
|
|
62
|
+
throw new Error('members file must be composition-members-1.');
|
|
63
|
+
}
|
|
64
|
+
const host = readHost(flags, availability);
|
|
65
|
+
const prepared = membersFile.members.map((entry) => readMember(entry, availability));
|
|
66
|
+
try {
|
|
67
|
+
bindSidecars(api, host, prepared);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
return failClosed(error);
|
|
70
|
+
}
|
|
71
|
+
const domainPackRoot = prepared.some((item) => item.kind === 'domain-pack' && item.files) ? await domainResolver() : undefined;
|
|
72
|
+
let created;
|
|
73
|
+
try {
|
|
74
|
+
created = api.createCompositionRecord({
|
|
75
|
+
...(host.input ? { host: host.input } : {}),
|
|
76
|
+
members: prepared.map((item) => item.input),
|
|
77
|
+
availability,
|
|
78
|
+
...(flags.policy ? { policy: readBoundedJson(flags.policy) } : {}),
|
|
79
|
+
...(domainPackRoot ? { domainPackRoot } : {}),
|
|
80
|
+
karBinder: api.karCompositionBinder,
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return failClosed(error);
|
|
84
|
+
}
|
|
85
|
+
const verdict = api.verifyCompositionRecord(created.record, {
|
|
86
|
+
...(host.bytes ? { hostBytes: host.bytes } : {}),
|
|
87
|
+
members: materialMap(prepared),
|
|
88
|
+
...(domainPackRoot ? { domainPackRoot } : {}),
|
|
89
|
+
karBinder: api.karCompositionBinder,
|
|
90
|
+
});
|
|
91
|
+
if (!verdict.ok) {
|
|
92
|
+
console.log(verdict.code);
|
|
93
|
+
process.exitCode = 1;
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const files = [];
|
|
97
|
+
const bundleMembers = [];
|
|
98
|
+
if (host.bytes) {
|
|
99
|
+
files.push({ path: 'host.knolo', bytes: host.bytes });
|
|
100
|
+
}
|
|
101
|
+
for (const item of prepared) collectMemberFiles(item, files, bundleMembers, created.record);
|
|
102
|
+
const recordText = `${JSON.stringify(created.record, null, 2)}\n`;
|
|
103
|
+
files.push({ path: 'composition.json', bytes: Buffer.from(recordText) });
|
|
104
|
+
const bundle = {
|
|
105
|
+
format: 'composition-bundle-1',
|
|
106
|
+
version: 1,
|
|
107
|
+
compositionRoot: created.compositionRoot,
|
|
108
|
+
...(host.bytes ? { host: { path: 'host.knolo', sha256: digest(host.bytes) } } : {}),
|
|
109
|
+
members: bundleMembers,
|
|
110
|
+
files: files.map((file) => ({ path: file.path, sha256: digest(file.bytes) })).sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)),
|
|
111
|
+
};
|
|
112
|
+
mkdirSync(out, { recursive: true });
|
|
113
|
+
for (const file of files) {
|
|
114
|
+
if (file.path === 'composition.json') continue;
|
|
115
|
+
const target = safeJoin(out, file.path);
|
|
116
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
117
|
+
writeFileSync(target, file.bytes);
|
|
118
|
+
}
|
|
119
|
+
writeFileSync(path.join(out, 'bundle.json'), `${JSON.stringify(bundle, null, 2)}\n`);
|
|
120
|
+
writeFileSync(path.join(out, 'composition.json'), recordText);
|
|
121
|
+
if (flags.json) console.log(JSON.stringify({ compositionRoot: created.compositionRoot, availability }, null, 2));
|
|
122
|
+
else console.log(`${created.compositionRoot}\n${out}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function composeVerify(flags) {
|
|
126
|
+
const directory = flags._[0];
|
|
127
|
+
if (!directory) throw new Error(`Missing composition directory.\n${COMPOSE_USAGE}`);
|
|
128
|
+
try {
|
|
129
|
+
return await verifyDirectory(flags, directory);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return failClosed(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function verifyDirectory(flags, directory) {
|
|
136
|
+
const api = await loadComposition();
|
|
137
|
+
const dir = resolve(directory);
|
|
138
|
+
const record = readBoundedJson(path.join(dir, 'composition.json'));
|
|
139
|
+
const bundle = existsSync(path.join(dir, 'bundle.json')) ? readBoundedJson(path.join(dir, 'bundle.json')) : null;
|
|
140
|
+
const overrides = new Map();
|
|
141
|
+
for (const pair of flags.member ?? []) {
|
|
142
|
+
const split = pair.indexOf('=');
|
|
143
|
+
if (split <= 0) throw new Error('--member must be alias=path.');
|
|
144
|
+
overrides.set(pair.slice(0, split), pair.slice(split + 1));
|
|
145
|
+
}
|
|
146
|
+
const hostBytes = flags.host ? readBounded(flags.host, KAR_CLI_MAX_IMAGE_BYTES) : bundle?.host ? readBundleFile(dir, bundle, bundle.host.path) : undefined;
|
|
147
|
+
const members = {};
|
|
148
|
+
const parsed = api.parseCompositionRecord(record);
|
|
149
|
+
for (const member of parsed.members) {
|
|
150
|
+
const loaded = loadMemberBytes(dir, bundle, member, overrides);
|
|
151
|
+
if (loaded) members[member.alias] = loaded;
|
|
152
|
+
}
|
|
153
|
+
const needsDomain = Object.values(members).some((item) => item && 'files' in item);
|
|
154
|
+
const domainPackRoot = needsDomain ? await domainResolver() : undefined;
|
|
155
|
+
const verdict = api.verifyCompositionRecord(record, {
|
|
156
|
+
...(hostBytes ? { hostBytes } : {}),
|
|
157
|
+
members,
|
|
158
|
+
requireMaterial: flags['require-material'] === true,
|
|
159
|
+
...(domainPackRoot ? { domainPackRoot } : {}),
|
|
160
|
+
karBinder: api.karCompositionBinder,
|
|
161
|
+
});
|
|
162
|
+
if (!verdict.ok) {
|
|
163
|
+
console.log(verdict.code);
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
console.log(verdict.availability === 'digest' ? 'VERIFIED digest-bound' : 'VERIFIED');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function composeExcerpt(flags) {
|
|
171
|
+
const api = await loadComposition();
|
|
172
|
+
const out = resolve(required(flags, 'out'));
|
|
173
|
+
if (existsSync(out)) throw new Error(`Refusing to overwrite ${out}`);
|
|
174
|
+
const image = readBounded(required(flags, 'image'), KAR_CLI_MAX_IMAGE_BYTES);
|
|
175
|
+
const objectIds = flags.object ?? [];
|
|
176
|
+
if (objectIds.length === 0) throw new Error('excerpt requires at least one --object.');
|
|
177
|
+
let created;
|
|
178
|
+
try {
|
|
179
|
+
created = api.createBoundedExcerpt({ image, objectIds });
|
|
180
|
+
} catch (error) {
|
|
181
|
+
return failClosed(error);
|
|
182
|
+
}
|
|
183
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
184
|
+
writeFileSync(out, `${JSON.stringify(created.excerpt, null, 2)}\n`);
|
|
185
|
+
console.log(created.excerptRoot);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function composeQuery(flags) {
|
|
189
|
+
const api = await loadComposition();
|
|
190
|
+
const directory = required(flags, 'composition');
|
|
191
|
+
const dir = resolve(directory);
|
|
192
|
+
const record = readBoundedJson(path.join(dir, 'composition.json'));
|
|
193
|
+
const bundle = existsSync(path.join(dir, 'bundle.json')) ? readBoundedJson(path.join(dir, 'bundle.json')) : null;
|
|
194
|
+
const parsed = api.parseCompositionRecord(record);
|
|
195
|
+
const hostBytes = bundle?.host ? readBundleFile(dir, bundle, bundle.host.path) : undefined;
|
|
196
|
+
const members = {};
|
|
197
|
+
for (const member of parsed.members) {
|
|
198
|
+
const loaded = loadMemberBytes(dir, bundle, member, new Map());
|
|
199
|
+
if (loaded) members[member.alias] = loaded;
|
|
200
|
+
}
|
|
201
|
+
let session;
|
|
202
|
+
try {
|
|
203
|
+
session = api.openComposedSession(record, {
|
|
204
|
+
...(hostBytes ? { hostBytes } : {}),
|
|
205
|
+
members,
|
|
206
|
+
...(Object.values(members).some((item) => item && 'files' in item) ? { domainPackRoot: await domainResolver() } : {}),
|
|
207
|
+
karBinder: api.karCompositionBinder,
|
|
208
|
+
});
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return failClosed(error);
|
|
211
|
+
}
|
|
212
|
+
let result;
|
|
213
|
+
try {
|
|
214
|
+
result = api.queryComposedSession(session, {
|
|
215
|
+
...(flags.eql ? { eql: flags.eql } : {}),
|
|
216
|
+
...(flags.question ? { question: flags.question } : {}),
|
|
217
|
+
...(flags['require-queryable'] ? { requireQueryable: true } : {}),
|
|
218
|
+
...(flags['require-policy'] ? { requirePolicy: true, policy: readBoundedJson(required(flags, 'policy')), principal: required(flags, 'principal') } : {}),
|
|
219
|
+
});
|
|
220
|
+
} catch (error) {
|
|
221
|
+
return failClosed(error);
|
|
222
|
+
}
|
|
223
|
+
if (flags.json) {
|
|
224
|
+
console.log(JSON.stringify(result, null, 2));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
for (const row of result.results) {
|
|
228
|
+
console.log(`${row.alias} ${row.kind} ${row.status}`);
|
|
229
|
+
if (row.planRoot) console.log(`planRoot ${row.planRoot}`);
|
|
230
|
+
if (row.resultRoot) console.log(`resultRoot ${row.resultRoot}`);
|
|
231
|
+
if (row.hits) console.log(`hits ${row.hits.length}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function attestCreate(flags) {
|
|
236
|
+
const api = await loadComposition();
|
|
237
|
+
const out = resolve(required(flags, 'out'));
|
|
238
|
+
if (existsSync(out)) throw new Error(`Refusing to overwrite ${out}`);
|
|
239
|
+
const parsed = api.assertCompositionRoot(readBoundedJson(compositionPath(required(flags, 'composition'))));
|
|
240
|
+
const statement = required(flags, 'statement');
|
|
241
|
+
const subject = subjectFrom(parsed, flags);
|
|
242
|
+
let attestation;
|
|
243
|
+
try {
|
|
244
|
+
attestation = api.createCompositionAttestation({ record: parsed, subject, statement }).attestation;
|
|
245
|
+
if (flags['sign-key']) attestation = await signAttestation(api, attestation, flags);
|
|
246
|
+
} catch (error) {
|
|
247
|
+
return failClosed(error);
|
|
248
|
+
}
|
|
249
|
+
mkdirSync(path.dirname(out), { recursive: true });
|
|
250
|
+
writeFileSync(out, `${JSON.stringify(attestation, null, 2)}\n`);
|
|
251
|
+
console.log(attestation.attestationRoot);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function attestVerify(flags) {
|
|
255
|
+
const api = await loadComposition();
|
|
256
|
+
const file = flags._[0];
|
|
257
|
+
if (!file) throw new Error(`Missing attestation file.\n${ATTEST_USAGE}`);
|
|
258
|
+
const attestation = readBoundedJson(file);
|
|
259
|
+
if (flags.composition) {
|
|
260
|
+
let record;
|
|
261
|
+
try {
|
|
262
|
+
record = api.assertCompositionRoot(readBoundedJson(compositionPath(flags.composition)));
|
|
263
|
+
} catch (error) {
|
|
264
|
+
return failClosed(error);
|
|
265
|
+
}
|
|
266
|
+
if (attestation.compositionRoot !== record.compositionRoot) {
|
|
267
|
+
console.log('ATTESTATION_MISMATCH');
|
|
268
|
+
process.exitCode = 1;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const verdict = await api.verifyCompositionAttestation(attestation, {
|
|
273
|
+
...(flags.now !== undefined ? { now: Number(flags.now) } : {}),
|
|
274
|
+
checkSignature: flags['content-only'] ? false : true,
|
|
275
|
+
...(flags['public-key'] ? { publicKey: loadPublicKey(flags['public-key']) } : {}),
|
|
276
|
+
...(flags.keyring ? { keyring: loadKeyring(flags.keyring) } : {}),
|
|
277
|
+
});
|
|
278
|
+
if (!verdict.ok) {
|
|
279
|
+
console.log(verdict.code);
|
|
280
|
+
process.exitCode = 1;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (verdict.signature === 'content-only') console.log('VERIFIED content-only');
|
|
284
|
+
else console.log('VERIFIED');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function bindSidecars(api, host, prepared) {
|
|
288
|
+
for (const member of prepared) {
|
|
289
|
+
if (member.kind !== 'kar-sidecar' || !member.bytes) continue;
|
|
290
|
+
const imageBytes = member.input.bindsTo === 'host' ? host.bytes : prepared.find((item) => item.alias === member.input.bindsTo)?.bytes;
|
|
291
|
+
if (!imageBytes) {
|
|
292
|
+
const error = new Error(`${member.alias} binds to an image without bytes.`);
|
|
293
|
+
error.code = 'COMPOSITION_KAR_UNBOUND';
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
const bound = api.karCompositionBinder(imageBytes, member.bytes);
|
|
297
|
+
member.input.knowledgeRoot = bound.knowledgeRoot;
|
|
298
|
+
member.input.semanticRoot = bound.semanticRoot;
|
|
299
|
+
if (member.input.bindsTo === 'host') {
|
|
300
|
+
host.input.knowledgeRoot = bound.knowledgeRoot;
|
|
301
|
+
} else {
|
|
302
|
+
const image = prepared.find((item) => item.alias === member.input.bindsTo);
|
|
303
|
+
if (image && image.input.knowledgeRoot === undefined) image.input.knowledgeRoot = bound.knowledgeRoot;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readHost(flags, availability) {
|
|
309
|
+
if (!flags.host) return { input: { kind: 'none' } };
|
|
310
|
+
if (availability === 'digest' && flags['host-state-root']) {
|
|
311
|
+
return {
|
|
312
|
+
input: {
|
|
313
|
+
kind: 'v5-image',
|
|
314
|
+
stateRoot: flags['host-state-root'],
|
|
315
|
+
objectRoot: required(flags, 'host-object-root'),
|
|
316
|
+
commitDigest: required(flags, 'host-commit-digest'),
|
|
317
|
+
...(flags['host-knowledge-root'] ? { knowledgeRoot: flags['host-knowledge-root'] } : {}),
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const bytes = readBounded(flags.host, KAR_CLI_MAX_IMAGE_BYTES);
|
|
322
|
+
return {
|
|
323
|
+
bytes,
|
|
324
|
+
input: {
|
|
325
|
+
kind: 'v5-image',
|
|
326
|
+
bytes,
|
|
327
|
+
...(flags['host-knowledge-root'] ? { knowledgeRoot: flags['host-knowledge-root'] } : {}),
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function readMember(entry, availability) {
|
|
333
|
+
if (!entry || typeof entry.alias !== 'string' || typeof entry.kind !== 'string') throw new Error('Each member needs alias and kind.');
|
|
334
|
+
const input = { alias: entry.alias, kind: entry.kind };
|
|
335
|
+
if (availability === 'digest' && !entry.path) return { alias: entry.alias, kind: entry.kind, input: { ...input, ...digestFields(entry) } };
|
|
336
|
+
if (!entry.path) throw new Error(`Member ${entry.alias} needs path.`);
|
|
337
|
+
if (entry.kind === 'domain-pack') {
|
|
338
|
+
const files = readDomainFiles(entry.path);
|
|
339
|
+
return { alias: entry.alias, kind: entry.kind, files, input: { ...input, files } };
|
|
340
|
+
}
|
|
341
|
+
if (entry.kind === 'excerpt') {
|
|
342
|
+
const excerpt = readBoundedJson(entry.path);
|
|
343
|
+
const parentBytes = entry.parent ? readBounded(entry.parent, KAR_CLI_MAX_IMAGE_BYTES) : undefined;
|
|
344
|
+
return {
|
|
345
|
+
alias: entry.alias,
|
|
346
|
+
kind: entry.kind,
|
|
347
|
+
excerpt,
|
|
348
|
+
parentBytes,
|
|
349
|
+
input: {
|
|
350
|
+
...input,
|
|
351
|
+
excerptRoot: excerpt.excerptRoot,
|
|
352
|
+
parentStateRoot: excerpt.parent?.stateRoot,
|
|
353
|
+
parentObjectRoot: excerpt.parent?.objectRoot,
|
|
354
|
+
parentCommitDigest: excerpt.parent?.commitDigest,
|
|
355
|
+
inclusion: excerpt.inclusion,
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
if (entry.kind === 'composition') {
|
|
360
|
+
const directory = resolve(entry.path);
|
|
361
|
+
const directoryRecord = existsSync(path.join(directory, 'composition.json'));
|
|
362
|
+
const nestedPath = directoryRecord ? path.join(directory, 'composition.json') : directory;
|
|
363
|
+
const nested = readBoundedJson(nestedPath);
|
|
364
|
+
const hostFile = directoryRecord ? path.join(directory, 'host.knolo') : undefined;
|
|
365
|
+
const hostBytes = hostFile && existsSync(hostFile) ? readBounded(hostFile, KAR_CLI_MAX_IMAGE_BYTES) : undefined;
|
|
366
|
+
return { alias: entry.alias, kind: entry.kind, record: nested, hostBytes, input: { ...input, record: nested } };
|
|
367
|
+
}
|
|
368
|
+
const bytes = readBounded(entry.path, KAR_CLI_MAX_IMAGE_BYTES);
|
|
369
|
+
const extra = {};
|
|
370
|
+
if (entry.kind === 'kar-sidecar') extra.bindsTo = entry.bindsTo;
|
|
371
|
+
if (entry.knowledgeRoot) extra.knowledgeRoot = entry.knowledgeRoot;
|
|
372
|
+
return { alias: entry.alias, kind: entry.kind, bytes, input: { ...input, bytes, ...extra } };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function digestFields(entry) {
|
|
376
|
+
const copy = { ...entry };
|
|
377
|
+
delete copy.path;
|
|
378
|
+
delete copy.parent;
|
|
379
|
+
return copy;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function materialMap(prepared) {
|
|
383
|
+
const members = {};
|
|
384
|
+
for (const item of prepared) {
|
|
385
|
+
if (item.bytes) members[item.alias] = { bytes: item.bytes };
|
|
386
|
+
else if (item.files) members[item.alias] = { files: item.files };
|
|
387
|
+
else if (item.record) members[item.alias] = { record: item.record, ...(item.hostBytes ? { hostBytes: item.hostBytes } : {}) };
|
|
388
|
+
else if (item.kind === 'excerpt') members[item.alias] = { excerpt: item.excerpt, ...(item.parentBytes ? { parentBytes: item.parentBytes } : {}) };
|
|
389
|
+
}
|
|
390
|
+
return members;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function collectMemberFiles(item, files, bundleMembers, record) {
|
|
394
|
+
const declared = record.members.find((member) => member.alias === item.alias);
|
|
395
|
+
if (item.bytes) {
|
|
396
|
+
const relative = item.kind === 'kar-sidecar' ? `members/${item.alias}.kar.json` : `members/${item.alias}.knolo`;
|
|
397
|
+
files.push({ path: relative, bytes: item.bytes });
|
|
398
|
+
bundleMembers.push({ alias: item.alias, kind: item.kind, path: relative, sha256: digest(item.bytes) });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (item.files) {
|
|
402
|
+
for (const file of item.files) {
|
|
403
|
+
files.push({ path: `members/${item.alias}/${file.path}`, bytes: file.bytes });
|
|
404
|
+
}
|
|
405
|
+
bundleMembers.push({ alias: item.alias, kind: item.kind, path: `members/${item.alias}`, ...(declared?.filesRoot ? { sha256: declared.filesRoot } : {}) });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (item.excerpt) {
|
|
409
|
+
const relative = `excerpts/${item.alias}.json`;
|
|
410
|
+
const bytes = Buffer.from(`${JSON.stringify(item.excerpt, null, 2)}\n`);
|
|
411
|
+
files.push({ path: relative, bytes });
|
|
412
|
+
bundleMembers.push({ alias: item.alias, kind: item.kind, path: relative, sha256: digest(bytes) });
|
|
413
|
+
if (item.parentBytes) {
|
|
414
|
+
const parent = `excerpts/${item.alias}.parent.knolo`;
|
|
415
|
+
files.push({ path: parent, bytes: item.parentBytes });
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (item.record) {
|
|
420
|
+
const relative = `members/${item.alias}/composition.json`;
|
|
421
|
+
const bytes = Buffer.from(`${JSON.stringify(item.record, null, 2)}\n`);
|
|
422
|
+
files.push({ path: relative, bytes });
|
|
423
|
+
bundleMembers.push({ alias: item.alias, kind: item.kind, path: relative, sha256: digest(bytes) });
|
|
424
|
+
if (item.hostBytes) files.push({ path: `members/${item.alias}/host.knolo`, bytes: item.hostBytes });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function loadMemberBytes(dir, bundle, member, overrides) {
|
|
429
|
+
const override = overrides.get(member.alias);
|
|
430
|
+
if (override) return bytesForKind(member, readBounded(override, KAR_CLI_MAX_IMAGE_BYTES), override);
|
|
431
|
+
const listed = bundle?.members?.find((item) => item.alias === member.alias);
|
|
432
|
+
if (!listed) {
|
|
433
|
+
if (member.kind === 'v5-image' || member.kind === 'v4-pack' || member.kind === 'kar-sidecar') {
|
|
434
|
+
const cached = cacheFile(member.sha256);
|
|
435
|
+
if (cached) return { bytes: cached };
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
if (member.kind === 'domain-pack') {
|
|
440
|
+
const prefix = `${listed.path.replace(/\/$/, '')}/`;
|
|
441
|
+
const files = (bundle.files ?? [])
|
|
442
|
+
.filter((file) => file.path.startsWith(prefix))
|
|
443
|
+
.map((file) => ({ path: file.path.slice(prefix.length), bytes: readBundleFile(dir, bundle, file.path) }));
|
|
444
|
+
return { files };
|
|
445
|
+
}
|
|
446
|
+
if (member.kind === 'composition') {
|
|
447
|
+
const record = readBoundedJson(safeJoin(dir, listed.path));
|
|
448
|
+
const directory = listed.path.split('/').slice(0, -1).join('/');
|
|
449
|
+
const hostPath = directory ? `${directory}/host.knolo` : 'host.knolo';
|
|
450
|
+
const hostFile = (bundle.files ?? []).find((file) => file.path === hostPath);
|
|
451
|
+
return { record, ...(hostFile ? { hostBytes: readBundleFile(dir, bundle, hostPath) } : {}) };
|
|
452
|
+
}
|
|
453
|
+
if (member.kind === 'excerpt') {
|
|
454
|
+
const excerpt = JSON.parse(readBundleFile(dir, bundle, listed.path).toString('utf8'));
|
|
455
|
+
const parentPath = `excerpts/${member.alias}.parent.knolo`;
|
|
456
|
+
const parentFile = (bundle.files ?? []).find((file) => file.path === parentPath);
|
|
457
|
+
return { excerpt, ...(parentFile ? { parentBytes: readBundleFile(dir, bundle, parentPath) } : {}) };
|
|
458
|
+
}
|
|
459
|
+
return { bytes: readBundleFile(dir, bundle, listed.path) };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function bytesForKind(member, bytes, file) {
|
|
463
|
+
if (member.kind === 'domain-pack') return { files: readDomainFiles(file) };
|
|
464
|
+
if (member.kind === 'composition') return { record: readBoundedJson(file) };
|
|
465
|
+
if (member.kind === 'excerpt') return { excerpt: readBoundedJson(file) };
|
|
466
|
+
return { bytes };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function readBundleFile(dir, bundle, relative) {
|
|
470
|
+
const file = (bundle.files ?? []).find((item) => item.path === relative);
|
|
471
|
+
const bytes = readFileSync(safeJoin(dir, relative));
|
|
472
|
+
if (file && digest(bytes) !== file.sha256) {
|
|
473
|
+
const error = new Error(`${relative} does not match bundle.json.`);
|
|
474
|
+
error.code = 'COMPOSITION_DIGEST_MISMATCH';
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
return bytes;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function cacheFile(sha) {
|
|
481
|
+
if (typeof sha !== 'string' || !sha.startsWith('sha256-')) return undefined;
|
|
482
|
+
const file = path.join(homedir(), '.knolo', 'cache', 'sha256', `${sha.slice('sha256-'.length)}.knolo`);
|
|
483
|
+
if (!existsSync(file)) return undefined;
|
|
484
|
+
const bytes = readFileSync(file);
|
|
485
|
+
return digest(bytes) === sha ? bytes : undefined;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function readDomainFiles(directory) {
|
|
489
|
+
const root = resolve(directory);
|
|
490
|
+
const files = [{ path: 'domain.yaml', bytes: readBounded(path.join(root, 'domain.yaml'), KAR_CLI_MAX_IMAGE_BYTES) }];
|
|
491
|
+
collectDomain(files, root, 'rules', (name) => /\.(ya?ml|json)$/i.test(name));
|
|
492
|
+
collectDomain(files, root, 'plans', (name) => name.endsWith('.json'));
|
|
493
|
+
collectDomain(files, root, 'fixtures', (name) => name.endsWith('.json'));
|
|
494
|
+
return files;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function collectDomain(files, root, folder, accept) {
|
|
498
|
+
const dir = path.join(root, folder);
|
|
499
|
+
if (!existsSync(dir)) return;
|
|
500
|
+
for (const name of readdirSync(dir).filter(accept).sort()) {
|
|
501
|
+
files.push({ path: `${folder}/${name}`, bytes: readBounded(path.join(dir, name), KAR_CLI_MAX_IMAGE_BYTES) });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function domainResolver() {
|
|
506
|
+
const authoring = await loadAuthoring();
|
|
507
|
+
const { loadDomainDirectory } = await import('./kar-produce.mjs');
|
|
508
|
+
return (files) => {
|
|
509
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'knolo-domain-'));
|
|
510
|
+
try {
|
|
511
|
+
for (const file of files) {
|
|
512
|
+
const target = safeJoin(dir, file.path);
|
|
513
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
514
|
+
writeFileSync(target, file.bytes);
|
|
515
|
+
}
|
|
516
|
+
const loaded = loadDomainDirectory(authoring, dir);
|
|
517
|
+
if (!loaded.ok) return null;
|
|
518
|
+
return { id: loaded.pack.id, version: String(loaded.pack.version), root: authoring.domainPackRoot(loaded.pack) };
|
|
519
|
+
} finally {
|
|
520
|
+
rmSync(dir, { recursive: true, force: true });
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function subjectFrom(record, flags) {
|
|
526
|
+
const kind = required(flags, 'subject');
|
|
527
|
+
if (kind === 'composition') return { kind, compositionRoot: record.compositionRoot };
|
|
528
|
+
if (kind === 'v5-image') {
|
|
529
|
+
const member = memberByAlias(record, flags.alias);
|
|
530
|
+
const image = member?.kind === 'v5-image' ? member : record.host.kind === 'v5-image' ? record.host : null;
|
|
531
|
+
if (!image) throw new Error('--subject v5-image needs --alias or a host image.');
|
|
532
|
+
return { kind, stateRoot: image.stateRoot, objectRoot: image.objectRoot, commitDigest: image.commitDigest };
|
|
533
|
+
}
|
|
534
|
+
if (kind === 'excerpt') {
|
|
535
|
+
const member = memberByAlias(record, required(flags, 'alias'));
|
|
536
|
+
if (!member || member.kind !== 'excerpt') throw new Error('--alias is not an excerpt.');
|
|
537
|
+
return { kind, excerptRoot: member.excerptRoot };
|
|
538
|
+
}
|
|
539
|
+
if (kind === 'kar-result') {
|
|
540
|
+
return {
|
|
541
|
+
kind,
|
|
542
|
+
karRoot: required(flags, 'kar-root'),
|
|
543
|
+
knowledgeRoot: required(flags, 'knowledge-root'),
|
|
544
|
+
stateRoot: required(flags, 'state-root'),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
throw new Error('--subject must be composition, v5-image, excerpt, or kar-result.');
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
async function signAttestation(api, attestation, flags) {
|
|
551
|
+
const key = readBoundedJson(flags['sign-key']);
|
|
552
|
+
const principal = flags.principal ?? key.principal;
|
|
553
|
+
const keyId = flags['key-id'] ?? key.keyId;
|
|
554
|
+
const issuedAt = Number(flags['issued-at'] ?? key.issuedAt);
|
|
555
|
+
const expiresAt = Number(flags['expires-at'] ?? key.expiresAt);
|
|
556
|
+
if (!principal || !keyId) throw new Error('Signing requires --principal and --key-id.');
|
|
557
|
+
if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(expiresAt)) {
|
|
558
|
+
throw new Error('Signing requires --issued-at and --expires-at as unix milliseconds. The CLI does not read the clock.');
|
|
559
|
+
}
|
|
560
|
+
const privateKey = decodeBase64(key.privateKey ?? key.seed);
|
|
561
|
+
let keyringRoot = typeof key.keyringRoot === 'string' ? key.keyringRoot : undefined;
|
|
562
|
+
if (flags.keyring) {
|
|
563
|
+
const computed = (await loadAuthorityRoot())(loadKeyring(flags.keyring));
|
|
564
|
+
if (keyringRoot && keyringRoot !== computed) throw new Error('Signing key keyringRoot does not match --keyring.');
|
|
565
|
+
keyringRoot = computed;
|
|
566
|
+
}
|
|
567
|
+
const envelope = {
|
|
568
|
+
format: 'composition-signature-1',
|
|
569
|
+
version: 1,
|
|
570
|
+
attestationRoot: attestation.attestationRoot,
|
|
571
|
+
algorithm: 'Ed25519',
|
|
572
|
+
principal,
|
|
573
|
+
keyId,
|
|
574
|
+
issuedAt,
|
|
575
|
+
expiresAt,
|
|
576
|
+
...(keyringRoot ? { keyringRoot } : {}),
|
|
577
|
+
};
|
|
578
|
+
const payload = api.attestationSignaturePayload(envelope);
|
|
579
|
+
const cryptoKey = privateKey.length === 32
|
|
580
|
+
? await crypto.subtle.importKey('raw', privateKey, { name: 'Ed25519' }, false, ['sign'])
|
|
581
|
+
: await crypto.subtle.importKey('pkcs8', privateKey, { name: 'Ed25519' }, false, ['sign']);
|
|
582
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, cryptoKey, payload));
|
|
583
|
+
return api.withCompositionSignature(attestation, {
|
|
584
|
+
principal,
|
|
585
|
+
keyId,
|
|
586
|
+
issuedAt,
|
|
587
|
+
expiresAt,
|
|
588
|
+
signature,
|
|
589
|
+
...(envelope.keyringRoot ? { keyringRoot: envelope.keyringRoot } : {}),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function loadPublicKey(file) {
|
|
594
|
+
const json = readBoundedJson(file);
|
|
595
|
+
const encoded = json && typeof json === 'object' ? json.publicKey ?? json.key : json;
|
|
596
|
+
if (typeof encoded !== 'string') throw new Error('Public key file must contain a base64 publicKey.');
|
|
597
|
+
return decodeBase64(encoded);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function loadKeyring(file) {
|
|
601
|
+
const json = readBoundedJson(file);
|
|
602
|
+
if (!json || !Array.isArray(json.keys)) throw new Error('Keyring must contain keys.');
|
|
603
|
+
return {
|
|
604
|
+
version: json.version,
|
|
605
|
+
sequence: json.sequence,
|
|
606
|
+
keys: json.keys.map((key) => ({ ...key, publicKey: typeof key.publicKey === 'string' ? decodeBase64(key.publicKey) : Uint8Array.from(key.publicKey) })),
|
|
607
|
+
rotations: json.rotations ?? [],
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function memberByAlias(record, alias) {
|
|
612
|
+
if (!alias) return undefined;
|
|
613
|
+
return record.members.find((member) => member.alias === alias);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function compositionPath(file) {
|
|
617
|
+
const resolved = resolve(file);
|
|
618
|
+
return existsSync(path.join(resolved, 'composition.json')) ? path.join(resolved, 'composition.json') : resolved;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function digest(bytes) {
|
|
622
|
+
return `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function decodeBase64(value) {
|
|
626
|
+
if (typeof value !== 'string' || value.length === 0) throw new Error('Expected base64.');
|
|
627
|
+
return new Uint8Array(Buffer.from(value, 'base64'));
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function safeJoin(root, relative) {
|
|
631
|
+
if (typeof relative !== 'string' || relative.length === 0 || relative.startsWith('/') || relative.includes('\\')) {
|
|
632
|
+
throw new Error(`Unsafe path ${relative}`);
|
|
633
|
+
}
|
|
634
|
+
if (relative.split('/').some((part) => part.length === 0 || part === '.' || part === '..')) throw new Error(`Unsafe path ${relative}`);
|
|
635
|
+
const resolved = path.resolve(root, relative);
|
|
636
|
+
const base = path.resolve(root);
|
|
637
|
+
if (resolved !== base && !resolved.startsWith(`${base}${path.sep}`)) throw new Error(`Unsafe path ${relative}`);
|
|
638
|
+
return resolved;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function failClosed(error) {
|
|
642
|
+
if (error && error.name === 'KarError') {
|
|
643
|
+
console.log('COMPOSITION_KAR_UNBOUND');
|
|
644
|
+
console.log(error.message);
|
|
645
|
+
process.exitCode = 1;
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (error && typeof error.code === 'string') {
|
|
649
|
+
console.log(error.code);
|
|
650
|
+
console.log(error.message);
|
|
651
|
+
process.exitCode = 1;
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function parseFlags(args) {
|
|
658
|
+
const flags = { _: [] };
|
|
659
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
660
|
+
const arg = args[index];
|
|
661
|
+
if (arg === '--json' || arg === '--require-material' || arg === '--content-only' || arg === '--require-queryable' || arg === '--require-policy') {
|
|
662
|
+
flags[arg.slice(2)] = true;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (arg.startsWith('--')) {
|
|
666
|
+
const key = arg.slice(2);
|
|
667
|
+
const value = args[index + 1];
|
|
668
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`Missing value for --${key}`);
|
|
669
|
+
index += 1;
|
|
670
|
+
if (key === 'member' || key === 'object') {
|
|
671
|
+
flags[key] = flags[key] ?? [];
|
|
672
|
+
flags[key].push(value);
|
|
673
|
+
} else flags[key] = value;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
flags._.push(arg);
|
|
677
|
+
}
|
|
678
|
+
return flags;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function required(flags, name) {
|
|
682
|
+
if (!flags[name]) throw new Error(`Missing --${name}`);
|
|
683
|
+
return flags[name];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function resolve(file) {
|
|
687
|
+
return path.resolve(process.cwd(), file);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async function loadAuthorityRoot() {
|
|
691
|
+
const candidates = [
|
|
692
|
+
path.resolve(__dirname, '../../core/dist/knowledge_key_rotation_v5.js'),
|
|
693
|
+
'@knolo/core',
|
|
694
|
+
];
|
|
695
|
+
for (const candidate of candidates) {
|
|
696
|
+
try {
|
|
697
|
+
const href = candidate.startsWith('@') ? candidate : pathToFileURL(candidate).href;
|
|
698
|
+
const loaded = await import(href);
|
|
699
|
+
if (loaded.authorityKeyringRootV1) return loaded.authorityKeyringRootV1;
|
|
700
|
+
} catch {
|
|
701
|
+
// Try the next candidate.
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
throw new Error('Could not load authorityKeyringRootV1. Build packages/core first.');
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function loadComposition() {
|
|
708
|
+
const candidates = [
|
|
709
|
+
path.resolve(__dirname, '../../core/dist/composition/index.js'),
|
|
710
|
+
'@knolo/core/composition',
|
|
711
|
+
];
|
|
712
|
+
for (const candidate of candidates) {
|
|
713
|
+
try {
|
|
714
|
+
const href = candidate.startsWith('@') ? candidate : pathToFileURL(candidate).href;
|
|
715
|
+
const loaded = await import(href);
|
|
716
|
+
if (loaded.createCompositionRecord && loaded.verifyCompositionRecord && loaded.karCompositionBinder) return loaded;
|
|
717
|
+
} catch {
|
|
718
|
+
// Try the next candidate.
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
throw new Error('Could not load @knolo/core/composition. Build packages/core first.');
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
async function loadAuthoring() {
|
|
725
|
+
const candidates = [
|
|
726
|
+
path.resolve(__dirname, '../../core/dist/experimental/kar/authoring/index.js'),
|
|
727
|
+
'@knolo/core/experimental/kar/authoring',
|
|
728
|
+
];
|
|
729
|
+
for (const candidate of candidates) {
|
|
730
|
+
try {
|
|
731
|
+
const href = candidate.startsWith('@') ? candidate : pathToFileURL(candidate).href;
|
|
732
|
+
const loaded = await import(href);
|
|
733
|
+
if (loaded.domainPackRoot && loaded.parseAuthoringDocument && loaded.interpretDomainPack) return loaded;
|
|
734
|
+
} catch {
|
|
735
|
+
// Try the next candidate.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
throw new Error('Could not load @knolo/core/kar/authoring. Build packages/core first.');
|
|
739
|
+
}
|
package/bin/knolo.mjs
CHANGED
|
@@ -73,6 +73,9 @@ const SUBCOMMANDS = new Set([
|
|
|
73
73
|
'semantic:inspect',
|
|
74
74
|
'semantic:validate',
|
|
75
75
|
'kar',
|
|
76
|
+
'compose',
|
|
77
|
+
'attest',
|
|
78
|
+
'verify-attestation',
|
|
76
79
|
]);
|
|
77
80
|
const ICP_SUBCOMMANDS = new Set([
|
|
78
81
|
'init',
|
|
@@ -168,6 +171,8 @@ Commands:
|
|
|
168
171
|
dev Watch config/sources and rebuild on change
|
|
169
172
|
v5 Inspect, compress, decompress, and health-check a V5 Knowledge Image
|
|
170
173
|
kar KAR evidence-set retrieval over a Committed Evidence Graph
|
|
174
|
+
compose Compose Knowledge Images, packs, and attestations offline
|
|
175
|
+
attest Create or verify a composition attestation
|
|
171
176
|
icp Scaffold and operate an ICP-native knowledge canister
|
|
172
177
|
|
|
173
178
|
Global options:
|
|
@@ -204,6 +209,9 @@ function printCommandHelp(command) {
|
|
|
204
209
|
dev: 'Usage: knolo dev',
|
|
205
210
|
v5: 'Usage: knolo v5 <info|health|studio|query|compress|decompress> <image.v5> [--out <file>] [--mode fast|balanced|max] [--attach-index]\n\nFor query: knolo v5 query <image.v5> <EQL> [--json] [--receipt <file>].',
|
|
206
211
|
kar: 'Usage: knolo kar <evaluate|verify|explain|inspect|graph|package|produce|domain|dossier|coverage|benchmark|demo|conformance> [options]\n\nKnolo Adversarial Retrieval. Semantics are frozen at kar-1-research-1.\n\n knolo kar evaluate --image knowledge.knolo --graph knowledge.kar.json --plan plan.json --query "..."\n knolo kar verify --image knowledge.knolo --graph knowledge.kar.json --plan plan.json --query "..." --result result.json\n knolo kar explain --result result.json\n knolo kar inspect --image knowledge.knolo --graph knowledge.kar.json [--plan plan.json]\n knolo kar graph build --image knowledge.knolo --source knowledge.ceg.yaml --out knowledge.kar.json\n knolo kar package --image knowledge.knolo --graph knowledge.kar.json --out dist/my-knowledge-kar/\n knolo kar produce rules --image knowledge.knolo --domain domains/contracts-v4 --out proposals.json\n knolo kar domain validate domains/contracts-v4\n knolo kar dossier build --image knowledge.knolo --graph knowledge.kar.json --plan plan.json --query "..." --out dossier/\n knolo kar dossier verify dossier/\n knolo kar coverage build --image knowledge.knolo --graph knowledge.kar.json --plan plan.json --query "..." --out coverage/\n knolo kar coverage verify coverage/\n knolo kar benchmark annotate --packet packet.json --decisions decisions.json --out labels.json --role human --reviewer-id <id>\n knolo kar demo\n knolo kar conformance\n\n--json prints the machine-readable result. verify exits non-zero when the certificate does not match. Abstention is a result and exits 0. graph and package author a Committed Evidence Graph without changing retrieval. produce proposes CEG Source and does not write committed bytes. dossier packages one evaluation and fails closed when committed fields change. coverage reports met requirements, gaps, and plans that did not decide. A gap is not a false claim. benchmark labels gold without showing producer output. demo and conformance run from a knolo-core checkout.',
|
|
212
|
+
compose: 'Usage: knolo compose <create|verify|excerpt|query> [options]\n\nOffline composition of Knowledge Images, packs, KAR sidecars, Domain Packs, and bounded excerpts.\n\n knolo compose create --members members.json --out composition/ [--host knowledge.knolo] [--availability material|digest] [--policy policy.json]\n knolo compose verify composition/ [--require-material] [--member alias=path]\n knolo compose excerpt --image knowledge.knolo --object <id> --out excerpt.json\n knolo compose query --composition composition/ --eql "FROM chunk SEARCH \\"valve\\"" [--question "..."] [--json]\n\ncompositionRoot does not change a V5 state root or a KAR root. statement used means a query or evaluation ran. It does not mean SATISFIED, and it does not mean a claim is true or false. Hub attested: true is a listing flag and is not an attestation root. The CLI does not fetch Hub.',
|
|
213
|
+
attest: 'Usage: knolo attest <create|verify> [options]\n\n knolo attest create --composition composition/composition.json --subject composition --statement mount|used|cited --out attestation.json [--sign-key key.json --principal <id> --key-id <id> --issued-at <ms> --expires-at <ms>]\n knolo attest verify attestation.json [--composition composition.json] [--public-key key.json] [--now <ms>] [--content-only]\n\nstatement used means a query or KAR evaluation ran. It does not mean the claim is true or false. Hub attested: true is unrelated. Signing uses the supplied times. The CLI does not read the clock.',
|
|
214
|
+
'verify-attestation': 'Usage: knolo verify-attestation <attestation.json> [--composition composition.json] [--public-key key.json] [--keyring keyring.json] [--now <ms>] [--content-only]\n\nSame check as knolo attest verify. A bad signature exits 1 even when the content root matches. --content-only checks the content root and ignores the envelope. statement used is not a truth verdict. Hub attested: true is unrelated.',
|
|
207
215
|
icp: 'Usage: knolo icp <command> [options]',
|
|
208
216
|
'semantic:index':
|
|
209
217
|
'Usage: knolo semantic:index --pack <path> [--out <path>] [--model <id>] [--endpoint <url>]',
|
|
@@ -1853,6 +1861,10 @@ async function main() {
|
|
|
1853
1861
|
const { runKarCli } = await import('./kar.mjs');
|
|
1854
1862
|
return runKarCli(commandArgs);
|
|
1855
1863
|
}
|
|
1864
|
+
if (command === 'compose' || command === 'attest' || command === 'verify-attestation') {
|
|
1865
|
+
const { runComposeCli } = await import('./compose.mjs');
|
|
1866
|
+
return runComposeCli(command, commandArgs);
|
|
1867
|
+
}
|
|
1856
1868
|
if (command === 'init') return await cmdInit();
|
|
1857
1869
|
if (command === 'add') return await cmdAdd(commandArgs);
|
|
1858
1870
|
if (command === 'search') return await runHubSearch(commandArgs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knolo/cli",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"knolo": "bin/knolo.mjs"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"bin/",
|
|
10
10
|
"templates/"
|
|
11
11
|
],
|
|
12
|
-
"description": "CLI for Knolo V4 packs, V5 Knowledge Images,
|
|
12
|
+
"description": "CLI for Knolo V4 packs, V5 Knowledge Images, KAR evidence-set retrieval, and offline composition.",
|
|
13
13
|
"license": "Apache-2.0",
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
},
|
|
29
29
|
"scripts": {
|
|
30
30
|
"build": "node -e \"console.log('cli is executable via bin/knolo.mjs')\"",
|
|
31
|
-
"test": "npm run build --prefix ../core && node --test test/cli.test.mjs test/kar.test.mjs test/kar-graph.test.mjs test/kar-produce.test.mjs test/hub.integration.test.mjs",
|
|
31
|
+
"test": "npm run build --prefix ../core && node --test test/cli.test.mjs test/kar.test.mjs test/kar-graph.test.mjs test/kar-produce.test.mjs test/hub.integration.test.mjs test/compose.test.mjs",
|
|
32
32
|
"test:hub": "node --test test/hub.integration.test.mjs"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@knolo/core": "^6.
|
|
35
|
+
"@knolo/core": "^6.1.0"
|
|
36
36
|
},
|
|
37
37
|
"private": false
|
|
38
38
|
}
|