cognitive-modules-cli 2.2.5 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/README.md +25 -3
  3. package/dist/audit.d.ts +13 -0
  4. package/dist/audit.js +25 -0
  5. package/dist/cli.js +188 -3
  6. package/dist/commands/add.js +232 -7
  7. package/dist/commands/compose.d.ts +2 -0
  8. package/dist/commands/compose.js +60 -1
  9. package/dist/commands/core.d.ts +31 -0
  10. package/dist/commands/core.js +338 -0
  11. package/dist/commands/index.d.ts +1 -0
  12. package/dist/commands/index.js +1 -0
  13. package/dist/commands/pipe.js +45 -2
  14. package/dist/commands/run.d.ts +1 -0
  15. package/dist/commands/run.js +136 -31
  16. package/dist/commands/search.js +13 -3
  17. package/dist/commands/update.js +4 -1
  18. package/dist/errors/index.d.ts +7 -0
  19. package/dist/errors/index.js +48 -40
  20. package/dist/modules/composition.d.ts +15 -2
  21. package/dist/modules/composition.js +16 -6
  22. package/dist/modules/loader.d.ts +10 -0
  23. package/dist/modules/loader.js +168 -0
  24. package/dist/modules/runner.d.ts +10 -6
  25. package/dist/modules/runner.js +130 -16
  26. package/dist/profile.d.ts +8 -0
  27. package/dist/profile.js +59 -0
  28. package/dist/provenance.d.ts +50 -0
  29. package/dist/provenance.js +137 -0
  30. package/dist/registry/assets.d.ts +48 -0
  31. package/dist/registry/assets.js +723 -0
  32. package/dist/registry/client.d.ts +20 -5
  33. package/dist/registry/client.js +87 -30
  34. package/dist/registry/tar.d.ts +8 -0
  35. package/dist/registry/tar.js +353 -0
  36. package/dist/server/http.js +167 -42
  37. package/dist/server/index.d.ts +2 -0
  38. package/dist/server/index.js +1 -0
  39. package/dist/server/sse.d.ts +13 -0
  40. package/dist/server/sse.js +22 -0
  41. package/dist/types.d.ts +31 -0
  42. package/package.json +1 -1
@@ -9,13 +9,16 @@
9
9
  * cog add ziel-io/cognitive-modules -m code-simplifier
10
10
  * cog add https://github.com/org/repo --module name --tag v1.0.0
11
11
  */
12
- import { createWriteStream, existsSync, mkdirSync, rmSync, readdirSync, statSync, copyFileSync } from 'node:fs';
12
+ import { createWriteStream, existsSync, mkdirSync, rmSync, readdirSync, statSync, copyFileSync, lstatSync } from 'node:fs';
13
13
  import { writeFile, readFile, mkdir } from 'node:fs/promises';
14
- import { pipeline } from 'node:stream/promises';
14
+ import { pipeline, finished } from 'node:stream/promises';
15
15
  import { Readable } from 'node:stream';
16
16
  import { join, basename, dirname, resolve, sep, isAbsolute } from 'node:path';
17
17
  import { homedir, tmpdir } from 'node:os';
18
+ import { createHash } from 'node:crypto';
18
19
  import { RegistryClient } from '../registry/client.js';
20
+ import { extractTarGzFile } from '../registry/tar.js';
21
+ import { PROVENANCE_SPEC, computeModuleIntegrity, writeModuleProvenance } from '../provenance.js';
19
22
  // Module storage paths
20
23
  const USER_MODULES_DIR = join(homedir(), '.cognitive', 'modules');
21
24
  const INSTALLED_MANIFEST = join(homedir(), '.cognitive', 'installed.json');
@@ -153,7 +156,11 @@ function copyDir(src, dest) {
153
156
  for (const entry of readdirSync(src)) {
154
157
  const srcPath = join(src, entry);
155
158
  const destPath = join(dest, entry);
156
- if (statSync(srcPath).isDirectory()) {
159
+ const st = lstatSync(srcPath);
160
+ if (st.isSymbolicLink()) {
161
+ throw new Error(`Refusing to install module containing symlink: ${srcPath}`);
162
+ }
163
+ if (st.isDirectory()) {
157
164
  copyDir(srcPath, destPath);
158
165
  }
159
166
  else {
@@ -161,6 +168,74 @@ function copyDir(src, dest) {
161
168
  }
162
169
  }
163
170
  }
171
+ async function downloadTarballWithSha256(url, outPath, maxBytes) {
172
+ const controller = new AbortController();
173
+ const timeout = setTimeout(() => controller.abort(), 10_000);
174
+ const hash = createHash('sha256');
175
+ let received = 0;
176
+ const fileStream = createWriteStream(outPath);
177
+ try {
178
+ const response = await fetch(url, {
179
+ headers: { 'User-Agent': 'cognitive-runtime/2.2' },
180
+ signal: controller.signal,
181
+ });
182
+ if (!response.ok) {
183
+ throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`);
184
+ }
185
+ const contentLengthHeader = response.headers?.get?.('content-length');
186
+ if (contentLengthHeader) {
187
+ const contentLength = Number(contentLengthHeader);
188
+ if (!Number.isNaN(contentLength) && contentLength > maxBytes) {
189
+ throw new Error(`Tarball too large: ${contentLength} bytes (max ${maxBytes})`);
190
+ }
191
+ }
192
+ if (!response.body) {
193
+ throw new Error('Tarball response has no body');
194
+ }
195
+ const reader = response.body.getReader?.();
196
+ if (!reader) {
197
+ // Fallback: stream pipeline without incremental hash.
198
+ await pipeline(Readable.fromWeb(response.body), fileStream);
199
+ const content = await readFile(outPath);
200
+ return createHash('sha256').update(content).digest('hex');
201
+ }
202
+ while (true) {
203
+ const { done, value } = await reader.read();
204
+ if (done)
205
+ break;
206
+ if (value) {
207
+ received += value.byteLength;
208
+ if (received > maxBytes) {
209
+ controller.abort();
210
+ throw new Error(`Tarball too large: ${received} bytes (max ${maxBytes})`);
211
+ }
212
+ const chunk = Buffer.from(value);
213
+ hash.update(chunk);
214
+ if (!fileStream.write(chunk)) {
215
+ await new Promise((resolve) => fileStream.once('drain', () => resolve()));
216
+ }
217
+ }
218
+ }
219
+ fileStream.end();
220
+ await finished(fileStream);
221
+ return hash.digest('hex');
222
+ }
223
+ catch (error) {
224
+ try {
225
+ fileStream.destroy();
226
+ }
227
+ catch {
228
+ // ignore
229
+ }
230
+ if (error instanceof Error && error.name === 'AbortError') {
231
+ throw new Error('Tarball download timed out after 10000ms');
232
+ }
233
+ throw error;
234
+ }
235
+ finally {
236
+ clearTimeout(timeout);
237
+ }
238
+ }
164
239
  /**
165
240
  * Get module version from module.yaml or MODULE.md
166
241
  */
@@ -266,8 +341,13 @@ function parseModuleSpec(spec) {
266
341
  export async function addFromRegistry(moduleSpec, ctx, options = {}) {
267
342
  const { name: moduleName, version: requestedVersion } = parseModuleSpec(moduleSpec);
268
343
  const { name: customName, registry: registryUrl } = options;
344
+ const policy = ctx.policy;
345
+ let tempDir;
269
346
  try {
270
- const client = new RegistryClient(registryUrl);
347
+ const client = new RegistryClient(registryUrl, {
348
+ timeoutMs: ctx.registryTimeoutMs,
349
+ maxBytes: ctx.registryMaxBytes,
350
+ });
271
351
  const moduleInfo = await client.getModule(moduleName);
272
352
  if (!moduleInfo) {
273
353
  return {
@@ -282,6 +362,14 @@ export async function addFromRegistry(moduleSpec, ctx, options = {}) {
282
362
  // Get download info
283
363
  const downloadInfo = await client.getDownloadUrl(moduleName);
284
364
  if (downloadInfo.isGitHub && downloadInfo.githubInfo) {
365
+ if (policy?.profile === 'certified') {
366
+ return {
367
+ success: false,
368
+ error: `Certified profile requires registry tarball provenance.\n` +
369
+ `Registry entry for '${moduleName}' resolves to a GitHub source, which is not allowed in --profile certified.\n` +
370
+ `Use a tarball-based registry entry (distribution.tarball + checksum), or run with --profile strict/default.`,
371
+ };
372
+ }
285
373
  const { org, repo, path, ref } = downloadInfo.githubInfo;
286
374
  // Use addFromGitHub() directly (not add() to avoid recursion)
287
375
  const result = await addFromGitHub(`${org}/${repo}`, ctx, {
@@ -325,10 +413,112 @@ export async function addFromRegistry(moduleSpec, ctx, options = {}) {
325
413
  }
326
414
  return result;
327
415
  }
328
- // For tarball sources, download directly (future implementation)
416
+ // Tarball sources: require checksum, verify, and extract safely.
417
+ if (!downloadInfo.url.startsWith('http')) {
418
+ return { success: false, error: `Unsupported registry download URL: ${downloadInfo.url}` };
419
+ }
420
+ if (!downloadInfo.checksum) {
421
+ return {
422
+ success: false,
423
+ error: `Registry tarball missing checksum (required for safe install): ${moduleName}`,
424
+ };
425
+ }
426
+ tempDir = join(tmpdir(), `cog-reg-${Date.now()}`);
427
+ mkdirSync(tempDir, { recursive: true });
428
+ const tarPath = join(tempDir, 'module.tar.gz');
429
+ const MAX_TARBALL_BYTES = 20 * 1024 * 1024; // 20MB
430
+ const actualSha256 = await downloadTarballWithSha256(downloadInfo.url, tarPath, MAX_TARBALL_BYTES);
431
+ const expected = downloadInfo.checksum;
432
+ const checksumMatch = expected.match(/^sha256:([a-f0-9]{64})$/);
433
+ if (!checksumMatch) {
434
+ throw new Error(`Unsupported checksum format (expected sha256:<64hex>): ${expected}`);
435
+ }
436
+ const expectedHash = checksumMatch[1];
437
+ if (actualSha256 !== expectedHash) {
438
+ throw new Error(`Checksum mismatch for ${moduleName}: expected ${expectedHash}, got ${actualSha256}`);
439
+ }
440
+ const extractedRoot = join(tempDir, 'pkg');
441
+ mkdirSync(extractedRoot, { recursive: true });
442
+ await extractTarGzFile(tarPath, extractedRoot, {
443
+ maxFiles: 5_000,
444
+ maxTotalBytes: 50 * 1024 * 1024,
445
+ maxSingleFileBytes: 20 * 1024 * 1024,
446
+ maxTarBytes: 100 * 1024 * 1024,
447
+ });
448
+ // Find module directory inside extractedRoot.
449
+ const rootNames = readdirSync(extractedRoot).filter((e) => e !== '__MACOSX' && e !== '.DS_Store');
450
+ const rootPaths = rootNames.map((e) => join(extractedRoot, e)).filter((p) => existsSync(p));
451
+ const rootDirs = rootPaths.filter((p) => statSync(p).isDirectory());
452
+ const rootFiles = rootPaths.filter((p) => !statSync(p).isDirectory());
453
+ if (rootDirs.length === 0) {
454
+ throw new Error('Tarball extraction produced no root directory');
455
+ }
456
+ if (rootDirs.length !== 1 || rootFiles.length > 0) {
457
+ throw new Error(`Tarball must contain exactly one module root directory and no other top-level entries. ` +
458
+ `dirs=${rootDirs.map((p) => basename(p)).join(',') || '(none)'} files=${rootFiles.map((p) => basename(p)).join(',') || '(none)'}`);
459
+ }
460
+ // Strict mode: require root dir itself to be a valid module.
461
+ const sourcePath = rootDirs[0];
462
+ if (!isValidModule(sourcePath)) {
463
+ throw new Error('Root directory in tarball is not a valid module');
464
+ }
465
+ const installName = (customName || moduleName);
466
+ const safeInstallName = assertSafeModuleName(installName);
467
+ const targetPath = resolveModuleTarget(safeInstallName);
468
+ if (existsSync(targetPath)) {
469
+ rmSync(targetPath, { recursive: true, force: true });
470
+ }
471
+ await mkdir(USER_MODULES_DIR, { recursive: true });
472
+ copyDir(sourcePath, targetPath);
473
+ const version = await getModuleVersion(sourcePath);
474
+ // Write provenance + integrity (enables certified profile gating).
475
+ try {
476
+ const integrity = await computeModuleIntegrity(targetPath);
477
+ await writeModuleProvenance(targetPath, {
478
+ spec: PROVENANCE_SPEC,
479
+ createdAt: new Date().toISOString(),
480
+ source: {
481
+ type: 'registry',
482
+ registryUrl: registryUrl ?? null,
483
+ moduleName,
484
+ requestedVersion: requestedVersion ?? null,
485
+ resolvedVersion: version ?? null,
486
+ tarballUrl: downloadInfo.url,
487
+ checksum: downloadInfo.checksum,
488
+ sha256: actualSha256,
489
+ quality: {
490
+ // moduleInfo is typed loosely (v1/v2); best-effort extract for v2.
491
+ verified: moduleInfo?.quality?.verified,
492
+ conformance_level: moduleInfo?.quality?.conformance_level,
493
+ spec_version: moduleInfo?.identity?.spec_version,
494
+ },
495
+ },
496
+ integrity,
497
+ });
498
+ }
499
+ catch (e) {
500
+ // If provenance fails, keep install but warn loudly (non-certified users can still run).
501
+ console.error(`Warning: failed to write provenance for ${safeInstallName}: ${e.message}`);
502
+ }
503
+ await recordInstall(safeInstallName, {
504
+ source: downloadInfo.url,
505
+ githubUrl: downloadInfo.url,
506
+ version,
507
+ installedAt: targetPath,
508
+ installedTime: new Date().toISOString(),
509
+ registryModule: moduleName,
510
+ registryUrl,
511
+ });
329
512
  return {
330
- success: false,
331
- error: `Tarball downloads not yet supported. Source: ${moduleInfo.source}`,
513
+ success: true,
514
+ data: {
515
+ message: `Added: ${safeInstallName}${version ? ` v${version}` : ''} (registry tarball)`,
516
+ name: safeInstallName,
517
+ version,
518
+ location: targetPath,
519
+ source: 'registry',
520
+ registryModule: moduleName,
521
+ },
332
522
  };
333
523
  }
334
524
  catch (error) {
@@ -337,6 +527,16 @@ export async function addFromRegistry(moduleSpec, ctx, options = {}) {
337
527
  error: error instanceof Error ? error.message : String(error),
338
528
  };
339
529
  }
530
+ finally {
531
+ if (tempDir) {
532
+ try {
533
+ rmSync(tempDir, { recursive: true, force: true });
534
+ }
535
+ catch {
536
+ // ignore
537
+ }
538
+ }
539
+ }
340
540
  }
341
541
  /**
342
542
  * Add a module from GitHub
@@ -344,6 +544,14 @@ export async function addFromRegistry(moduleSpec, ctx, options = {}) {
344
544
  export async function addFromGitHub(url, ctx, options = {}) {
345
545
  const { org, repo, fullUrl } = parseGitHubUrl(url);
346
546
  const { module: modulePath, name, branch = 'main', tag } = options;
547
+ const policy = ctx.policy;
548
+ if (policy?.profile === 'certified') {
549
+ return {
550
+ success: false,
551
+ error: `Certified profile requires registry tarball provenance; GitHub installs are not allowed.\n` +
552
+ `Use 'cog add <module>' against a tarball-based registry entry, or run with --profile strict/default.`,
553
+ };
554
+ }
347
555
  // Determine ref (tag takes priority)
348
556
  const ref = tag || branch;
349
557
  const isTag = !!tag;
@@ -388,6 +596,19 @@ export async function addFromGitHub(url, ctx, options = {}) {
388
596
  installedAt: targetPath,
389
597
  installedTime: new Date().toISOString(),
390
598
  });
599
+ // Best-effort provenance for non-certified installs (useful for audit/debug).
600
+ try {
601
+ const integrity = await computeModuleIntegrity(targetPath);
602
+ await writeModuleProvenance(targetPath, {
603
+ spec: PROVENANCE_SPEC,
604
+ createdAt: new Date().toISOString(),
605
+ source: { type: 'github', repoUrl: fullUrl, ref, modulePath: modulePath ?? null },
606
+ integrity,
607
+ });
608
+ }
609
+ catch {
610
+ // ignore
611
+ }
391
612
  // Cleanup temp directory
392
613
  const tempDir = dirname(repoRoot);
393
614
  if (tempDir && tempDir !== '/' && tempDir !== '.' && tempDir !== USER_MODULES_DIR) {
@@ -427,6 +648,10 @@ export async function addFromGitHub(url, ctx, options = {}) {
427
648
  * Add a module from GitHub or Registry (auto-detect source)
428
649
  */
429
650
  export async function add(source, ctx, options = {}) {
651
+ // Allow a global registry override via ctx.registryUrl unless explicitly set.
652
+ if (!options.registry && ctx.registryUrl) {
653
+ options = { ...options, registry: ctx.registryUrl };
654
+ }
430
655
  // Determine source type
431
656
  if (isGitHubSource(source)) {
432
657
  return addFromGitHub(source, ctx, options);
@@ -13,6 +13,8 @@ export interface ComposeOptions {
13
13
  args?: string;
14
14
  /** JSON input data */
15
15
  input?: string;
16
+ /** Disable validation */
17
+ noValidate?: boolean;
16
18
  /** Maximum composition depth */
17
19
  maxDepth?: number;
18
20
  /** Timeout in milliseconds */
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { findModule, getDefaultSearchPaths, executeComposition } from '../modules/index.js';
11
11
  import { ErrorCodes, attachContext, makeErrorEnvelope } from '../errors/index.js';
12
+ import { writeAuditRecord } from '../audit.js';
12
13
  function looksLikeCode(str) {
13
14
  const codeIndicators = [
14
15
  /^(def|function|class|const|let|var|import|export|public|private)\s/,
@@ -34,7 +35,19 @@ export async function compose(moduleName, ctx, options = {}) {
34
35
  data: errorEnvelope,
35
36
  };
36
37
  }
38
+ if (ctx.policy?.requireV22) {
39
+ if (module.formatVersion !== 'v2.2') {
40
+ const errorEnvelope = attachContext(makeErrorEnvelope({
41
+ code: ErrorCodes.INVALID_INPUT,
42
+ message: `Certified profile requires v2.2 modules; got: ${module.formatVersion ?? 'unknown'} (${module.format})`,
43
+ suggestion: "Migrate the module to v2.2, or run with `--profile strict` / `--profile default`",
44
+ }), { module: moduleName, provider: ctx.provider.name });
45
+ return { success: false, error: errorEnvelope.error.message, data: errorEnvelope };
46
+ }
47
+ }
37
48
  try {
49
+ const policy = ctx.policy;
50
+ const startedAt = Date.now();
38
51
  // Parse input if provided as JSON
39
52
  let inputData = {};
40
53
  if (options.input) {
@@ -67,7 +80,31 @@ export async function compose(moduleName, ctx, options = {}) {
67
80
  const result = await executeComposition(moduleName, inputData, ctx.provider, {
68
81
  cwd: ctx.cwd,
69
82
  maxDepth: options.maxDepth,
70
- timeoutMs: options.timeout
83
+ timeoutMs: options.timeout,
84
+ policy,
85
+ validateInput: (() => {
86
+ if (options.noValidate)
87
+ return false;
88
+ if (!policy)
89
+ return true;
90
+ if (policy.validate === 'off')
91
+ return false;
92
+ if (policy.validate === 'on')
93
+ return true;
94
+ return policy.profile !== 'core';
95
+ })(),
96
+ validateOutput: (() => {
97
+ if (options.noValidate)
98
+ return false;
99
+ if (!policy)
100
+ return true;
101
+ if (policy.validate === 'off')
102
+ return false;
103
+ if (policy.validate === 'on')
104
+ return true;
105
+ return policy.profile !== 'core';
106
+ })(),
107
+ enableRepair: policy?.enableRepair ?? true,
71
108
  });
72
109
  if (options.verbose) {
73
110
  console.error('--- Composition Trace ---');
@@ -98,6 +135,28 @@ export async function compose(moduleName, ctx, options = {}) {
98
135
  data: errorEnvelope,
99
136
  };
100
137
  }
138
+ if (policy?.audit) {
139
+ const rec = await writeAuditRecord({
140
+ ts: new Date().toISOString(),
141
+ kind: 'compose',
142
+ policy,
143
+ provider: ctx.provider.name,
144
+ module: { name: module.name, version: module.version, location: module.location, formatVersion: module.formatVersion },
145
+ input: inputData,
146
+ result: {
147
+ ok: result.ok,
148
+ result: result.result,
149
+ moduleResults: result.moduleResults,
150
+ trace: result.trace,
151
+ totalTimeMs: result.totalTimeMs,
152
+ error: result.error,
153
+ },
154
+ notes: [`duration_ms=${Date.now() - startedAt}`],
155
+ });
156
+ if (rec) {
157
+ console.error(`Audit: ${rec.path}`);
158
+ }
159
+ }
101
160
  // Return result
102
161
  if (options.trace) {
103
162
  // Include full result with trace
@@ -0,0 +1,31 @@
1
+ /**
2
+ * cog core - Minimal "one-file" Core workflow
3
+ *
4
+ * Goals:
5
+ * - A single Markdown file can be a runnable module (optional frontmatter + prompt body)
6
+ * - Runtime generates loose schemas and always returns a v2.2 envelope on execution
7
+ * - No registry / conformance / certification required to get started
8
+ */
9
+ import type { CommandContext, CommandResult } from '../types.js';
10
+ export interface CoreOptions {
11
+ args?: string;
12
+ input?: string;
13
+ noValidate?: boolean;
14
+ pretty?: boolean;
15
+ verbose?: boolean;
16
+ stream?: boolean;
17
+ dryRun?: boolean;
18
+ stdin?: boolean;
19
+ force?: boolean;
20
+ }
21
+ export declare function coreNew(filePath: string, options?: {
22
+ dryRun?: boolean;
23
+ }): Promise<CommandResult>;
24
+ export declare function coreSchema(filePath: string): Promise<CommandResult>;
25
+ export declare function corePromote(filePath: string, outDir?: string, options?: {
26
+ dryRun?: boolean;
27
+ force?: boolean;
28
+ }): Promise<CommandResult>;
29
+ export declare function coreRunText(markdownOrPrompt: string, ctx: CommandContext, options?: CoreOptions): Promise<CommandResult>;
30
+ export declare function coreRun(filePath: string, ctx: CommandContext, options?: CoreOptions): Promise<CommandResult>;
31
+ export declare function core(subcommand: string | undefined, target: string | undefined, ctx: CommandContext, options?: CoreOptions, rest?: string[]): Promise<CommandResult>;