cognitive-modules-cli 2.2.7 → 2.2.8

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.
@@ -577,10 +577,18 @@ export class CompositionOrchestrator {
577
577
  provider;
578
578
  cwd;
579
579
  searchPaths;
580
- constructor(provider, cwd = process.cwd()) {
580
+ validateInput;
581
+ validateOutput;
582
+ enableRepair;
583
+ policy;
584
+ constructor(provider, cwd = process.cwd(), enforcement = {}) {
581
585
  this.provider = provider;
582
586
  this.cwd = cwd;
583
587
  this.searchPaths = getDefaultSearchPaths(cwd);
588
+ this.validateInput = enforcement.validateInput ?? true;
589
+ this.validateOutput = enforcement.validateOutput ?? true;
590
+ this.enableRepair = enforcement.enableRepair ?? true;
591
+ this.policy = enforcement.policy;
584
592
  }
585
593
  /**
586
594
  * Execute a composed module workflow
@@ -1160,9 +1168,11 @@ export class CompositionOrchestrator {
1160
1168
  try {
1161
1169
  const result = await runModule(module, this.provider, {
1162
1170
  input,
1163
- validateInput: true,
1164
- validateOutput: true,
1165
- useV22: true
1171
+ validateInput: this.validateInput,
1172
+ validateOutput: this.validateOutput,
1173
+ useV22: true,
1174
+ enableRepair: this.enableRepair,
1175
+ policy: this.policy,
1166
1176
  });
1167
1177
  const endTime = Date.now();
1168
1178
  trace.push({
@@ -1278,8 +1288,8 @@ export class CompositionOrchestrator {
1278
1288
  * Execute a composed module workflow
1279
1289
  */
1280
1290
  export async function executeComposition(moduleName, input, provider, options = {}) {
1281
- const { cwd = process.cwd(), ...execOptions } = options;
1282
- const orchestrator = new CompositionOrchestrator(provider, cwd);
1291
+ const { cwd = process.cwd(), validateInput, validateOutput, enableRepair, policy, ...execOptions } = options;
1292
+ const orchestrator = new CompositionOrchestrator(provider, cwd, { validateInput, validateOutput, enableRepair, policy });
1283
1293
  return orchestrator.execute(moduleName, input, execOptions);
1284
1294
  }
1285
1295
  /**
@@ -7,6 +7,16 @@ import type { CognitiveModule, ModuleTier, SchemaStrictness } from '../types.js'
7
7
  * Load a Cognitive Module (auto-detects format)
8
8
  */
9
9
  export declare function loadModule(modulePath: string): Promise<CognitiveModule>;
10
+ /**
11
+ * Load a single-file module (Markdown) with optional YAML frontmatter.
12
+ *
13
+ * This enables the "5-minute" path: one file runs, and the runtime generates a loose schema/envelope.
14
+ *
15
+ * File format:
16
+ * - Optional YAML frontmatter between --- ... --- (same as v1 MODULE.md)
17
+ * - Body is treated as prompt
18
+ */
19
+ export declare function loadSingleFileModule(filePath: string): Promise<CognitiveModule>;
10
20
  export declare function findModule(name: string, searchPaths: string[]): Promise<CognitiveModule | null>;
11
21
  export declare function listModules(searchPaths: string[]): Promise<CognitiveModule[]>;
12
22
  export declare function getDefaultSearchPaths(cwd: string): string[];
@@ -7,6 +7,7 @@ import * as path from 'node:path';
7
7
  import { homedir } from 'node:os';
8
8
  import yaml from 'js-yaml';
9
9
  const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?/;
10
+ const SAFE_MODULE_NAME_REGEX = /^[a-z0-9][a-z0-9._-]*$/i;
10
11
  /**
11
12
  * Detect module format version
12
13
  */
@@ -333,6 +334,173 @@ export async function loadModule(modulePath) {
333
334
  return loadModuleV0(modulePath);
334
335
  }
335
336
  }
337
+ // =============================================================================
338
+ // Single-File Modules (Ad-hoc)
339
+ // =============================================================================
340
+ function coerceModuleName(name, fallback) {
341
+ const raw = (typeof name === 'string' && name.trim().length > 0) ? name.trim() : fallback;
342
+ const normalized = raw
343
+ .replace(/\s+/g, '-')
344
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
345
+ .replace(/-+/g, '-')
346
+ .replace(/^-|-$/g, '');
347
+ const lower = normalized.toLowerCase();
348
+ if (SAFE_MODULE_NAME_REGEX.test(lower))
349
+ return lower;
350
+ return fallback;
351
+ }
352
+ function defaultMetaSchema() {
353
+ return {
354
+ type: 'object',
355
+ additionalProperties: true,
356
+ required: ['confidence', 'risk', 'explain'],
357
+ properties: {
358
+ confidence: { type: 'number', minimum: 0, maximum: 1 },
359
+ risk: { type: 'string', enum: ['none', 'low', 'medium', 'high'] },
360
+ explain: { type: 'string', maxLength: 280 },
361
+ },
362
+ };
363
+ }
364
+ function defaultDataSchema() {
365
+ // Intentionally loose: the "5-minute" path should not fail on business fields.
366
+ return {
367
+ type: 'object',
368
+ additionalProperties: true,
369
+ };
370
+ }
371
+ function defaultInputSchema() {
372
+ // Matches runtime behavior: args are mapped to either query or code.
373
+ return {
374
+ type: 'object',
375
+ additionalProperties: true,
376
+ properties: {
377
+ query: { type: 'string' },
378
+ code: { type: 'string' },
379
+ args: { type: 'string' },
380
+ },
381
+ };
382
+ }
383
+ function defaultEnvelopeSchema(metaSchema) {
384
+ // Used as a hint for structured output (provider jsonSchema). Validation uses metaSchema/dataSchema separately.
385
+ return {
386
+ type: 'object',
387
+ additionalProperties: true,
388
+ oneOf: [
389
+ {
390
+ type: 'object',
391
+ required: ['ok', 'meta', 'data'],
392
+ properties: {
393
+ ok: { const: true },
394
+ version: { type: 'string' },
395
+ meta: metaSchema,
396
+ data: { type: 'object', additionalProperties: true },
397
+ },
398
+ },
399
+ {
400
+ type: 'object',
401
+ required: ['ok', 'meta', 'error'],
402
+ properties: {
403
+ ok: { const: false },
404
+ version: { type: 'string' },
405
+ meta: metaSchema,
406
+ error: {
407
+ type: 'object',
408
+ additionalProperties: true,
409
+ required: ['code', 'message'],
410
+ properties: {
411
+ code: { type: 'string' },
412
+ message: { type: 'string' },
413
+ recoverable: { type: 'boolean' },
414
+ },
415
+ },
416
+ partial_data: { type: 'object', additionalProperties: true },
417
+ },
418
+ },
419
+ ],
420
+ };
421
+ }
422
+ function looksLikeMarkdownModule(filePath) {
423
+ const ext = path.extname(filePath).toLowerCase();
424
+ return ext === '.md' || ext === '.markdown';
425
+ }
426
+ /**
427
+ * Load a single-file module (Markdown) with optional YAML frontmatter.
428
+ *
429
+ * This enables the "5-minute" path: one file runs, and the runtime generates a loose schema/envelope.
430
+ *
431
+ * File format:
432
+ * - Optional YAML frontmatter between --- ... --- (same as v1 MODULE.md)
433
+ * - Body is treated as prompt
434
+ */
435
+ export async function loadSingleFileModule(filePath) {
436
+ const absPath = path.resolve(filePath);
437
+ if (!looksLikeMarkdownModule(absPath)) {
438
+ throw new Error(`Single-file modules currently support Markdown (.md) only: ${absPath}`);
439
+ }
440
+ const content = await fs.readFile(absPath, 'utf-8');
441
+ let manifest = {};
442
+ let prompt = content;
443
+ const match = content.match(FRONTMATTER_REGEX);
444
+ if (match) {
445
+ try {
446
+ const loaded = yaml.load(match[1]);
447
+ manifest = (loaded && typeof loaded === 'object') ? loaded : {};
448
+ }
449
+ catch {
450
+ manifest = {};
451
+ }
452
+ prompt = (match[2] ?? '').trim();
453
+ }
454
+ const base = path.basename(absPath, path.extname(absPath));
455
+ const name = coerceModuleName(manifest.name, coerceModuleName(base, 'single-file-module'));
456
+ const tier = manifest.tier ?? 'decision';
457
+ const responsibility = manifest.responsibility
458
+ ?? `Ad-hoc single-file module loaded from ${path.basename(absPath)}`;
459
+ const excludes = Array.isArray(manifest.excludes) ? manifest.excludes : [];
460
+ const metaSchema = defaultMetaSchema();
461
+ const dataSchema = defaultDataSchema();
462
+ const inputSchema = defaultInputSchema();
463
+ const envelopeSchema = defaultEnvelopeSchema(metaSchema);
464
+ // Keep validation permissive by default, but still enforce envelope meta shape.
465
+ const schemaStrictness = manifest.schema_strictness ?? 'low';
466
+ return {
467
+ name,
468
+ version: manifest.version ?? '0.1.0',
469
+ responsibility,
470
+ excludes,
471
+ constraints: manifest.constraints,
472
+ policies: manifest.policies,
473
+ tools: manifest.tools,
474
+ output: manifest.output ?? { envelope: true, format: 'json_lenient' },
475
+ failure: manifest.failure,
476
+ runtimeRequirements: manifest.runtime_requirements,
477
+ tier,
478
+ schemaStrictness,
479
+ overflow: {
480
+ enabled: true,
481
+ recoverable: true,
482
+ max_items: 20,
483
+ require_suggested_mapping: false,
484
+ },
485
+ enums: { strategy: 'extensible', unknown_tag: 'custom' },
486
+ compat: { accepts_v21_payload: true, runtime_auto_wrap: true, schema_output_alias: 'data' },
487
+ metaConfig: { risk_rule: 'explicit' },
488
+ composition: undefined,
489
+ context: manifest.context,
490
+ prompt: prompt.length > 0 ? prompt : content.trim(),
491
+ // Schemas:
492
+ // - outputSchema is used as provider jsonSchema hint (we want the full envelope)
493
+ // - metaSchema/dataSchema are used for runtime validation after wrapping/repair
494
+ inputSchema,
495
+ outputSchema: envelopeSchema,
496
+ dataSchema,
497
+ metaSchema,
498
+ errorSchema: undefined,
499
+ location: absPath,
500
+ format: 'v2',
501
+ formatVersion: 'v2.2',
502
+ };
503
+ }
336
504
  /**
337
505
  * Check if a directory contains a valid module
338
506
  */
@@ -3,7 +3,7 @@
3
3
  * v2.2: Envelope format with meta/data separation, risk_rule, repair pass
4
4
  * v2.2.1: Version field, enhanced error taxonomy, observability hooks, streaming
5
5
  */
6
- import type { Provider, CognitiveModule, ModuleResult, ModuleInput, EnvelopeResponseV22, EnvelopeMeta, RiskLevel } from '../types.js';
6
+ import type { Provider, CognitiveModule, ModuleResult, ModuleInput, EnvelopeResponseV22, EnvelopeMeta, RiskLevel, ExecutionPolicy } from '../types.js';
7
7
  /**
8
8
  * Validate data against JSON schema. Returns list of errors.
9
9
  */
@@ -366,6 +366,7 @@ export interface RunOptions {
366
366
  enableRepair?: boolean;
367
367
  traceId?: string;
368
368
  model?: string;
369
+ policy?: ExecutionPolicy;
369
370
  }
370
371
  export declare function runModule(module: CognitiveModule, provider: Provider, options?: RunOptions): Promise<ModuleResult>;
371
372
  /** Event types emitted during streaming execution */
@@ -394,6 +395,7 @@ export interface StreamOptions {
394
395
  enableRepair?: boolean;
395
396
  traceId?: string;
396
397
  model?: string;
398
+ policy?: ExecutionPolicy;
397
399
  }
398
400
  /**
399
401
  * Run a cognitive module with streaming output.
@@ -5,7 +5,10 @@
5
5
  */
6
6
  import _Ajv from 'ajv';
7
7
  const Ajv = _Ajv.default || _Ajv;
8
+ import * as fs from 'node:fs/promises';
9
+ import * as path from 'node:path';
8
10
  import { aggregateRisk, isV22Envelope } from '../types.js';
11
+ import { readModuleProvenance, verifyModuleIntegrity } from '../provenance.js';
9
12
  // =============================================================================
10
13
  // Schema Validation
11
14
  // =============================================================================
@@ -1046,12 +1049,113 @@ function convertLegacyToEnvelope(data, isError = false) {
1046
1049
  };
1047
1050
  }
1048
1051
  }
1052
+ async function enforcePolicyGates(module, policy) {
1053
+ if (!policy)
1054
+ return null;
1055
+ if (policy.requireV22) {
1056
+ const fv = module.formatVersion ?? 'unknown';
1057
+ if (fv !== 'v2.2') {
1058
+ return makeErrorResponse({
1059
+ code: 'E4007', // PERMISSION_DENIED
1060
+ message: `Certified policy requires v2.2 modules; got: ${fv} (${module.format})`,
1061
+ explain: 'Refused by execution policy.',
1062
+ confidence: 1.0,
1063
+ risk: 'none',
1064
+ suggestion: 'Migrate the module to v2.2, or run with --profile strict/default.',
1065
+ });
1066
+ }
1067
+ }
1068
+ if (policy.profile !== 'certified')
1069
+ return null;
1070
+ const loc = module.location;
1071
+ if (typeof loc !== 'string' || loc.trim().length === 0) {
1072
+ return makeErrorResponse({
1073
+ code: 'E4007', // PERMISSION_DENIED
1074
+ message: 'Certified policy requires an installed module with provenance; module location is missing.',
1075
+ explain: 'Refused by execution policy.',
1076
+ confidence: 1.0,
1077
+ risk: 'none',
1078
+ suggestion: 'Reinstall the module from a registry tarball that writes provenance.json.',
1079
+ });
1080
+ }
1081
+ // Single-file modules (5-minute path) are intentionally not allowed in certified flows.
1082
+ try {
1083
+ const st = await fs.stat(loc);
1084
+ if (!st.isDirectory()) {
1085
+ return makeErrorResponse({
1086
+ code: 'E4007', // PERMISSION_DENIED
1087
+ message: `Certified policy requires module directory provenance; got a non-directory location: ${loc}`,
1088
+ explain: 'Refused by execution policy.',
1089
+ confidence: 1.0,
1090
+ risk: 'none',
1091
+ suggestion: 'Install the module via `cog add <name>` (registry tarball) or use --profile strict/default.',
1092
+ });
1093
+ }
1094
+ }
1095
+ catch {
1096
+ return makeErrorResponse({
1097
+ code: 'E4007',
1098
+ message: `Certified policy requires module directory provenance, but location does not exist: ${loc}`,
1099
+ explain: 'Refused by execution policy.',
1100
+ confidence: 1.0,
1101
+ risk: 'none',
1102
+ suggestion: 'Reinstall the module from a registry tarball and retry.',
1103
+ });
1104
+ }
1105
+ const prov = await readModuleProvenance(loc);
1106
+ if (!prov) {
1107
+ return makeErrorResponse({
1108
+ code: 'E4007', // PERMISSION_DENIED
1109
+ message: `Certified policy requires provenance.json in the module directory: ${loc}`,
1110
+ explain: 'Refused by execution policy.',
1111
+ confidence: 1.0,
1112
+ risk: 'none',
1113
+ suggestion: 'Reinstall the module from a registry tarball (distribution.tarball + checksum), then retry.',
1114
+ });
1115
+ }
1116
+ if (prov.source.type !== 'registry') {
1117
+ return makeErrorResponse({
1118
+ code: 'E4007', // PERMISSION_DENIED
1119
+ message: `Certified policy requires registry provenance; module provenance is type=${prov.source.type}`,
1120
+ explain: 'Refused by execution policy.',
1121
+ confidence: 1.0,
1122
+ risk: 'none',
1123
+ suggestion: 'Reinstall the module from a registry tarball and retry, or use --profile strict/default.',
1124
+ });
1125
+ }
1126
+ // Integrity check (tamper detection).
1127
+ const ok = await verifyModuleIntegrity(loc, prov);
1128
+ if (!ok.ok) {
1129
+ return makeErrorResponse({
1130
+ code: 'E4007', // PERMISSION_DENIED
1131
+ message: `Certified policy integrity check failed: ${ok.reason}`,
1132
+ explain: 'Module contents appear to have been modified after install.',
1133
+ confidence: 1.0,
1134
+ risk: 'none',
1135
+ suggestion: 'Reinstall the module from the registry tarball to restore integrity.',
1136
+ details: { location: loc, reason: ok.reason },
1137
+ });
1138
+ }
1139
+ // Optional: enforce that the module directory remains within itself (defense-in-depth for weird paths).
1140
+ const resolved = path.resolve(loc);
1141
+ if (!resolved)
1142
+ return null;
1143
+ return null;
1144
+ }
1049
1145
  // =============================================================================
1050
1146
  // Main Runner
1051
1147
  // =============================================================================
1052
1148
  export async function runModule(module, provider, options = {}) {
1053
- const { args, input, verbose = false, validateInput = true, validateOutput = true, useEnvelope, useV22, enableRepair = true, traceId, model: modelOverride } = options;
1149
+ const { args, input, verbose = false, validateInput = true, validateOutput = true, useEnvelope, useV22, enableRepair = true, traceId, model: modelOverride, policy } = options;
1054
1150
  const startTime = Date.now();
1151
+ const gate = await enforcePolicyGates(module, policy);
1152
+ if (gate) {
1153
+ const msg = gate.ok === false && 'error' in gate && gate.error?.message
1154
+ ? String(gate.error.message)
1155
+ : 'Refused by execution policy';
1156
+ _invokeErrorHooks(module.name, new Error(msg), null);
1157
+ return gate;
1158
+ }
1055
1159
  // Determine if we should use envelope format
1056
1160
  const shouldUseEnvelope = useEnvelope ?? (module.output?.envelope === true || module.format === 'v2');
1057
1161
  // Determine if we should use v2.2 format
@@ -1339,7 +1443,7 @@ export async function runModule(module, provider, options = {}) {
1339
1443
  * }
1340
1444
  */
1341
1445
  export async function* runModuleStream(module, provider, options = {}) {
1342
- const { input, args, validateInput = true, validateOutput = true, useV22 = true, enableRepair = true, traceId, model } = options;
1446
+ const { input, args, validateInput = true, validateOutput = true, useV22 = true, enableRepair = true, traceId, model, policy } = options;
1343
1447
  const startTime = Date.now();
1344
1448
  const moduleName = module.name;
1345
1449
  const providerName = provider?.name;
@@ -1356,6 +1460,13 @@ export async function* runModuleStream(module, provider, options = {}) {
1356
1460
  try {
1357
1461
  // Emit start event
1358
1462
  yield makeEvent('start');
1463
+ const gate = await enforcePolicyGates(module, policy);
1464
+ if (gate) {
1465
+ const errorObj = gate?.error ?? { code: 'E4007', message: 'Refused by execution policy' };
1466
+ yield makeEvent('error', { error: { code: errorObj.code, message: errorObj.message } });
1467
+ yield makeEvent('end', { result: gate });
1468
+ return;
1469
+ }
1359
1470
  // Build input data
1360
1471
  const inputData = input || {};
1361
1472
  if (args && !inputData.code && !inputData.query) {
@@ -0,0 +1,8 @@
1
+ import type { ExecutionPolicy } from './types.js';
2
+ export interface ResolvePolicyInput {
3
+ profile?: string | null;
4
+ validate?: string | null;
5
+ noValidate?: boolean;
6
+ audit?: boolean;
7
+ }
8
+ export declare function resolveExecutionPolicy(input: ResolvePolicyInput): ExecutionPolicy;
@@ -0,0 +1,59 @@
1
+ function normalizeProfile(raw) {
2
+ const v = (raw ?? '').trim().toLowerCase();
3
+ if (v === 'core')
4
+ return 'core';
5
+ if (v === 'default' || v === '')
6
+ return 'default';
7
+ if (v === 'strict')
8
+ return 'strict';
9
+ if (v === 'certified' || v === 'cert')
10
+ return 'certified';
11
+ throw new Error(`Invalid --profile: ${raw}. Expected one of: core|default|strict|certified`);
12
+ }
13
+ function normalizeValidate(raw) {
14
+ const v = (raw ?? '').trim().toLowerCase();
15
+ if (v === '' || v === 'auto')
16
+ return 'auto';
17
+ if (v === 'on' || v === 'true' || v === '1')
18
+ return 'on';
19
+ if (v === 'off' || v === 'false' || v === '0')
20
+ return 'off';
21
+ throw new Error(`Invalid --validate: ${raw}. Expected one of: auto|on|off`);
22
+ }
23
+ export function resolveExecutionPolicy(input) {
24
+ const profile = normalizeProfile(input.profile);
25
+ // Base defaults per profile.
26
+ let validate = profile === 'core' ? 'off' : 'on';
27
+ let audit = false;
28
+ let enableRepair = true;
29
+ let requireV22 = false;
30
+ if (profile === 'strict') {
31
+ validate = 'on';
32
+ audit = false;
33
+ enableRepair = true;
34
+ requireV22 = false;
35
+ }
36
+ if (profile === 'certified') {
37
+ validate = 'on';
38
+ audit = true;
39
+ enableRepair = false; // certification prefers fail-fast over runtime repair
40
+ requireV22 = true;
41
+ }
42
+ // CLI overrides.
43
+ const validateExplicit = input.validate != null || Boolean(input.noValidate);
44
+ if (input.validate != null) {
45
+ validate = normalizeValidate(input.validate);
46
+ }
47
+ if (input.noValidate) {
48
+ validate = 'off';
49
+ }
50
+ if (typeof input.audit === 'boolean') {
51
+ audit = input.audit;
52
+ }
53
+ // Trigger rule: if audit is enabled and validate wasn't explicitly turned off,
54
+ // force validation on (auditing without validation is usually not meaningful).
55
+ if (audit && !(validateExplicit && validate === 'off')) {
56
+ validate = 'on';
57
+ }
58
+ return { profile, validate, audit, enableRepair, requireV22 };
59
+ }
@@ -0,0 +1,50 @@
1
+ export declare const PROVENANCE_FILENAME = "provenance.json";
2
+ export declare const PROVENANCE_SPEC = "cognitive.module.provenance/v1";
3
+ export type ProvenanceSource = {
4
+ type: 'registry';
5
+ registryUrl?: string | null;
6
+ moduleName: string;
7
+ requestedVersion?: string | null;
8
+ resolvedVersion?: string | null;
9
+ tarballUrl: string;
10
+ checksum: string;
11
+ sha256: string;
12
+ quality?: {
13
+ verified?: boolean;
14
+ conformance_level?: number;
15
+ spec_version?: string;
16
+ };
17
+ } | {
18
+ type: 'github';
19
+ repoUrl: string;
20
+ ref?: string | null;
21
+ modulePath?: string | null;
22
+ };
23
+ export interface ModuleIntegrity {
24
+ algorithm: 'sha256';
25
+ maxFiles: number;
26
+ maxTotalBytes: number;
27
+ maxSingleFileBytes: number;
28
+ totalBytes: number;
29
+ files: Record<string, string>;
30
+ }
31
+ export interface ModuleProvenance {
32
+ spec: typeof PROVENANCE_SPEC;
33
+ createdAt: string;
34
+ source: ProvenanceSource;
35
+ integrity: ModuleIntegrity;
36
+ }
37
+ export interface IntegrityOptions {
38
+ maxFiles: number;
39
+ maxTotalBytes: number;
40
+ maxSingleFileBytes: number;
41
+ }
42
+ export declare function computeModuleIntegrity(moduleDir: string, options?: Partial<IntegrityOptions>): Promise<ModuleIntegrity>;
43
+ export declare function writeModuleProvenance(moduleDir: string, prov: ModuleProvenance): Promise<void>;
44
+ export declare function readModuleProvenance(moduleDir: string): Promise<ModuleProvenance | null>;
45
+ export declare function verifyModuleIntegrity(moduleDir: string, prov: ModuleProvenance): Promise<{
46
+ ok: true;
47
+ } | {
48
+ ok: false;
49
+ reason: string;
50
+ }>;
@@ -0,0 +1,137 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import { createReadStream } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import * as path from 'node:path';
5
+ export const PROVENANCE_FILENAME = 'provenance.json';
6
+ export const PROVENANCE_SPEC = 'cognitive.module.provenance/v1';
7
+ const DEFAULT_INTEGRITY_LIMITS = {
8
+ maxFiles: 5_000,
9
+ maxTotalBytes: 50 * 1024 * 1024, // 50MB
10
+ maxSingleFileBytes: 20 * 1024 * 1024, // 20MB
11
+ };
12
+ async function hashFileSha256(filePath, size) {
13
+ const h = createHash('sha256');
14
+ await new Promise((resolve, reject) => {
15
+ const rs = createReadStream(filePath);
16
+ rs.on('data', (chunk) => h.update(chunk));
17
+ rs.on('error', reject);
18
+ rs.on('end', resolve);
19
+ });
20
+ // Include size in the hash domain separation to make accidental truncation obvious.
21
+ h.update(`\nsize:${size}\n`);
22
+ return h.digest('hex');
23
+ }
24
+ async function walkFiles(rootDir, relDir) {
25
+ const absDir = path.join(rootDir, relDir);
26
+ const entries = await fs.readdir(absDir, { withFileTypes: true });
27
+ const out = [];
28
+ for (const ent of entries) {
29
+ const rel = relDir ? path.posix.join(relDir.replace(/\\/g, '/'), ent.name) : ent.name;
30
+ const abs = path.join(rootDir, rel);
31
+ // Never hash our own provenance.
32
+ if (rel === PROVENANCE_FILENAME)
33
+ continue;
34
+ if (ent.name === '.DS_Store' || ent.name === '__MACOSX')
35
+ continue;
36
+ if (ent.isSymbolicLink()) {
37
+ // Refuse to hash symlinks. Tar extraction also rejects them.
38
+ continue;
39
+ }
40
+ if (ent.isDirectory()) {
41
+ out.push(...await walkFiles(rootDir, rel));
42
+ continue;
43
+ }
44
+ if (ent.isFile()) {
45
+ out.push(rel);
46
+ }
47
+ }
48
+ return out;
49
+ }
50
+ export async function computeModuleIntegrity(moduleDir, options = {}) {
51
+ const limits = {
52
+ ...DEFAULT_INTEGRITY_LIMITS,
53
+ ...options,
54
+ };
55
+ const relFiles = (await walkFiles(moduleDir, '')).sort((a, b) => a.localeCompare(b));
56
+ if (relFiles.length > limits.maxFiles) {
57
+ throw new Error(`Module has too many files to hash (max ${limits.maxFiles}): ${relFiles.length}`);
58
+ }
59
+ const files = {};
60
+ let totalBytes = 0;
61
+ for (const rel of relFiles) {
62
+ const abs = path.join(moduleDir, rel);
63
+ const st = await fs.stat(abs);
64
+ if (!st.isFile())
65
+ continue;
66
+ if (st.size > limits.maxSingleFileBytes) {
67
+ throw new Error(`Module file too large for integrity hashing (max ${limits.maxSingleFileBytes} bytes): ${rel}`);
68
+ }
69
+ totalBytes += st.size;
70
+ if (totalBytes > limits.maxTotalBytes) {
71
+ throw new Error(`Module too large for integrity hashing (max ${limits.maxTotalBytes} bytes)`);
72
+ }
73
+ const sha256 = await hashFileSha256(abs, st.size);
74
+ files[rel] = sha256;
75
+ }
76
+ return {
77
+ algorithm: 'sha256',
78
+ maxFiles: limits.maxFiles,
79
+ maxTotalBytes: limits.maxTotalBytes,
80
+ maxSingleFileBytes: limits.maxSingleFileBytes,
81
+ totalBytes,
82
+ files,
83
+ };
84
+ }
85
+ export async function writeModuleProvenance(moduleDir, prov) {
86
+ const filePath = path.join(moduleDir, PROVENANCE_FILENAME);
87
+ const json = JSON.stringify(prov, null, 2) + '\n';
88
+ await fs.writeFile(filePath, json, 'utf-8');
89
+ }
90
+ export async function readModuleProvenance(moduleDir) {
91
+ const filePath = path.join(moduleDir, PROVENANCE_FILENAME);
92
+ try {
93
+ const raw = await fs.readFile(filePath, 'utf-8');
94
+ const parsed = JSON.parse(raw);
95
+ if (!parsed || typeof parsed !== 'object')
96
+ return null;
97
+ if (parsed.spec !== PROVENANCE_SPEC)
98
+ return null;
99
+ if (!parsed.source || typeof parsed.source !== 'object')
100
+ return null;
101
+ if (!parsed.integrity || typeof parsed.integrity !== 'object')
102
+ return null;
103
+ return parsed;
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ export async function verifyModuleIntegrity(moduleDir, prov) {
110
+ const expected = prov.integrity?.files ?? {};
111
+ const expectedKeys = Object.keys(expected).sort();
112
+ if (expectedKeys.length === 0) {
113
+ return { ok: false, reason: 'Provenance integrity.files is empty' };
114
+ }
115
+ const computed = await computeModuleIntegrity(moduleDir, {
116
+ maxFiles: prov.integrity.maxFiles,
117
+ maxTotalBytes: prov.integrity.maxTotalBytes,
118
+ maxSingleFileBytes: prov.integrity.maxSingleFileBytes,
119
+ });
120
+ const computedKeys = Object.keys(computed.files).sort();
121
+ if (expectedKeys.length !== computedKeys.length) {
122
+ return { ok: false, reason: 'Integrity file list changed (file count mismatch)' };
123
+ }
124
+ for (let i = 0; i < expectedKeys.length; i++) {
125
+ if (expectedKeys[i] !== computedKeys[i]) {
126
+ return { ok: false, reason: `Integrity file list changed (mismatch at ${i}: ${expectedKeys[i]} vs ${computedKeys[i]})` };
127
+ }
128
+ }
129
+ for (const rel of expectedKeys) {
130
+ const a = expected[rel];
131
+ const b = computed.files[rel];
132
+ if (a !== b) {
133
+ return { ok: false, reason: `Integrity mismatch for ${rel}` };
134
+ }
135
+ }
136
+ return { ok: true };
137
+ }