chati-dev 4.5.16 → 4.5.28

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 (122) hide show
  1. package/README.md +8 -3
  2. package/bin/chati.js +124 -66
  3. package/framework/agents/build/dev.md +5 -5
  4. package/framework/agents/deploy/devops.md +7 -7
  5. package/framework/agents/discover/brief.md +4 -4
  6. package/framework/agents/discover/brownfield-wu.md +3 -3
  7. package/framework/agents/discover/greenfield-wu.md +3 -3
  8. package/framework/agents/plan/architect.md +2 -2
  9. package/framework/agents/plan/detail.md +4 -4
  10. package/framework/agents/plan/phases.md +2 -2
  11. package/framework/agents/plan/tasks.md +2 -2
  12. package/framework/agents/plan/ux.md +2 -2
  13. package/framework/agents/quality/qa-implementation.md +11 -10
  14. package/framework/agents/quality/qa-planning.md +3 -3
  15. package/framework/agents/quality/qa-visual.md +1 -1
  16. package/framework/config.yaml +3 -3
  17. package/framework/constitution.md +18 -18
  18. package/framework/context/protocols.md +1 -1
  19. package/framework/context/quality.md +1 -1
  20. package/framework/context/root.md +4 -4
  21. package/framework/data/entity-registry.yaml +1 -1
  22. package/framework/domains/agents/orchestrator.yaml +2 -2
  23. package/framework/domains/constitution.yaml +1 -1
  24. package/framework/hooks/advance-trigger.js +4 -6
  25. package/framework/hooks/git-push-authority.js +45 -37
  26. package/framework/hooks/mode-governance.js +149 -40
  27. package/framework/hooks/model-governance.js +13 -20
  28. package/framework/hooks/prism-engine.js +74 -92
  29. package/framework/hooks/reasoning-escalator.js +23 -40
  30. package/framework/hooks/session-digest.js +12 -13
  31. package/framework/hooks/session-reader.js +224 -0
  32. package/framework/hooks/session-writer.js +195 -0
  33. package/framework/hooks/team-quality-gate.js +34 -24
  34. package/framework/i18n/en.yaml +2 -2
  35. package/framework/i18n/es.yaml +2 -2
  36. package/framework/i18n/fr.yaml +2 -2
  37. package/framework/i18n/pt.yaml +2 -2
  38. package/framework/intelligence/context-engine.md +4 -4
  39. package/framework/intelligence/memory-layer.md +1 -1
  40. package/framework/manifest.json +129 -119
  41. package/framework/manifest.sig +1 -1
  42. package/framework/orchestrator/chati-router.js +252 -25
  43. package/framework/orchestrator/chati.md +238 -70
  44. package/framework/schemas/session.schema.json +1 -1
  45. package/framework/tasks/orchestrator-deviation.md +1 -1
  46. package/framework/tasks/orchestrator-escalate.md +1 -1
  47. package/framework/tasks/orchestrator-handoff.md +6 -6
  48. package/framework/tasks/orchestrator-health.md +5 -9
  49. package/framework/tasks/orchestrator-mode-switch.md +3 -7
  50. package/framework/tasks/orchestrator-resume.md +10 -14
  51. package/framework/tasks/orchestrator-route.md +3 -3
  52. package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
  53. package/framework/tasks/orchestrator-status.md +9 -9
  54. package/framework/tasks/orchestrator-suggest-mode.md +1 -1
  55. package/framework/tasks/qa-impl-consolidate.md +2 -2
  56. package/framework/tasks/qa-impl-performance-test.md +4 -4
  57. package/framework/tasks/qa-impl-regression-check.md +4 -4
  58. package/framework/tasks/qa-impl-sast-scan.md +1 -1
  59. package/framework/tasks/qa-impl-test-execute.md +1 -1
  60. package/framework/tasks/qa-impl-verdict.md +2 -2
  61. package/framework/tasks/qa-planning-consolidate.md +3 -3
  62. package/framework/tasks/qa-planning-coverage-plan.md +2 -2
  63. package/framework/tasks/qa-planning-gate-define.md +4 -4
  64. package/framework/tasks/qa-planning-risk-matrix.md +4 -4
  65. package/framework/tasks/qa-planning-test-strategy.md +2 -2
  66. package/node_modules/@chati/browser-capability/src/index.js +12 -2
  67. package/node_modules/@chati/planning/src/index.js +24 -4
  68. package/node_modules/@chati/provider-registry/src/index.js +11 -0
  69. package/node_modules/@chati/rail/src/index.js +1967 -83
  70. package/node_modules/@chati/release-lane/README.md +12 -8
  71. package/node_modules/@chati/release-lane/package.json +1 -1
  72. package/node_modules/@chati/release-lane/src/index.js +1426 -58
  73. package/node_modules/@chati/review-council/src/index.js +63 -0
  74. package/node_modules/@chati/tracking-clickup/README.md +13 -0
  75. package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
  76. package/package-artifact-manifest.json +1 -0
  77. package/package-artifact-manifest.sig +1 -0
  78. package/package.json +16 -7
  79. package/scripts/verify-real-harness-e2e.js +1581 -0
  80. package/src/config/framework-adapter.js +4 -4
  81. package/src/installer/core.js +148 -53
  82. package/src/installer/manifest.js +140 -9
  83. package/src/installer/package-artifact.js +249 -0
  84. package/src/installer/templates.js +65 -20
  85. package/src/installer-v2/catalog-client.js +531 -23
  86. package/src/installer-v2/index.js +91 -34
  87. package/src/installer-v2/installation-authority.js +327 -0
  88. package/src/installer-v2/provider-executable.js +247 -0
  89. package/src/installer-v2/wizard-installation.js +1 -1
  90. package/src/orchestrator/browser-runtime.js +44 -13
  91. package/src/orchestrator/cli.js +1545 -174
  92. package/src/orchestrator/clickup-projection.js +43 -1
  93. package/src/orchestrator/clickup-runtime.js +355 -39
  94. package/src/orchestrator/doctor.js +16 -3
  95. package/src/orchestrator/index.js +16 -0
  96. package/src/orchestrator/planning-runtime.js +20 -2
  97. package/src/orchestrator/rail-adjudication-evidence.js +234 -0
  98. package/src/orchestrator/rail-evidence-authority.js +147 -0
  99. package/src/orchestrator/rail-execution-evidence.js +45 -0
  100. package/src/orchestrator/rail-runtime.js +836 -56
  101. package/src/orchestrator/release-runtime.js +65 -6
  102. package/src/orchestrator/review-runtime.js +186 -41
  103. package/src/orchestrator/runtime-installation-v2.js +236 -20
  104. package/src/orchestrator/session-manager.js +1167 -53
  105. package/src/terminal/adapters/claude-adapter.js +3 -1
  106. package/src/terminal/adapters/codex-adapter.js +2 -0
  107. package/src/terminal/adapters/grok-adapter.js +7 -4
  108. package/src/terminal/handoff-parser.js +19 -1
  109. package/src/terminal/prompt-builder.js +9 -1
  110. package/src/terminal/provider-preflight.js +36 -3
  111. package/src/terminal/rail-execution-worktree.js +213 -0
  112. package/src/terminal/rail-prompts.js +169 -0
  113. package/src/terminal/rail-readonly-workspace.js +324 -0
  114. package/src/terminal/run-agent.js +384 -24
  115. package/src/terminal/run-parallel.js +5 -0
  116. package/src/terminal/run-rail-adjudication.js +323 -0
  117. package/src/terminal/run-rail-review.js +291 -0
  118. package/src/terminal/run-rail-rework.js +325 -0
  119. package/src/terminal/run-rail-task.js +380 -0
  120. package/src/terminal/run-team.js +5 -0
  121. package/src/terminal/spawner.js +1225 -77
  122. package/src/wizard/index.js +3 -2
@@ -49,10 +49,10 @@ export function stripLegacyBuildSurface(content) {
49
49
  const CODEX_CREDENTIAL_BOUNDARY = `
50
50
  ## Codex external provider credential boundary
51
51
 
52
- Commands returned in any \`spawn_command\`, \`parallel_spawn_command\` or
53
- \`parallel_spawn_commands\` start another provider CLI. Codex MUST execute each
54
- command through its native local execution path so the child CLI can access its
55
- existing local credentials:
52
+ Commands returned in any \`spawn_command\`, \`deferred_spawn_command\`,
53
+ \`parallel_spawn_command\` or \`parallel_spawn_commands\` start another provider
54
+ CLI. Codex MUST execute each command through its native local execution path so
55
+ the child CLI can access its existing local credentials:
56
56
 
57
57
  - If the active Codex host requires approval to leave a restricted workspace
58
58
  sandbox, request the narrow native approval it exposes for the exact command.
@@ -1,17 +1,18 @@
1
- import { mkdirSync, writeFileSync, copyFileSync, existsSync, readFileSync, readdirSync, statSync, rmSync } from 'fs';
1
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, rmSync } from 'fs';
2
2
  import { execFileSync } from 'child_process';
3
3
  import { join, dirname } from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import { createRequire } from 'module';
6
6
  import { IDE_CONFIGS, IDE_TO_PROVIDER } from '../config/ide-configs.js';
7
7
  import { mergeClaudeMCPConfig, upsertManagedMcpToml } from '../config/mcp-configs.js';
8
- import { generateSessionYaml, generateConfigYaml, generateProjectContext, generateClaudeMd, generateClaudeLocalMd, generateCodexSkill, generateGeminiRouter, generateGrokRouter, generateGeminiSessionLock, generateAgentsOverrideMd, generateCodexConstitutionGuardRules, generateCodexReadProtectionRules } from './templates.js';
8
+ import { generateSessionYaml, generateConfigYaml, generateProjectContext, generateClaudeMd, generateClaudeLocalMd, generateCodexSkill, generateGeminiRouter, generateGrokRouter, generateGrokGovernanceRules, generateGeminiSessionLock, generateAgentsOverrideMd, generateCodexConstitutionGuardRules, generateCodexReadProtectionRules } from './templates.js';
9
9
  import { generateContextFiles } from '../config/context-file-generator.js';
10
10
  import { adaptFrameworkFile, ADAPTABLE_FILES, stripLegacyBuildSurface } from '../config/framework-adapter.js';
11
11
  import { generateClaudeSettings } from '../config/claude-settings-generator.js';
12
12
  import { generateProviderOverlays } from './provider-overlay.js';
13
13
  import { applyInstalledPathReplacement } from './path-replacement.js';
14
- import { verifyManifest } from './manifest.js';
14
+ import { verifyFrameworkSource } from './manifest.js';
15
+ import { initializeSessionDocument } from '../orchestrator/session-manager.js';
15
16
  import * as yaml from 'js-yaml';
16
17
 
17
18
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -30,47 +31,35 @@ export async function installFramework(config) {
30
31
  const enabledProviders = allProviders?.length
31
32
  ? [...new Set(allProviders)]
32
33
  : [...new Set((selectedIDEs || []).map(ide => IDE_TO_PROVIDER[ide]).filter(Boolean))];
33
- // Always derive version from package.json (single source of truth)
34
+ // The signed source is always anchored to the package version. `config.version`
35
+ // remains an installer/test presentation override and cannot redefine trust.
36
+ let packageVersion = null;
37
+ try {
38
+ const pkgPath = join(__dirname, '..', '..', 'package.json');
39
+ packageVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
40
+ } catch { /* the integrity check below will still fail closed on source drift */ }
41
+
34
42
  let { version } = config;
35
- if (!version) {
36
- try {
37
- const pkgPath = join(__dirname, '..', '..', 'package.json');
38
- version = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
39
- } catch { version = 'unknown'; }
40
- }
43
+ if (!version) version = packageVersion || 'unknown';
41
44
 
42
45
  // 0. Verify framework signature (supply chain protection)
43
- const manifestPath = join(FRAMEWORK_SOURCE, 'manifest.json');
44
- const sigPath = join(FRAMEWORK_SOURCE, 'manifest.sig');
45
-
46
- if (existsSync(manifestPath) && existsSync(sigPath)) {
47
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
48
- const signature = readFileSync(sigPath, 'utf-8').trim();
49
- const result = verifyManifest(manifest, signature);
50
-
51
- if (!result.valid && result.reason === 'signature-mismatch') {
52
- throw new Error('Framework signature verification failed. Package may have been tampered with.');
53
- }
54
- }
55
-
56
- // 1. Create .chati/ session directory
57
- createDir(join(targetDir, '.chati'));
58
- const sessionPath = join(targetDir, '.chati', 'session.yaml');
59
- // Preserve existing session.yaml on reinstall/recovery (don't destroy pipeline state).
60
- // Only write fresh session if one does not exist.
61
- if (!existsSync(sessionPath)) {
62
- writeFileSync(
63
- sessionPath,
64
- generateSessionYaml({ projectName, projectType, language, selectedIDEs, selectedMCPs, allProviders: enabledProviders }),
65
- 'utf-8'
66
- );
46
+ const integrity = verifyFrameworkSource(FRAMEWORK_SOURCE, { expectedVersion: packageVersion });
47
+ if (!integrity.valid) {
48
+ throw new Error(`Framework integrity verification failed (${integrity.reason}). Package may have been tampered with.`);
67
49
  }
50
+ // 1. Create the canonical session through the lock-aware session manager.
51
+ // Reinstall/recovery always preserves an existing document.
52
+ const initialSession = initializeSessionDocument(
53
+ targetDir,
54
+ generateSessionYaml({ projectName, projectType, language, selectedIDEs, selectedMCPs, allProviders: enabledProviders }),
55
+ );
56
+ if (initialSession.error) throw new Error(initialSession.error);
68
57
 
69
58
  // 2. Create .chati.dev/ framework directory (hidden — dot prefix)
70
59
  // The source is chati.dev/ in the monorepo; installed as .chati.dev/ in user projects.
71
60
  // Text replacement during copy handles all internal path references.
72
61
  const frameworkDir = join(targetDir, '.chati.dev');
73
- createDir(frameworkDir);
62
+ createSafeManagedDirectory(frameworkDir);
74
63
 
75
64
  // 2b. Create artifacts/ at PROJECT ROOT (visible to user — this is their output)
76
65
  const artifactsDir = join(targetDir, 'artifacts');
@@ -101,7 +90,7 @@ export async function installFramework(config) {
101
90
  ];
102
91
 
103
92
  for (const dir of frameworkDirs) {
104
- createDir(join(frameworkDir, dir));
93
+ createSafeManagedDirectory(join(frameworkDir, dir));
105
94
  }
106
95
 
107
96
  // Create .chati/memories/ directory tree for Memory Layer
@@ -162,6 +151,9 @@ export async function installFramework(config) {
162
151
  join(__dirname, '..', '..', 'framework', 'hooks'),
163
152
  join(frameworkDir, 'framework', 'hooks')
164
153
  );
154
+ if (isRuntimeV2) {
155
+ rmSync(join(frameworkDir, 'framework', 'hooks', 'post-dev.js'), { force: true });
156
+ }
165
157
 
166
158
  // Also bundle production deps so the CLI can import js-yaml/chalk/etc. at runtime
167
159
  // without relying on a node_modules tree in the user's project.
@@ -330,11 +322,18 @@ function pruneLegacyExecutionSurface(frameworkDir, providers) {
330
322
  }
331
323
 
332
324
  export function ensureGitRepository(targetDir) {
325
+ const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('GIT_')));
326
+ const gitEnvironment = {
327
+ ...environment,
328
+ GIT_CONFIG_NOSYSTEM: '1',
329
+ GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
330
+ GIT_TERMINAL_PROMPT: '0',
331
+ };
333
332
  try {
334
- execFileSync('git', ['-C', targetDir, 'rev-parse', '--show-toplevel'], { stdio: 'ignore' });
333
+ execFileSync('git', ['-C', targetDir, 'rev-parse', '--show-toplevel'], { stdio: 'ignore', env: gitEnvironment });
335
334
  return { initialized: false };
336
335
  } catch {
337
- execFileSync('git', ['-C', targetDir, 'init', '-q'], { stdio: 'ignore' });
336
+ execFileSync('git', ['-C', targetDir, 'init', '-q'], { stdio: 'ignore', env: gitEnvironment });
338
337
  return { initialized: true };
339
338
  }
340
339
  }
@@ -401,7 +400,7 @@ const TEXT_EXTENSIONS = new Set(['.md', '.js', '.yaml', '.yml', '.json', '.html'
401
400
  */
402
401
  function copyDirRecursive(srcDir, destDir, provider, relBase = '') {
403
402
  if (!existsSync(srcDir)) return;
404
- createDir(destDir);
403
+ createSafeManagedDirectory(destDir);
405
404
 
406
405
  const entries = readdirSync(srcDir, { withFileTypes: true });
407
406
  for (const entry of entries) {
@@ -425,10 +424,10 @@ function copyDirRecursive(srcDir, destDir, provider, relBase = '') {
425
424
  if (provider && ADAPTABLE_FILES.has(relPath)) {
426
425
  content = adaptFrameworkFile(content, relPath, provider);
427
426
  }
428
- writeFileSync(destPath, content, 'utf-8');
427
+ writeSafeManagedFile(destPath, content, 'utf-8');
429
428
  } else {
430
429
  // Binary file: direct copy
431
- copyFileSync(srcPath, destPath);
430
+ copySafeManagedFile(srcPath, destPath);
432
431
  }
433
432
  }
434
433
  }
@@ -451,13 +450,13 @@ export function copyFrameworkFiles(destDir, provider = null) {
451
450
  const src = join(FRAMEWORK_SOURCE, file);
452
451
  if (!existsSync(src)) continue;
453
452
  const dest = join(destDir, file);
454
- createDir(dirname(dest));
453
+ createSafeManagedDirectory(dirname(dest));
455
454
  let content = readFileSync(src, 'utf-8');
456
455
  content = applyInstalledPathReplacement(content);
457
456
  if (provider && ADAPTABLE_FILES.has(file)) {
458
457
  content = adaptFrameworkFile(content, file, provider);
459
458
  }
460
- writeFileSync(dest, content, 'utf-8');
459
+ writeSafeManagedFile(dest, content, 'utf-8');
461
460
  }
462
461
 
463
462
  // Copy each framework directory recursively
@@ -512,6 +511,14 @@ Parse the JSON output. This single command already handled: license validation,
512
511
 
513
512
  **The \`pipeline\` object contains the full JSON from \`orchestrate next\`.** Use its fields (agent_file, spawn_command, handoff_status, etc.) as documented in the orchestrator.
514
513
 
514
+ **Pending interaction invariant:** If \`action\` is \`resume_pending_interaction\`,
515
+ do not execute a provider command. Display exactly \`pipeline.question\`, with
516
+ no preface, summary or paraphrase. After the user answers, write
517
+ \`{"response":"<exact user response>"}\` to \`pipeline.response_file\` using the
518
+ native file-write tool, never a shell command. Then execute
519
+ \`pipeline.deferred_spawn_command\` exactly once and unchanged. Never execute a
520
+ command merely because a command-shaped field is present.
521
+
515
522
  **User input:** $ARGUMENTS
516
523
  `;
517
524
  writeFileSync(join(targetDir, '.claude', 'commands', 'chati.md'), routerContent, 'utf-8');
@@ -544,14 +551,14 @@ Parse the JSON output. This single command already handled: license validation,
544
551
  writeFileSync(join(targetDir, '.codex', 'rules', 'constitution-guard.rules'), generateCodexConstitutionGuardRules(), 'utf-8');
545
552
  writeFileSync(join(targetDir, '.codex', 'rules', 'read-protection.rules'), generateCodexReadProtectionRules(), 'utf-8');
546
553
 
547
- // .codex/hooks.json — license-guard wired to UserPromptSubmit (per-turn enforcement).
548
- // Codex hooks require [features] hooks = true in the active project config.
549
- // Without it, license enforcement only happens at skill entry and the router,
550
- // entry a long-running terminal session would not be re-validated.
554
+ // .codex/hooks.json — use Codex's native lifecycle surface for the same
555
+ // controller boundaries enforced in the other enabled harnesses. In
556
+ // particular, `PreToolUse` observes Bash and local mutation tools, not
557
+ // merely the prompt entrypoint. Codex hooks require [features] hooks = true
558
+ // in the active project config.
551
559
  writeFileSync(
552
560
  join(targetDir, '.codex', 'hooks.json'),
553
561
  JSON.stringify({
554
- $comment: 'chati.dev Codex lifecycle hooks. Requires [features] hooks = true.',
555
562
  hooks: {
556
563
  UserPromptSubmit: [
557
564
  {
@@ -564,6 +571,34 @@ Parse the JSON output. This single command already handled: license validation,
564
571
  ],
565
572
  },
566
573
  ],
574
+ PreToolUse: [
575
+ {
576
+ matcher: '^Bash$',
577
+ hooks: [
578
+ {
579
+ type: 'command',
580
+ command: 'node .chati.dev/hooks/git-push-authority.js',
581
+ },
582
+ ],
583
+ },
584
+ {
585
+ matcher: '^(?:apply_patch|Edit|Write|NotebookEdit)$',
586
+ hooks: [
587
+ {
588
+ type: 'command',
589
+ command: 'node .chati.dev/hooks/mode-governance.js',
590
+ },
591
+ {
592
+ type: 'command',
593
+ command: 'node .chati.dev/hooks/team-quality-gate.js',
594
+ },
595
+ {
596
+ type: 'command',
597
+ command: 'node .chati.dev/hooks/reasoning-escalator.js',
598
+ },
599
+ ],
600
+ },
601
+ ],
567
602
  },
568
603
  }, null, 2) + '\n',
569
604
  'utf-8'
@@ -599,6 +634,8 @@ Parse the JSON output. This single command already handled: license validation,
599
634
  } else if (ideKey === 'grok-cli') {
600
635
  writeFileSync(join(targetDir, '.grok', 'commands', 'chati.md'), generateGrokRouter({ orchestratorPath }), 'utf-8');
601
636
  writeFileSync(join(targetDir, '.grok', 'session-lock.md'), generateClaudeLocalMd(), 'utf-8');
637
+ createDir(join(targetDir, '.grok', 'rules'));
638
+ writeFileSync(join(targetDir, '.grok', 'rules', 'chati-governance.md'), generateGrokGovernanceRules(), 'utf-8');
602
639
  const grokConfigPath = join(targetDir, '.grok', 'config.toml');
603
640
  if (selectedMCPs.length > 0 || existsSync(grokConfigPath)) {
604
641
  const existingConfig = existsSync(grokConfigPath) ? readFileSync(grokConfigPath, 'utf-8') : '';
@@ -699,6 +736,7 @@ export function updateGitignore(targetDir, selectedIDEs) {
699
736
  '',
700
737
  '# Runtime state (session, teams, timeline, transient memories)',
701
738
  '.chati/session.yaml',
739
+ '.chati/runtime/',
702
740
  '.chati/teams/',
703
741
  '.chati/timeline.json',
704
742
  '.chati/memories/*/session/',
@@ -745,6 +783,7 @@ export function updateGitignore(targetDir, selectedIDEs) {
745
783
  '',
746
784
  '# Grok provider framework files',
747
785
  '.grok/commands/',
786
+ '.grok/rules/',
748
787
  '.grok/session-lock.md',
749
788
  );
750
789
  }
@@ -961,6 +1000,58 @@ function createDir(dir) {
961
1000
  }
962
1001
  }
963
1002
 
1003
+ function unsafeManagedPath(path, message) {
1004
+ const error = new Error(`${message}: ${path}`);
1005
+ error.code = 'UNSAFE_INSTALLATION_TARGET';
1006
+ throw error;
1007
+ }
1008
+
1009
+ /** Managed framework paths must never resolve through a user-provided link. */
1010
+ function createSafeManagedDirectory(dir) {
1011
+ const missing = [];
1012
+ let current = join(dir);
1013
+ for (;;) {
1014
+ try {
1015
+ const stat = lstatSync(current);
1016
+ if (stat.isSymbolicLink() || !stat.isDirectory()) unsafeManagedPath(current, 'managed directory must be a real directory');
1017
+ break;
1018
+ } catch (error) {
1019
+ if (error?.code !== 'ENOENT') throw error;
1020
+ missing.push(current);
1021
+ const parent = dirname(current);
1022
+ if (parent === current) unsafeManagedPath(current, 'could not resolve a managed directory ancestor');
1023
+ current = parent;
1024
+ }
1025
+ }
1026
+ for (const path of missing.reverse()) {
1027
+ try { mkdirSync(path); }
1028
+ catch (error) { if (error?.code !== 'EEXIST') throw error; }
1029
+ const stat = lstatSync(path);
1030
+ if (stat.isSymbolicLink() || !stat.isDirectory()) unsafeManagedPath(path, 'managed directory changed while being created');
1031
+ }
1032
+ }
1033
+
1034
+ function assertSafeManagedFileDestination(path) {
1035
+ try {
1036
+ const stat = lstatSync(path);
1037
+ if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) {
1038
+ unsafeManagedPath(path, 'managed file destination must be a single-link regular file');
1039
+ }
1040
+ } catch (error) {
1041
+ if (error?.code !== 'ENOENT') throw error;
1042
+ }
1043
+ }
1044
+
1045
+ function writeSafeManagedFile(path, content, encoding) {
1046
+ assertSafeManagedFileDestination(path);
1047
+ writeFileSync(path, content, encoding);
1048
+ }
1049
+
1050
+ function copySafeManagedFile(source, destination) {
1051
+ assertSafeManagedFileDestination(destination);
1052
+ copyFileSync(source, destination);
1053
+ }
1054
+
964
1055
  /**
965
1056
  * Recursively copy CLI source (excluding tests, node_modules, hidden files).
966
1057
  * Used to bundle the CLI source into the user's .chati.dev/_cli/ folder so
@@ -968,7 +1059,7 @@ function createDir(dir) {
968
1059
  */
969
1060
  export function copyCliSource(srcDir, destDir) {
970
1061
  if (!existsSync(srcDir)) return;
971
- createDir(destDir);
1062
+ createSafeManagedDirectory(destDir);
972
1063
  const entries = readdirSync(srcDir);
973
1064
  for (const entry of entries) {
974
1065
  if (entry.startsWith('.')) continue;
@@ -980,7 +1071,7 @@ export function copyCliSource(srcDir, destDir) {
980
1071
  if (stat.isDirectory()) {
981
1072
  copyCliSource(srcPath, destPath);
982
1073
  } else {
983
- copyFileSync(srcPath, destPath);
1074
+ copySafeManagedFile(srcPath, destPath);
984
1075
  }
985
1076
  }
986
1077
  }
@@ -990,7 +1081,7 @@ export function copyCliSource(srcDir, destDir) {
990
1081
  */
991
1082
  function copyDirVerbatim(srcDir, destDir) {
992
1083
  if (!existsSync(srcDir)) return;
993
- createDir(destDir);
1084
+ createSafeManagedDirectory(destDir);
994
1085
  for (const entry of readdirSync(srcDir)) {
995
1086
  const srcPath = join(srcDir, entry);
996
1087
  const destPath = join(destDir, entry);
@@ -999,7 +1090,11 @@ function copyDirVerbatim(srcDir, destDir) {
999
1090
  if (stat.isDirectory()) {
1000
1091
  copyDirVerbatim(srcPath, destPath);
1001
1092
  } else {
1002
- try { copyFileSync(srcPath, destPath); } catch { /* skip unreadable */ }
1093
+ try { copySafeManagedFile(srcPath, destPath); }
1094
+ catch (error) {
1095
+ if (error?.code === 'UNSAFE_INSTALLATION_TARGET') throw error;
1096
+ /* skip unreadable source material */
1097
+ }
1003
1098
  }
1004
1099
  }
1005
1100
  }
@@ -1027,7 +1122,7 @@ export function copyCliDependencies(pkgDir, destNodeModules) {
1027
1122
  // Resolve each dep from the package.json location (works for hoisted + nested layouts).
1028
1123
 
1029
1124
  const require = createRequire(join(pkgDir, 'package.json'));
1030
- createDir(destNodeModules);
1125
+ createSafeManagedDirectory(destNodeModules);
1031
1126
 
1032
1127
  const visited = new Set();
1033
1128
  const queue = [...deps];
@@ -1,5 +1,7 @@
1
- import { readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
2
- import { join, dirname } from 'path';
1
+ import {
2
+ readFileSync, writeFileSync, existsSync, statSync, lstatSync, mkdirSync, readdirSync,
3
+ } from 'fs';
4
+ import { join, dirname, posix } from 'path';
3
5
  import { fileURLToPath } from 'url';
4
6
  import { verify as cryptoVerify, createPublicKey } from 'crypto';
5
7
  import { hashFile } from './file-hasher.js';
@@ -139,15 +141,144 @@ export function compareManifests(oldManifest, newManifest) {
139
141
  * @returns {{ valid: boolean, reason: string }}
140
142
  */
141
143
  export function verifyManifest(manifest, signatureBase64, publicKeyPem = null) {
142
- // publicKeyPem is a test seam: production callers use the embedded key.
143
- const key = publicKeyPem ? createPublicKey(publicKeyPem) : SIGNING_PUBLIC_KEY;
144
- if (!key) return { valid: false, reason: 'no-public-key' };
144
+ try {
145
+ // publicKeyPem is a test seam: production callers use the embedded key.
146
+ const key = publicKeyPem ? createPublicKey(publicKeyPem) : SIGNING_PUBLIC_KEY;
147
+ if (!key) return { valid: false, reason: 'no-public-key' };
148
+ if (typeof signatureBase64 !== 'string'
149
+ || !/^[A-Za-z0-9+/]+={0,2}$/.test(signatureBase64)
150
+ || Buffer.from(signatureBase64, 'base64').length !== 64) {
151
+ return { valid: false, reason: 'invalid-signature-encoding' };
152
+ }
153
+
154
+ const manifestJson = serializeManifest(manifest);
155
+ const signature = Buffer.from(signatureBase64, 'base64');
156
+ const valid = cryptoVerify(null, Buffer.from(manifestJson), key, signature);
157
+ return { valid, reason: valid ? 'ok' : 'signature-mismatch' };
158
+ } catch {
159
+ return { valid: false, reason: 'manifest-verification-error' };
160
+ }
161
+ }
162
+
163
+ function isCanonicalManifestPath(filePath) {
164
+ return typeof filePath === 'string'
165
+ && filePath.length > 0
166
+ && !filePath.includes('\\')
167
+ && !filePath.includes('\0')
168
+ && !posix.isAbsolute(filePath)
169
+ && posix.normalize(filePath) === filePath
170
+ && filePath !== '.'
171
+ && !filePath.startsWith('../')
172
+ && !['manifest.json', 'manifest.sig'].includes(filePath);
173
+ }
174
+
175
+ function collectFrameworkFiles(rootDir) {
176
+ const files = new Map();
177
+
178
+ function walk(directory, segments = []) {
179
+ const entries = readdirSync(directory, { withFileTypes: true })
180
+ .sort((left, right) => left.name.localeCompare(right.name));
181
+ for (const entry of entries) {
182
+ if (segments.length === 0 && ['manifest.json', 'manifest.sig'].includes(entry.name)) continue;
183
+ const entrySegments = [...segments, entry.name];
184
+ const relativePath = entrySegments.join('/');
185
+ const absolutePath = join(rootDir, ...entrySegments);
186
+ const stat = lstatSync(absolutePath);
187
+ if (stat.isSymbolicLink()) return { valid: false, reason: 'framework-symlink', file: relativePath };
188
+ if (stat.isDirectory()) {
189
+ const nested = walk(absolutePath, entrySegments);
190
+ if (!nested.valid) return nested;
191
+ } else if (stat.isFile()) {
192
+ if (stat.nlink !== 1) return { valid: false, reason: 'framework-hardlink', file: relativePath };
193
+ files.set(relativePath, { absolutePath, stat });
194
+ } else {
195
+ return { valid: false, reason: 'framework-special-file', file: relativePath };
196
+ }
197
+ }
198
+ return { valid: true };
199
+ }
145
200
 
146
- const manifestJson = serializeManifest(manifest);
147
- const signature = Buffer.from(signatureBase64, 'base64');
201
+ const rootStat = lstatSync(rootDir);
202
+ if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
203
+ return { valid: false, reason: 'invalid-framework-root', files };
204
+ }
205
+ const result = walk(rootDir);
206
+ return { ...result, files };
207
+ }
208
+
209
+ /**
210
+ * Verify that the signed manifest covers the exact framework file set and
211
+ * that every signed size/hash still matches the bytes about to be installed.
212
+ */
213
+ export function verifyManifestFiles(rootDir, manifest) {
214
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)
215
+ || !manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
216
+ return { valid: false, reason: 'invalid-manifest-schema' };
217
+ }
148
218
 
149
- const valid = cryptoVerify(null, Buffer.from(manifestJson), key, signature);
150
- return { valid, reason: valid ? 'ok' : 'signature-mismatch' };
219
+ const expected = new Map();
220
+ for (const [filePath, record] of Object.entries(manifest.files)) {
221
+ if (!isCanonicalManifestPath(filePath)) {
222
+ return { valid: false, reason: 'unsafe-manifest-path', file: filePath };
223
+ }
224
+ if (!record || typeof record !== 'object' || Array.isArray(record)
225
+ || !/^[a-f0-9]{64}$/.test(record.hash)
226
+ || !Number.isSafeInteger(record.size) || record.size < 0
227
+ || typeof record.type !== 'string') {
228
+ return { valid: false, reason: 'invalid-manifest-record', file: filePath };
229
+ }
230
+ expected.set(filePath, record);
231
+ }
232
+
233
+ let collected;
234
+ try { collected = collectFrameworkFiles(rootDir); }
235
+ catch (error) {
236
+ return { valid: false, reason: 'framework-read-error', detail: error.message };
237
+ }
238
+ if (!collected.valid) return collected;
239
+
240
+ for (const filePath of expected.keys()) {
241
+ if (!collected.files.has(filePath)) return { valid: false, reason: 'manifest-file-missing', file: filePath };
242
+ }
243
+ for (const filePath of collected.files.keys()) {
244
+ if (!expected.has(filePath)) return { valid: false, reason: 'unsigned-framework-file', file: filePath };
245
+ }
246
+
247
+ for (const [filePath, record] of expected) {
248
+ const actual = collected.files.get(filePath);
249
+ if (actual.stat.size !== record.size) return { valid: false, reason: 'framework-size-mismatch', file: filePath };
250
+ try {
251
+ if (hashFile(actual.absolutePath) !== record.hash) {
252
+ return { valid: false, reason: 'framework-hash-mismatch', file: filePath };
253
+ }
254
+ } catch (error) {
255
+ return { valid: false, reason: 'framework-read-error', file: filePath, detail: error.message };
256
+ }
257
+ }
258
+ return { valid: true, reason: 'ok', fileCount: expected.size };
259
+ }
260
+
261
+ /** Verify the complete signed framework source before the installer writes. */
262
+ export function verifyFrameworkSource(rootDir, { publicKeyPem = null, expectedVersion = null } = {}) {
263
+ const manifestPath = join(rootDir, 'manifest.json');
264
+ const signaturePath = join(rootDir, 'manifest.sig');
265
+ if (!existsSync(manifestPath)) return { valid: false, reason: 'manifest-missing' };
266
+ if (!existsSync(signaturePath)) return { valid: false, reason: 'signature-missing' };
267
+
268
+ let manifest;
269
+ let signature;
270
+ try {
271
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
272
+ signature = readFileSync(signaturePath, 'utf8').trim();
273
+ } catch (error) {
274
+ return { valid: false, reason: 'manifest-read-error', detail: error.message };
275
+ }
276
+ const signatureResult = verifyManifest(manifest, signature, publicKeyPem);
277
+ if (!signatureResult.valid) return signatureResult;
278
+ if (expectedVersion !== null && manifest.version !== expectedVersion) {
279
+ return { valid: false, reason: 'manifest-version-mismatch' };
280
+ }
281
+ return verifyManifestFiles(rootDir, manifest);
151
282
  }
152
283
 
153
284
  /**