@aikdna/kdna-core 0.7.1 → 0.8.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/package.json +5 -1
- package/src/asset-reader.js +90 -3
- package/src/crypto-profile.js +185 -0
- package/src/index.js +2 -0
- package/src/workpack-pure.js +254 -0
- package/schema/Composition_Policy.schema.json +0 -130
- package/schema/KDNA_Cases.schema.json +0 -77
- package/schema/KDNA_Cluster.schema.json +0 -132
- package/schema/KDNA_Core.schema.json +0 -286
- package/schema/KDNA_Core.strict.schema.json +0 -290
- package/schema/KDNA_Evolution.schema.json +0 -129
- package/schema/KDNA_Patterns.schema.json +0 -338
- package/schema/KDNA_Patterns.strict.schema.json +0 -342
- package/schema/KDNA_Reasoning.schema.json +0 -76
- package/schema/KDNA_Scenarios.schema.json +0 -112
- package/schema/KDNA_Scenarios.strict.schema.json +0 -101
- package/schema/eval.schema.json +0 -58
- package/schema/kdna-file.schema.json +0 -272
- package/schema/kdna-manifest-v1rc.json +0 -346
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikdna/kdna-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "KDNA core library — pure logic for loading, validating, linting, and rendering KDNA domain cognition packages. Zero Node.js dependencies.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -47,5 +47,9 @@
|
|
|
47
47
|
"ajv-formats": {
|
|
48
48
|
"optional": true
|
|
49
49
|
}
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@noble/hashes": "^2.2.0",
|
|
53
|
+
"cbor-x": "^1.6.4"
|
|
50
54
|
}
|
|
51
55
|
}
|
package/src/asset-reader.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* KDNA Asset Reader — direct .kdna container access.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* assets without persistent extraction to a domain directory.
|
|
4
|
+
* Supports KDNA Container v2 (payload.kdnab via CBOR).
|
|
5
|
+
* V1 plaintext containers (KDNA_Core.json etc. as ZIP entries) are rejected.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
const fs = require('fs');
|
|
10
9
|
const crypto = require('crypto');
|
|
11
10
|
const zlib = require('zlib');
|
|
11
|
+
const cbor = require('cbor-x');
|
|
12
12
|
const { loadDomainFromFiles, formatContext } = require('./loader');
|
|
13
13
|
|
|
14
14
|
const STANDARD_ENTRIES = [
|
|
15
15
|
'kdna.json',
|
|
16
|
+
'payload.kdnab',
|
|
17
|
+
'KDNA_Core.json',
|
|
18
|
+
'KDNA_Patterns.json',
|
|
19
|
+
'KDNA_Scenarios.json',
|
|
20
|
+
'KDNA_Cases.json',
|
|
21
|
+
'KDNA_Reasoning.json',
|
|
22
|
+
'KDNA_Evolution.json',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const V1_ENTRIES = [
|
|
16
26
|
'KDNA_Core.json',
|
|
17
27
|
'KDNA_Patterns.json',
|
|
18
28
|
'KDNA_Scenarios.json',
|
|
@@ -173,6 +183,11 @@ function manifestForDigest(manifest) {
|
|
|
173
183
|
delete copy.container_sha256;
|
|
174
184
|
delete copy.content_digest;
|
|
175
185
|
delete copy._source;
|
|
186
|
+
if (copy.authoring && typeof copy.authoring === 'object') {
|
|
187
|
+
const auth = { ...copy.authoring };
|
|
188
|
+
delete auth.content_digest;
|
|
189
|
+
copy.authoring = auth;
|
|
190
|
+
}
|
|
176
191
|
return copy;
|
|
177
192
|
}
|
|
178
193
|
|
|
@@ -269,6 +284,53 @@ function verifyMediaType(asset, errors) {
|
|
|
269
284
|
}
|
|
270
285
|
}
|
|
271
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Verify Human Lock signatures on locked axiom cards.
|
|
289
|
+
* Reconstructs signing payload (cardId|statement|fingerprint) and verifies
|
|
290
|
+
* against the creator's public key from kdna.json manifest.author.
|
|
291
|
+
*/
|
|
292
|
+
function verifyHumanLockSignatures(coreData, manifest, errors, warnings) {
|
|
293
|
+
const publicKeyPEM = manifest?.author?.public_key_pem;
|
|
294
|
+
if (!publicKeyPEM) {
|
|
295
|
+
// Skip if no public key available (unsigned assets are valid)
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const axioms = coreData?.axioms || [];
|
|
299
|
+
if (!Array.isArray(axioms) || axioms.length === 0) return;
|
|
300
|
+
|
|
301
|
+
let verified = 0, missing = 0, invalid = 0;
|
|
302
|
+
for (const ax of axioms) {
|
|
303
|
+
const hl = ax.human_lock;
|
|
304
|
+
if (!hl || !hl.signature) {
|
|
305
|
+
// Only warn for locked cards without signature
|
|
306
|
+
if (ax.status === 'locked' || ax.status === 'tested' || ax.status === 'published') {
|
|
307
|
+
missing++;
|
|
308
|
+
}
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const payload = [ax.id, hl.statement || '', hl.judgment_fingerprint || ''].join('\n');
|
|
313
|
+
const sig = Buffer.from(String(hl.signature).replace(/^ed25519:/, ''), 'hex');
|
|
314
|
+
const key = crypto.createPublicKey(publicKeyPEM);
|
|
315
|
+
const ok = crypto.verify(null, Buffer.from(payload), key, sig);
|
|
316
|
+
if (ok) { verified++; }
|
|
317
|
+
else { invalid++; }
|
|
318
|
+
} catch {
|
|
319
|
+
invalid++;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (invalid > 0) {
|
|
324
|
+
errors.push(`${invalid} Human Lock signature(s) failed verification — judgment may have been altered`);
|
|
325
|
+
}
|
|
326
|
+
if (missing > 0) {
|
|
327
|
+
warnings.push(`${missing} locked card(s) have no Human Lock signature`);
|
|
328
|
+
}
|
|
329
|
+
if (verified > 0 && invalid === 0) {
|
|
330
|
+
// All signed locks verified — good
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
272
334
|
function validateManifestIdentity(manifest, errors, _warnings) {
|
|
273
335
|
if (manifest.kdna_spec) {
|
|
274
336
|
errors.push('kdna.json: kdna_spec is not allowed. Use spec_version.');
|
|
@@ -321,6 +383,22 @@ function readManifest(asset) {
|
|
|
321
383
|
function readDataMapSync(asset, entries = STANDARD_ENTRIES, options = {}) {
|
|
322
384
|
const dataMap = {};
|
|
323
385
|
const manifest = readManifest(asset);
|
|
386
|
+
|
|
387
|
+
// ── KDNA Container v2: decode CBOR payload ──
|
|
388
|
+
if (asset.entries.has('payload.kdnab')) {
|
|
389
|
+
const payloadBuf = asset.readEntry('payload.kdnab');
|
|
390
|
+
const payload = cbor.decode(payloadBuf);
|
|
391
|
+
if (payload && payload.judgment) {
|
|
392
|
+
if (payload.judgment.core) dataMap['KDNA_Core.json'] = payload.judgment.core;
|
|
393
|
+
if (payload.judgment.patterns) dataMap['KDNA_Patterns.json'] = payload.judgment.patterns;
|
|
394
|
+
if (payload.judgment.scenarios) dataMap['KDNA_Scenarios.json'] = payload.judgment.scenarios;
|
|
395
|
+
if (payload.judgment.cases) dataMap['KDNA_Cases.json'] = payload.judgment.cases;
|
|
396
|
+
if (payload.judgment.reasoning) dataMap['KDNA_Reasoning.json'] = payload.judgment.reasoning;
|
|
397
|
+
if (payload.judgment.evolution) dataMap['KDNA_Evolution.json'] = payload.judgment.evolution;
|
|
398
|
+
}
|
|
399
|
+
return dataMap;
|
|
400
|
+
}
|
|
401
|
+
|
|
324
402
|
const encrypted = encryptedEntries(manifest).filter((entryName) => entries.includes(entryName));
|
|
325
403
|
if (encrypted.length && typeof options.decryptEntry !== 'function') {
|
|
326
404
|
throw new Error(`encrypted entries require decryptEntry hook: ${encrypted.join(', ')}`);
|
|
@@ -390,6 +468,15 @@ function verifySync(asset, options = {}) {
|
|
|
390
468
|
if (options.requireSignature || manifest.signature) {
|
|
391
469
|
signature_valid = verifySignature(asset, manifest, errors, warnings);
|
|
392
470
|
}
|
|
471
|
+
// ── Human Lock signature verification ──────────────────
|
|
472
|
+
if (asset.entries.has('KDNA_Core.json')) {
|
|
473
|
+
try {
|
|
474
|
+
const coreData = parseJson(asset.readEntry('KDNA_Core.json'), 'KDNA_Core.json');
|
|
475
|
+
verifyHumanLockSignatures(coreData, manifest, errors, warnings);
|
|
476
|
+
} catch (e) {
|
|
477
|
+
warnings.push(`Human Lock signature check skipped: ${e.message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
393
480
|
} catch (e) {
|
|
394
481
|
errors.push(e.message);
|
|
395
482
|
}
|
package/src/crypto-profile.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
|
|
3
|
+
let argon2id;
|
|
4
|
+
try {
|
|
5
|
+
({ argon2id } = require('@noble/hashes/argon2.js'));
|
|
6
|
+
} catch {
|
|
7
|
+
// Optional: password-protected assets require @noble/hashes
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
// ── Profile constants ──────────────────────────────────────────────
|
|
4
11
|
|
|
5
12
|
/**
|
|
@@ -18,9 +25,19 @@ const LICENSED_ENTRY_PROFILE = 'kdna-licensed-entry-v1';
|
|
|
18
25
|
*/
|
|
19
26
|
const LICENSED_EXPERIMENTAL_PROFILE = 'kdna-licensed-entry-experimental';
|
|
20
27
|
|
|
28
|
+
/**
|
|
29
|
+
* RFC-0009 compliant profile.
|
|
30
|
+
* - Argon2id password-based key derivation
|
|
31
|
+
* - AES-256-KW content encryption key wrapping
|
|
32
|
+
* - AES-256-GCM content encryption
|
|
33
|
+
* - Dual key slots: password + recovery
|
|
34
|
+
*/
|
|
35
|
+
const PASSWORD_PROTECTED_PROFILE = 'kdna-password-protected-v1';
|
|
36
|
+
|
|
21
37
|
const RFC_KDF = 'HKDF-SHA256';
|
|
22
38
|
const RFC_KEY_WRAPPING = 'AES-256-KW';
|
|
23
39
|
const LEGACY_KDF = 'scrypt-sha256';
|
|
40
|
+
const PASSWORD_KDF = 'Argon2id';
|
|
24
41
|
const ALG = 'AES-256-GCM';
|
|
25
42
|
|
|
26
43
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
@@ -246,6 +263,163 @@ function decryptLicensedEntryV1(envelopeValue, options = {}) {
|
|
|
246
263
|
]);
|
|
247
264
|
}
|
|
248
265
|
|
|
266
|
+
// ── RFC-0009: Password-protected encryption ───────────────────────
|
|
267
|
+
|
|
268
|
+
function ensureArgon2id() {
|
|
269
|
+
if (!argon2id) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
'password-protected assets require @noble/hashes. Install: npm install @noble/hashes',
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function derivePasswordKey(password, params = {}) {
|
|
277
|
+
ensureArgon2id();
|
|
278
|
+
const {
|
|
279
|
+
salt,
|
|
280
|
+
memory_kib = 65536,
|
|
281
|
+
iterations = 3,
|
|
282
|
+
parallelism = 4,
|
|
283
|
+
} = params;
|
|
284
|
+
if (!salt) throw new Error('salt is required for Argon2id');
|
|
285
|
+
const saltBuf = decodeBase64(salt, 'salt');
|
|
286
|
+
const passwordBuf = toBuffer(password, 'password');
|
|
287
|
+
const key = argon2id(passwordBuf, saltBuf, {
|
|
288
|
+
t: iterations,
|
|
289
|
+
m: memory_kib,
|
|
290
|
+
p: parallelism,
|
|
291
|
+
dkLen: 32,
|
|
292
|
+
});
|
|
293
|
+
return Buffer.from(key);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function generateRecoveryCode() {
|
|
297
|
+
const raw = crypto.randomBytes(32); // 256 bits
|
|
298
|
+
const hex = raw.toString('hex').toUpperCase();
|
|
299
|
+
const groups = hex.match(/.{4}/g);
|
|
300
|
+
return `kdna-recover-${groups.join('-')}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function decodeRecoveryCode(code) {
|
|
304
|
+
if (typeof code !== 'string' || !code.startsWith('kdna-recover-')) {
|
|
305
|
+
throw new Error('recovery code must start with "kdna-recover-"');
|
|
306
|
+
}
|
|
307
|
+
const hex = code.slice('kdna-recover-'.length).replace(/-/g, '');
|
|
308
|
+
if (!/^[0-9A-Fa-f]{64}$/.test(hex)) {
|
|
309
|
+
throw new Error('recovery code format is invalid');
|
|
310
|
+
}
|
|
311
|
+
return Buffer.from(hex, 'hex');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function encryptProtectedEntry(plaintext, options = {}) {
|
|
315
|
+
const { entryName, manifest = {}, password, includeRecovery = true, recoveryCode } = options;
|
|
316
|
+
if (!entryName) throw new Error('entryName is required');
|
|
317
|
+
if (!password) throw new Error('password is required for protected encryption');
|
|
318
|
+
|
|
319
|
+
const cek = generateCEK();
|
|
320
|
+
|
|
321
|
+
// Password slot
|
|
322
|
+
const salt = crypto.randomBytes(16);
|
|
323
|
+
const passwordKdf = {
|
|
324
|
+
name: PASSWORD_KDF,
|
|
325
|
+
salt: salt.toString('base64'),
|
|
326
|
+
memory_kib: 65536,
|
|
327
|
+
iterations: 3,
|
|
328
|
+
parallelism: 4,
|
|
329
|
+
};
|
|
330
|
+
const passwordKey = derivePasswordKey(password, passwordKdf);
|
|
331
|
+
const passwordWrappedKey = wrapCEK(cek, passwordKey);
|
|
332
|
+
|
|
333
|
+
const keySlots = [
|
|
334
|
+
{
|
|
335
|
+
slot: 'password',
|
|
336
|
+
wrap: RFC_KEY_WRAPPING,
|
|
337
|
+
wrapped_key: passwordWrappedKey.toString('base64'),
|
|
338
|
+
},
|
|
339
|
+
];
|
|
340
|
+
|
|
341
|
+
// Recovery slot
|
|
342
|
+
if (includeRecovery) {
|
|
343
|
+
const recoveryKey = recoveryCode ? decodeRecoveryCode(recoveryCode) : crypto.randomBytes(32);
|
|
344
|
+
const recoveryWrappedKey = wrapCEK(cek, recoveryKey);
|
|
345
|
+
keySlots.push({
|
|
346
|
+
slot: 'recovery',
|
|
347
|
+
wrap: RFC_KEY_WRAPPING,
|
|
348
|
+
wrapped_key: recoveryWrappedKey.toString('base64'),
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const iv = crypto.randomBytes(12);
|
|
353
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', cek, iv);
|
|
354
|
+
cipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_PROFILE));
|
|
355
|
+
const ciphertext = Buffer.concat([cipher.update(toBuffer(plaintext, 'plaintext')), cipher.final()]);
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
profile: PASSWORD_PROTECTED_PROFILE,
|
|
359
|
+
alg: ALG,
|
|
360
|
+
kdf: PASSWORD_KDF,
|
|
361
|
+
key_wrapping: RFC_KEY_WRAPPING,
|
|
362
|
+
password_kdf: passwordKdf,
|
|
363
|
+
key_slots: keySlots,
|
|
364
|
+
iv: iv.toString('base64'),
|
|
365
|
+
tag: cipher.getAuthTag().toString('base64'),
|
|
366
|
+
ciphertext: ciphertext.toString('base64'),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function decryptProtectedEntry(envelopeValue, options = {}) {
|
|
371
|
+
const { entryName, manifest = {}, password, recoveryCode } = options;
|
|
372
|
+
if (!entryName) throw new Error('entryName is required');
|
|
373
|
+
if (!password && !recoveryCode) {
|
|
374
|
+
throw new Error('password or recoveryCode is required for protected decryption');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const envelope = normalizeEnvelope(envelopeValue);
|
|
378
|
+
if (envelope.profile !== PASSWORD_PROTECTED_PROFILE) {
|
|
379
|
+
throw new Error(
|
|
380
|
+
`unsupported encrypted entry profile: ${envelope.profile || 'unknown'} (expected ${PASSWORD_PROTECTED_PROFILE})`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (envelope.alg !== ALG) throw new Error(`unsupported encrypted entry alg: ${envelope.alg}`);
|
|
384
|
+
if (envelope.kdf !== PASSWORD_KDF) throw new Error(`unsupported encrypted entry kdf: ${envelope.kdf}`);
|
|
385
|
+
if (envelope.key_wrapping !== RFC_KEY_WRAPPING) {
|
|
386
|
+
throw new Error(`unsupported encrypted entry key_wrapping: ${envelope.key_wrapping}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
let cek;
|
|
390
|
+
if (password) {
|
|
391
|
+
const passwordKey = derivePasswordKey(password, envelope.password_kdf);
|
|
392
|
+
const passwordSlot = envelope.key_slots.find((s) => s.slot === 'password');
|
|
393
|
+
if (!passwordSlot) throw new Error('password slot missing from envelope');
|
|
394
|
+
cek = unwrapCEK(decodeBase64(passwordSlot.wrapped_key, 'wrapped_key'), passwordKey);
|
|
395
|
+
} else {
|
|
396
|
+
const recoveryKey = decodeRecoveryCode(recoveryCode);
|
|
397
|
+
const recoverySlot = envelope.key_slots.find((s) => s.slot === 'recovery');
|
|
398
|
+
if (!recoverySlot) throw new Error('recovery slot missing from envelope');
|
|
399
|
+
cek = unwrapCEK(decodeBase64(recoverySlot.wrapped_key, 'wrapped_key'), recoveryKey);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', cek, decodeBase64(envelope.iv, 'iv'));
|
|
403
|
+
decipher.setAAD(encryptedEntryAad(entryName, manifest, PASSWORD_PROTECTED_PROFILE));
|
|
404
|
+
decipher.setAuthTag(decodeBase64(envelope.tag, 'tag'));
|
|
405
|
+
return Buffer.concat([
|
|
406
|
+
decipher.update(decodeBase64(envelope.ciphertext, 'ciphertext')),
|
|
407
|
+
decipher.final(),
|
|
408
|
+
]);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function createPasswordDecryptEntry(options = {}) {
|
|
412
|
+
const { password } = options;
|
|
413
|
+
return ({ entryName, ciphertext, manifest }) =>
|
|
414
|
+
decryptProtectedEntry(ciphertext, { entryName, manifest, password });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function createRecoveryDecryptEntry(options = {}) {
|
|
418
|
+
const { recoveryCode } = options;
|
|
419
|
+
return ({ entryName, ciphertext, manifest }) =>
|
|
420
|
+
decryptProtectedEntry(ciphertext, { entryName, manifest, recoveryCode });
|
|
421
|
+
}
|
|
422
|
+
|
|
249
423
|
// ── Legacy / experimental profile (pre-RFC, backward compat) ───────
|
|
250
424
|
|
|
251
425
|
function deriveLicensedEntryKeyLegacy(options = {}) {
|
|
@@ -329,10 +503,12 @@ module.exports = {
|
|
|
329
503
|
// Profiles
|
|
330
504
|
LICENSED_ENTRY_PROFILE,
|
|
331
505
|
LICENSED_EXPERIMENTAL_PROFILE,
|
|
506
|
+
PASSWORD_PROTECTED_PROFILE,
|
|
332
507
|
ALG,
|
|
333
508
|
RFC_KDF,
|
|
334
509
|
RFC_KEY_WRAPPING,
|
|
335
510
|
LEGACY_KDF,
|
|
511
|
+
PASSWORD_KDF,
|
|
336
512
|
|
|
337
513
|
// RFC-0008 compliant
|
|
338
514
|
deriveWrappingKey,
|
|
@@ -342,6 +518,15 @@ module.exports = {
|
|
|
342
518
|
encryptLicensedEntryV1,
|
|
343
519
|
decryptLicensedEntryV1,
|
|
344
520
|
|
|
521
|
+
// RFC-0009 compliant
|
|
522
|
+
derivePasswordKey,
|
|
523
|
+
generateRecoveryCode,
|
|
524
|
+
decodeRecoveryCode,
|
|
525
|
+
encryptProtectedEntry,
|
|
526
|
+
decryptProtectedEntry,
|
|
527
|
+
createPasswordDecryptEntry,
|
|
528
|
+
createRecoveryDecryptEntry,
|
|
529
|
+
|
|
345
530
|
// Legacy
|
|
346
531
|
deriveLicensedEntryKey: deriveLicensedEntryKeyLegacy,
|
|
347
532
|
encryptLicensedEntryLegacy,
|
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ const compose = require('./compose');
|
|
|
9
9
|
const assetReader = require('./asset-reader');
|
|
10
10
|
const cryptoProfile = require('./crypto-profile');
|
|
11
11
|
const publicApi = require('./public-api');
|
|
12
|
+
const workpackPure = require('./workpack-pure');
|
|
12
13
|
|
|
13
14
|
module.exports = {
|
|
14
15
|
...publicApi,
|
|
@@ -19,4 +20,5 @@ module.exports = {
|
|
|
19
20
|
...compose,
|
|
20
21
|
...assetReader,
|
|
21
22
|
...cryptoProfile,
|
|
23
|
+
...workpackPure,
|
|
22
24
|
};
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @aikdna/kdna-core — Work Pack validation (pure logic)
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency pure functions for validating KDNA Work Pack
|
|
5
|
+
* manifests against the Work Pack schema. Does not depend on ajv
|
|
6
|
+
* at source level — the validator is injected.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
// ── Embedded Work Pack Schema v0.1 ──────────────────────────────────
|
|
13
|
+
// Self-contained schema with resolved $refs for zero-dependency validation.
|
|
14
|
+
|
|
15
|
+
const WORK_PACK_SCHEMA = {
|
|
16
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
17
|
+
$id: 'https://aikdna.com/schemas/work-pack.schema.json',
|
|
18
|
+
title: 'KDNA Work Pack Manifest',
|
|
19
|
+
type: 'object',
|
|
20
|
+
required: ['format', 'format_version', 'name', 'version', 'description', 'status', 'kdna'],
|
|
21
|
+
properties: {
|
|
22
|
+
format: { type: 'string', const: 'kdna-workpack' },
|
|
23
|
+
format_version: { type: 'string', pattern: '^\\d+\\.\\d+$' },
|
|
24
|
+
name: { type: 'string', pattern: '^[a-z0-9]+(-[a-z0-9]+)*$', maxLength: 64 },
|
|
25
|
+
version: { type: 'string', pattern: '^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.]+)?(\\+[a-zA-Z0-9.]+)?$' },
|
|
26
|
+
description: { type: 'string', maxLength: 280 },
|
|
27
|
+
status: { type: 'string', enum: ['draft', 'experimental', 'stable', 'deprecated'] },
|
|
28
|
+
access: { type: 'string', enum: ['open', 'licensed', 'runtime', 'enterprise', 'partner'], default: 'open' },
|
|
29
|
+
license: { type: 'string', default: 'Apache-2.0' },
|
|
30
|
+
kdna: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
oneOf: [
|
|
33
|
+
{
|
|
34
|
+
required: ['mode', 'asset'],
|
|
35
|
+
properties: {
|
|
36
|
+
mode: { const: 'single' },
|
|
37
|
+
asset: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
required: ['name', 'version', 'role'],
|
|
40
|
+
properties: {
|
|
41
|
+
name: { type: 'string', pattern: '^[a-z0-9_]+$' },
|
|
42
|
+
version: { type: 'string' },
|
|
43
|
+
digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' },
|
|
44
|
+
role: { type: 'string', enum: ['primary', 'constraint', 'fallback'] },
|
|
45
|
+
},
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
required: ['mode', 'assets'],
|
|
52
|
+
properties: {
|
|
53
|
+
mode: { const: 'cluster' },
|
|
54
|
+
assets: {
|
|
55
|
+
type: 'array',
|
|
56
|
+
minItems: 2,
|
|
57
|
+
items: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
required: ['name', 'version', 'role'],
|
|
60
|
+
properties: {
|
|
61
|
+
name: { type: 'string', pattern: '^[a-z0-9_]+$' },
|
|
62
|
+
version: { type: 'string' },
|
|
63
|
+
digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' },
|
|
64
|
+
role: { type: 'string', enum: ['primary', 'constraint', 'fallback'] },
|
|
65
|
+
},
|
|
66
|
+
additionalProperties: false,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
skills: {
|
|
74
|
+
type: 'array',
|
|
75
|
+
default: [],
|
|
76
|
+
items: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
required: ['name'],
|
|
79
|
+
properties: {
|
|
80
|
+
name: { type: 'string', pattern: '^[a-z0-9]+([_-][a-z0-9]+)*$', maxLength: 64 },
|
|
81
|
+
type: { type: 'string' },
|
|
82
|
+
required: { type: 'boolean', default: true },
|
|
83
|
+
mcp_server: { type: ['string', 'null'], default: null },
|
|
84
|
+
fallback: { type: ['string', 'null'], default: null },
|
|
85
|
+
},
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
templates: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
default: {},
|
|
92
|
+
properties: {
|
|
93
|
+
task: { type: 'string' },
|
|
94
|
+
output: { type: 'string' },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
review_gates: {
|
|
98
|
+
type: 'array',
|
|
99
|
+
default: [],
|
|
100
|
+
items: { type: 'string' },
|
|
101
|
+
},
|
|
102
|
+
risk_policy: { type: 'string' },
|
|
103
|
+
trace_policy: { type: 'string' },
|
|
104
|
+
evals: { type: 'string' },
|
|
105
|
+
},
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// ── Public API ─────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validate a Work Pack manifest against the schema.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} manifest — parsed workpack.json content
|
|
115
|
+
* @param {object} [opts]
|
|
116
|
+
* @param {object} [opts.ajv] — optional pre-configured Ajv instance
|
|
117
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
118
|
+
*/
|
|
119
|
+
function validateWorkPackManifest(manifest, opts = {}) {
|
|
120
|
+
const Ajv = opts.ajv || _lazyAjv();
|
|
121
|
+
let validate;
|
|
122
|
+
try {
|
|
123
|
+
validate = Ajv.compile(WORK_PACK_SCHEMA);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { valid: false, errors: [`Schema compilation error: ${e.message}`] };
|
|
126
|
+
}
|
|
127
|
+
const ok = validate(manifest);
|
|
128
|
+
if (ok) return { valid: true, errors: [] };
|
|
129
|
+
const errors = (validate.errors || []).map(
|
|
130
|
+
(e) => `${e.instancePath || '/'}: ${e.message}`
|
|
131
|
+
);
|
|
132
|
+
return { valid: false, errors };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Check structural completeness — verify all referenced files exist.
|
|
137
|
+
*
|
|
138
|
+
* @param {object} manifest — parsed workpack.json
|
|
139
|
+
* @param {string} rootDir — directory containing workpack.json
|
|
140
|
+
* @returns {{ complete: boolean, missing: string[] }}
|
|
141
|
+
*/
|
|
142
|
+
function checkWorkPackStructure(manifest, rootDir) {
|
|
143
|
+
const missing = [];
|
|
144
|
+
const refs = [];
|
|
145
|
+
|
|
146
|
+
if (manifest.templates) {
|
|
147
|
+
if (manifest.templates.task) refs.push(manifest.templates.task);
|
|
148
|
+
if (manifest.templates.output) refs.push(manifest.templates.output);
|
|
149
|
+
}
|
|
150
|
+
if (manifest.review_gates) refs.push(...manifest.review_gates);
|
|
151
|
+
if (manifest.risk_policy) refs.push(manifest.risk_policy);
|
|
152
|
+
if (manifest.trace_policy) refs.push(manifest.trace_policy);
|
|
153
|
+
if (manifest.evals) refs.push(manifest.evals);
|
|
154
|
+
|
|
155
|
+
for (const ref of refs) {
|
|
156
|
+
const fullPath = path.resolve(rootDir, ref);
|
|
157
|
+
if (!fs.existsSync(fullPath)) missing.push(ref);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return { complete: missing.length === 0, missing };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Inspect a Work Pack — return a structured summary.
|
|
165
|
+
*
|
|
166
|
+
* @param {object} manifest — parsed workpack.json
|
|
167
|
+
* @param {string} rootDir — directory containing workpack.json
|
|
168
|
+
* @returns {object}
|
|
169
|
+
*/
|
|
170
|
+
function inspectWorkPack(manifest, rootDir) {
|
|
171
|
+
const kdnaMode = manifest.kdna?.mode || 'unknown';
|
|
172
|
+
const kdnaCount =
|
|
173
|
+
kdnaMode === 'single' ? 1 : (manifest.kdna?.assets || []).length;
|
|
174
|
+
|
|
175
|
+
const structure = checkWorkPackStructure(manifest, rootDir);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
name: manifest.name,
|
|
179
|
+
version: manifest.version,
|
|
180
|
+
description: manifest.description,
|
|
181
|
+
status: manifest.status,
|
|
182
|
+
access: manifest.access || 'open',
|
|
183
|
+
license: manifest.license || 'Apache-2.0',
|
|
184
|
+
format_version: manifest.format_version,
|
|
185
|
+
kdna: {
|
|
186
|
+
mode: kdnaMode,
|
|
187
|
+
assets: kdnaMode === 'single'
|
|
188
|
+
? [{ name: manifest.kdna.asset.name, version: manifest.kdna.asset.version, role: manifest.kdna.asset.role }]
|
|
189
|
+
: (manifest.kdna?.assets || []).map(a => ({ name: a.name, version: a.version, role: a.role })),
|
|
190
|
+
},
|
|
191
|
+
skills: (manifest.skills || []).map(s => ({
|
|
192
|
+
name: s.name,
|
|
193
|
+
type: s.type || 'unspecified',
|
|
194
|
+
required: s.required !== false,
|
|
195
|
+
fallback: s.fallback || null,
|
|
196
|
+
})),
|
|
197
|
+
templates: manifest.templates
|
|
198
|
+
? { task: manifest.templates.task || null, output: manifest.templates.output || null }
|
|
199
|
+
: null,
|
|
200
|
+
review_gates: (manifest.review_gates || []).length,
|
|
201
|
+
has_risk_policy: !!manifest.risk_policy,
|
|
202
|
+
has_trace_policy: !!manifest.trace_policy,
|
|
203
|
+
has_evals: !!manifest.evals,
|
|
204
|
+
structural_complete: structure.complete,
|
|
205
|
+
missing_files: structure.missing,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Load a Work Pack from a directory.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} dirPath — path to Work Pack directory
|
|
213
|
+
* @returns {{ manifest: object|null, error: string|null }}
|
|
214
|
+
*/
|
|
215
|
+
function loadWorkPack(dirPath) {
|
|
216
|
+
const wpPath = path.join(dirPath, 'workpack.json');
|
|
217
|
+
if (!fs.existsSync(wpPath)) {
|
|
218
|
+
return { manifest: null, error: `workpack.json not found in ${dirPath}` };
|
|
219
|
+
}
|
|
220
|
+
let manifest;
|
|
221
|
+
try {
|
|
222
|
+
manifest = JSON.parse(fs.readFileSync(wpPath, 'utf8'));
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return { manifest: null, error: `Invalid JSON in workpack.json: ${e.message}` };
|
|
225
|
+
}
|
|
226
|
+
return { manifest, error: null };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── Internal ────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
let _ajvInstance = null;
|
|
232
|
+
|
|
233
|
+
function _lazyAjv() {
|
|
234
|
+
if (_ajvInstance) return _ajvInstance;
|
|
235
|
+
try {
|
|
236
|
+
const Ajv = require('ajv');
|
|
237
|
+
const addFormats = require('ajv-formats');
|
|
238
|
+
_ajvInstance = new Ajv({ allErrors: true, strict: false });
|
|
239
|
+
addFormats(_ajvInstance);
|
|
240
|
+
return _ajvInstance;
|
|
241
|
+
} catch (e) {
|
|
242
|
+
throw new Error(
|
|
243
|
+
'ajv is required for Work Pack validation. Install: npm install ajv ajv-formats'
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = {
|
|
249
|
+
WORK_PACK_SCHEMA,
|
|
250
|
+
validateWorkPackManifest,
|
|
251
|
+
checkWorkPackStructure,
|
|
252
|
+
inspectWorkPack,
|
|
253
|
+
loadWorkPack,
|
|
254
|
+
};
|