chati-dev 4.5.16 → 4.5.27

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 +235 -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
@@ -1,9 +1,12 @@
1
- import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
1
+ import { createPublicKey, randomUUID, verify as cryptoVerify } from 'node:crypto';
2
+ import {
3
+ closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync,
4
+ mkdirSync, openSync, readFileSync, readSync, realpathSync, renameSync, unlinkSync, writeSync,
5
+ } from 'node:fs';
6
+ import { basename, dirname, join, parse, relative, resolve, sep } from 'node:path';
4
7
  import { isIP } from 'node:net';
5
8
  import { fileURLToPath } from 'node:url';
6
- import { canonicalSerialize, ContractError } from '@chati/core';
9
+ import { canonicalize, canonicalSerialize, ContractError, sha256 } from '@chati/core';
7
10
  import { validateCapabilitySnapshot } from '@chati/provider-registry';
8
11
 
9
12
  const moduleDir = dirname(fileURLToPath(import.meta.url));
@@ -13,11 +16,406 @@ export const CATALOG_CACHE_PATH = '.chati/v2/cache/model-catalog.json';
13
16
  export const BUNDLED_CATALOG_PATH = join(moduleDir, 'model-catalog.json');
14
17
  export const BUNDLED_CATALOG_SIGNATURE_PATH = join(moduleDir, 'model-catalog.sig');
15
18
  const PUBLIC_KEY_PATH = join(moduleDir, '..', 'installer', 'signing-public-key.pem');
19
+ const INSTALLATION_MAX_BYTES = 4 * 1024 * 1024;
20
+ const CATALOG_CACHE_MAX_BYTES = 4 * 1024 * 1024;
21
+ const INSTALLATION_STORAGE_SECURITY_TEST_HOOKS = Symbol.for('chati.installation-storage.security-test-hooks');
22
+
23
+ function runInstallationStorageSecurityTestHook(name, details) {
24
+ const hook = globalThis[INSTALLATION_STORAGE_SECURITY_TEST_HOOKS]?.[name];
25
+ if (typeof hook === 'function') hook(details);
26
+ }
16
27
 
17
28
  function catalogError(code, message, details = {}) {
18
29
  return new ContractError(code, message, details);
19
30
  }
20
31
 
32
+ function sameIdentity(left, right) {
33
+ return left?.dev === right?.dev && left?.ino === right?.ino;
34
+ }
35
+
36
+ function sameSnapshot(left, right) {
37
+ return sameIdentity(left, right)
38
+ && left?.size === right?.size
39
+ && left?.mode === right?.mode
40
+ && left?.nlink === right?.nlink
41
+ && left?.mtimeNs === right?.mtimeNs
42
+ && left?.ctimeNs === right?.ctimeNs;
43
+ }
44
+
45
+ function descriptorDirectoryPath(descriptor) {
46
+ return process.platform === 'linux' ? `/proc/self/fd/${descriptor}` : null;
47
+ }
48
+
49
+ function linuxDescriptorAnchor(absolute) {
50
+ if (process.platform !== 'linux') return null;
51
+ const match = absolute.match(/^\/proc\/self\/fd\/([1-9][0-9]*)(?:\/(.*))?$/);
52
+ if (!match) return null;
53
+ const descriptor = Number(match[1]);
54
+ let stat;
55
+ try { stat = fstatSync(descriptor); }
56
+ catch { throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory anchor is not open'); }
57
+ if (!stat.isDirectory()) {
58
+ throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory anchor must be a real directory');
59
+ }
60
+ return Object.freeze({ descriptor, segments: (match[2] || '').split(sep).filter(Boolean) });
61
+ }
62
+
63
+ function linuxWorkingDirectoryAnchor(absolute) {
64
+ if (process.platform !== 'linux') return null;
65
+ let descriptor;
66
+ try {
67
+ descriptor = openSync('.', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
68
+ const workingDirectory = realpathSync(`/proc/self/fd/${descriptor}`);
69
+ const remainder = relative(workingDirectory, absolute);
70
+ if (remainder === '..' || remainder.startsWith(`..${sep}`)) {
71
+ closeSync(descriptor);
72
+ return null;
73
+ }
74
+ return Object.freeze({ descriptor, segments: remainder.split(sep).filter(Boolean) });
75
+ } catch {
76
+ if (descriptor !== undefined) closeSync(descriptor);
77
+ return null;
78
+ }
79
+ }
80
+
81
+ function openPinnedInstallationDirectory(path, { create = false, allowDirectDirectoryFallback = false } = {}) {
82
+ const absolute = resolve(path);
83
+ const root = parse(absolute).root;
84
+ const descriptorAnchor = linuxDescriptorAnchor(absolute);
85
+ const workingDirectoryAnchor = descriptorAnchor ? null : linuxWorkingDirectoryAnchor(absolute);
86
+ const anchor = descriptorAnchor || workingDirectoryAnchor;
87
+ const segments = anchor ? anchor.segments : absolute.slice(root.length).split(sep).filter(Boolean);
88
+ if (process.platform === 'linux') {
89
+ let descriptor;
90
+ let directDirectoryFallback = false;
91
+ try {
92
+ if (anchor) {
93
+ descriptor = openSync(descriptorDirectoryPath(anchor.descriptor), constants.O_RDONLY | (constants.O_DIRECTORY || 0));
94
+ } else {
95
+ try {
96
+ descriptor = openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
97
+ } catch (error) {
98
+ // A strict sandbox may grant a pre-authorized external directory
99
+ // without granting the root descriptor needed for a normal
100
+ // component-by-component traversal. Keep this narrowly opt-in: the
101
+ // final directory remains O_NOFOLLOW-pinned, and malformed chains
102
+ // never take the fallback.
103
+ if (!allowDirectDirectoryFallback || error?.code !== 'EACCES') throw error;
104
+ descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
105
+ directDirectoryFallback = true;
106
+ }
107
+ }
108
+ } finally {
109
+ if (workingDirectoryAnchor) closeSync(workingDirectoryAnchor.descriptor);
110
+ }
111
+ try {
112
+ for (const segment of directDirectoryFallback ? [] : segments) {
113
+ const parentPath = descriptorDirectoryPath(descriptor);
114
+ const childPath = join(parentPath, segment);
115
+ let childDescriptor;
116
+ try {
117
+ childDescriptor = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
118
+ } catch (error) {
119
+ if (error?.code !== 'ENOENT' || !create) {
120
+ if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
121
+ throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory chain must contain only real directories');
122
+ }
123
+ throw error;
124
+ }
125
+ try { mkdirSync(childPath, { mode: 0o700 }); }
126
+ catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
127
+ childDescriptor = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
128
+ }
129
+ const childStat = fstatSync(childDescriptor);
130
+ if (!childStat.isDirectory()) {
131
+ closeSync(childDescriptor);
132
+ throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory chain must contain only real directories');
133
+ }
134
+ closeSync(descriptor);
135
+ descriptor = childDescriptor;
136
+ }
137
+ return Object.freeze({
138
+ absolute,
139
+ descriptor,
140
+ identity: fstatSync(descriptor),
141
+ reference: descriptorDirectoryPath(descriptor),
142
+ });
143
+ } catch (error) {
144
+ closeSync(descriptor);
145
+ throw error;
146
+ }
147
+ }
148
+ let current = root;
149
+ for (const segment of segments) {
150
+ current = resolve(current, segment);
151
+ let stat;
152
+ try { stat = lstatSync(current); }
153
+ catch (error) {
154
+ if (error?.code !== 'ENOENT' || !create) throw error;
155
+ try { mkdirSync(current, { mode: 0o700 }); }
156
+ catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
157
+ stat = lstatSync(current);
158
+ }
159
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory chain must contain only real directories');
160
+ }
161
+ const descriptor = openSync(absolute, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
162
+ return Object.freeze({ absolute, descriptor, identity: fstatSync(descriptor), reference: absolute });
163
+ }
164
+
165
+ function assertPinnedInstallationDirectory(directory) {
166
+ const current = fstatSync(directory.descriptor);
167
+ if (!current.isDirectory() || !sameIdentity(current, directory.identity)) {
168
+ throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory descriptor changed');
169
+ }
170
+ if (directory.reference === directory.absolute) {
171
+ const pathStat = lstatSync(directory.absolute);
172
+ if (pathStat.isSymbolicLink() || !pathStat.isDirectory() || !sameIdentity(pathStat, directory.identity)) {
173
+ throw catalogError('UNSAFE_INSTALLATION_DIRECTORY', 'installation storage directory changed during the operation');
174
+ }
175
+ }
176
+ }
177
+
178
+ function installationEntryPath(directory, name) {
179
+ if (basename(name) !== name || name === '.' || name === '..') {
180
+ throw catalogError('UNSAFE_INSTALLATION_FILE', 'installation storage entry must be a single path segment');
181
+ }
182
+ return join(directory.reference, name);
183
+ }
184
+
185
+ function readSafeInstallationEntry(directory, name, {
186
+ allowMissing = false,
187
+ unsafeCode = 'UNSAFE_INSTALLATION_FILE',
188
+ sizeCode = 'INSTALLATION_ARTIFACT_SIZE_LIMIT',
189
+ label = 'installation artifact',
190
+ maxBytes = INSTALLATION_MAX_BYTES,
191
+ encoding = 'utf8',
192
+ } = {}) {
193
+ let descriptor;
194
+ const path = installationEntryPath(directory, name);
195
+ try {
196
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
197
+ } catch (error) {
198
+ if (allowMissing && error?.code === 'ENOENT') return null;
199
+ if (['ELOOP', 'ENXIO', 'EISDIR'].includes(error?.code)) {
200
+ throw catalogError(unsafeCode, `${label} must not be a symlink, FIFO or directory`);
201
+ }
202
+ throw error;
203
+ }
204
+ try {
205
+ const stat = fstatSync(descriptor, { bigint: true });
206
+ if (!stat.isFile() || stat.nlink !== 1n) {
207
+ throw catalogError(unsafeCode, `${label} must be a regular single-link file`);
208
+ }
209
+ if (stat.size > BigInt(maxBytes)) {
210
+ throw catalogError(sizeCode, `${label} exceeds the 4 MiB safety limit`);
211
+ }
212
+ runInstallationStorageSecurityTestHook('afterFileStat', { path, label, maxBytes });
213
+ const chunks = [];
214
+ let total = 0;
215
+ while (true) {
216
+ const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
217
+ const bytesRead = readSync(descriptor, chunk, 0, chunk.length, null);
218
+ if (bytesRead === 0) break;
219
+ total += bytesRead;
220
+ if (total > maxBytes) throw catalogError(sizeCode, `${label} exceeds the 4 MiB safety limit`);
221
+ chunks.push(chunk.subarray(0, bytesRead));
222
+ }
223
+ const after = fstatSync(descriptor, { bigint: true });
224
+ if (!sameSnapshot(stat, after)) {
225
+ throw catalogError('INSTALLATION_STORAGE_CONFLICT', `${label} changed while it was being read`);
226
+ }
227
+ const bytes = Buffer.concat(chunks, total);
228
+ return { content: encoding === null ? bytes : bytes.toString(encoding), stat };
229
+ } finally { closeSync(descriptor); }
230
+ }
231
+
232
+ function readSafeInstallationFile(path, options = {}) {
233
+ let directory;
234
+ try {
235
+ directory = openPinnedInstallationDirectory(dirname(path), {
236
+ allowDirectDirectoryFallback: options.allowDirectDirectoryFallback === true,
237
+ });
238
+ }
239
+ catch (error) {
240
+ if (options.allowMissing && error?.code === 'ENOENT') return null;
241
+ throw error;
242
+ }
243
+ try { return readSafeInstallationEntry(directory, basename(path), options); }
244
+ finally { closeSync(directory.descriptor); }
245
+ }
246
+
247
+ function writeAll(descriptor, bytes) {
248
+ let offset = 0;
249
+ while (offset < bytes.length) offset += writeSync(descriptor, bytes, offset, bytes.length - offset);
250
+ }
251
+
252
+ function persistImmutableInstallationBackup(path, content, {
253
+ conflictCode = 'INSTALLATION_BACKUP_CONFLICT',
254
+ label = 'installation backup',
255
+ maxBytes = INSTALLATION_MAX_BYTES,
256
+ } = {}) {
257
+ const bytes = Buffer.isBuffer(content) ? content : Buffer.from(content);
258
+ if (bytes.length > maxBytes) {
259
+ throw catalogError('INSTALLATION_ARTIFACT_SIZE_LIMIT', `${label} exceeds the 4 MiB safety limit`);
260
+ }
261
+ const directory = openPinnedInstallationDirectory(dirname(path), { create: true });
262
+ const name = basename(path);
263
+ const existing = readSafeInstallationEntry(directory, name, { allowMissing: true, encoding: null, label, maxBytes });
264
+ if (existing !== null) {
265
+ closeSync(directory.descriptor);
266
+ if (!existing.content.equals(bytes)) throw catalogError(conflictCode, `${label} exists with different material`);
267
+ return false;
268
+ }
269
+ const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
270
+ const temporary = installationEntryPath(directory, temporaryName);
271
+ const destination = installationEntryPath(directory, name);
272
+ let temporaryDescriptor;
273
+ try {
274
+ temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
275
+ writeAll(temporaryDescriptor, bytes);
276
+ fsyncSync(temporaryDescriptor);
277
+ closeSync(temporaryDescriptor);
278
+ temporaryDescriptor = undefined;
279
+ runInstallationStorageSecurityTestHook('beforeImmutableCommit', { path, directory: directory.absolute });
280
+ assertPinnedInstallationDirectory(directory);
281
+ try { linkSync(temporary, destination); }
282
+ catch (error) {
283
+ if (error?.code !== 'EEXIST') throw error;
284
+ const raced = readSafeInstallationEntry(directory, name, { encoding: null, label, maxBytes });
285
+ if (!raced.content.equals(bytes)) throw catalogError(conflictCode, `${label} raced with different material`);
286
+ return false;
287
+ }
288
+ return true;
289
+ } finally {
290
+ if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
291
+ try { unlinkSync(temporary); } catch { /* preserve the primary result */ }
292
+ fsyncSync(directory.descriptor);
293
+ closeSync(directory.descriptor);
294
+ }
295
+ }
296
+
297
+ function replaceInstallationArtifact(path, content, source) {
298
+ const bytes = Buffer.from(content);
299
+ if (bytes.length > INSTALLATION_MAX_BYTES) {
300
+ throw catalogError('INSTALLATION_ARTIFACT_SIZE_LIMIT', 'upgraded installation artifact exceeds the 4 MiB safety limit');
301
+ }
302
+ const directory = openPinnedInstallationDirectory(dirname(path));
303
+ const name = basename(path);
304
+ const current = readSafeInstallationEntry(directory, name);
305
+ if (!sameIdentity(current.stat, source.stat) || current.content !== source.content) {
306
+ closeSync(directory.descriptor);
307
+ throw catalogError('INSTALLATION_STORAGE_CONFLICT', 'installation artifact changed before atomic upgrade');
308
+ }
309
+ const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
310
+ const temporary = installationEntryPath(directory, temporaryName);
311
+ const destination = installationEntryPath(directory, name);
312
+ let temporaryDescriptor;
313
+ try {
314
+ temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
315
+ writeAll(temporaryDescriptor, bytes);
316
+ fsyncSync(temporaryDescriptor);
317
+ closeSync(temporaryDescriptor);
318
+ temporaryDescriptor = undefined;
319
+ runInstallationStorageSecurityTestHook('beforeReplacementCommit', { path, directory: directory.absolute });
320
+ assertPinnedInstallationDirectory(directory);
321
+ const currentArtifact = readSafeInstallationEntry(directory, name);
322
+ if (!sameIdentity(currentArtifact.stat, source.stat) || currentArtifact.content !== source.content) {
323
+ throw catalogError('INSTALLATION_STORAGE_CONFLICT', 'installation storage changed during atomic upgrade');
324
+ }
325
+ renameSync(temporary, destination);
326
+ fsyncSync(directory.descriptor);
327
+ } finally {
328
+ if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
329
+ try { unlinkSync(temporary); } catch { /* preserve the primary result */ }
330
+ closeSync(directory.descriptor);
331
+ }
332
+ }
333
+
334
+ export function readSafeInstallationStorageFile(path, options = {}) {
335
+ return readSafeInstallationFile(path, options);
336
+ }
337
+
338
+ export function persistImmutableInstallationFile(path, content, options = {}) {
339
+ return persistImmutableInstallationBackup(path, content, options);
340
+ }
341
+
342
+ export function replaceInstallationStorageFile(path, content, source) {
343
+ return replaceInstallationArtifact(path, content, source);
344
+ }
345
+
346
+ export function readInstallationArtifactFile(projectDir, { allowMissing = true } = {}) {
347
+ const directory = resolve(projectDir, '.chati/v2');
348
+ return readSafeInstallationFile(join(directory, 'installation.json'), { allowMissing });
349
+ }
350
+
351
+ function readCatalogCacheFile(projectDir, { allowMissing = true } = {}) {
352
+ const directory = resolve(projectDir, '.chati/v2/cache');
353
+ return readSafeInstallationFile(join(directory, 'model-catalog.json'), {
354
+ allowMissing,
355
+ unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE',
356
+ sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
357
+ label: 'catalog cache',
358
+ maxBytes: CATALOG_CACHE_MAX_BYTES,
359
+ });
360
+ }
361
+
362
+ function persistCatalogCache(projectDir, payload) {
363
+ const content = `${JSON.stringify(payload, null, 2)}\n`;
364
+ const bytes = Buffer.from(content);
365
+ if (bytes.length > CATALOG_CACHE_MAX_BYTES) {
366
+ throw catalogError('CATALOG_CACHE_SIZE_LIMIT', 'catalog cache exceeds the 4 MiB safety limit');
367
+ }
368
+ const path = resolve(projectDir, CATALOG_CACHE_PATH);
369
+ const directory = openPinnedInstallationDirectory(dirname(path), { create: true });
370
+ const name = basename(path);
371
+ const existing = readSafeInstallationEntry(directory, name, {
372
+ allowMissing: true,
373
+ unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE',
374
+ sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
375
+ label: 'catalog cache',
376
+ maxBytes: CATALOG_CACHE_MAX_BYTES,
377
+ });
378
+ const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
379
+ const temporary = installationEntryPath(directory, temporaryName);
380
+ const destination = installationEntryPath(directory, name);
381
+ let temporaryDescriptor;
382
+ try {
383
+ temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
384
+ writeAll(temporaryDescriptor, bytes);
385
+ fsyncSync(temporaryDescriptor);
386
+ closeSync(temporaryDescriptor);
387
+ temporaryDescriptor = undefined;
388
+ runInstallationStorageSecurityTestHook('beforeCatalogCacheCommit', { path, directory: directory.absolute });
389
+ assertPinnedInstallationDirectory(directory);
390
+ if (existing !== null) {
391
+ const current = readSafeInstallationEntry(directory, name, {
392
+ unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE', sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
393
+ label: 'catalog cache', maxBytes: CATALOG_CACHE_MAX_BYTES,
394
+ });
395
+ if (!sameSnapshot(current.stat, existing.stat) || current.content !== existing.content) {
396
+ throw catalogError('CATALOG_CACHE_STORAGE_CONFLICT', 'catalog cache changed during atomic replacement');
397
+ }
398
+ } else {
399
+ try {
400
+ readSafeInstallationEntry(directory, name, {
401
+ unsafeCode: 'UNSAFE_CATALOG_CACHE_FILE', sizeCode: 'CATALOG_CACHE_SIZE_LIMIT',
402
+ label: 'catalog cache', maxBytes: CATALOG_CACHE_MAX_BYTES,
403
+ });
404
+ throw catalogError('CATALOG_CACHE_STORAGE_CONFLICT', 'catalog cache appeared during atomic creation');
405
+ } catch (error) {
406
+ if (error?.code !== 'ENOENT') throw error;
407
+ }
408
+ }
409
+ renameSync(temporary, destination);
410
+ fsyncSync(directory.descriptor);
411
+ } finally {
412
+ if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
413
+ try { unlinkSync(temporary); } catch { /* preserve the primary result */ }
414
+ closeSync(directory.descriptor);
415
+ }
416
+ return CATALOG_CACHE_PATH;
417
+ }
418
+
21
419
  function parseSignedPayload(value) {
22
420
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
23
421
  throw catalogError('INVALID_SIGNED_CATALOG', 'signed catalog payload must be an object');
@@ -39,12 +437,22 @@ export function assertCatalogUrl(value) {
39
437
  if (url.protocol !== 'https:' || url.username || url.password) throw catalogError('CATALOG_URL_INVALID', 'catalog URL must use HTTPS without embedded credentials');
40
438
  const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
41
439
  const ipKind = isIP(hostname);
42
- const blockedIpv4 = ipKind === 4 && (/^10\./.test(hostname)
43
- || /^127\./.test(hostname)
44
- || /^169\.254\./.test(hostname)
45
- || /^192\.168\./.test(hostname)
46
- || /^172\.(1[6-9]|2\d|3[01])\./.test(hostname)
47
- || /^0\./.test(hostname));
440
+ const mappedIpv4 = (() => {
441
+ const dotted = hostname.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
442
+ if (dotted) return dotted[1];
443
+ const hexadecimal = hostname.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
444
+ if (!hexadecimal) return null;
445
+ const high = Number.parseInt(hexadecimal[1], 16);
446
+ const low = Number.parseInt(hexadecimal[2], 16);
447
+ return [high >>> 8, high & 0xff, low >>> 8, low & 0xff].join('.');
448
+ })();
449
+ const effectiveIpv4 = ipKind === 4 ? hostname : mappedIpv4;
450
+ const blockedIpv4 = effectiveIpv4 !== null && (/^10\./.test(effectiveIpv4)
451
+ || /^127\./.test(effectiveIpv4)
452
+ || /^169\.254\./.test(effectiveIpv4)
453
+ || /^192\.168\./.test(effectiveIpv4)
454
+ || /^172\.(1[6-9]|2\d|3[01])\./.test(effectiveIpv4)
455
+ || /^0\./.test(effectiveIpv4));
48
456
  const blockedIpv6 = ipKind === 6 && (hostname === '::1' || hostname === '::' || /^(fc|fd|fe8|fe9|fea|feb)/.test(hostname));
49
457
  if (hostname === 'localhost' || hostname.endsWith('.localhost') || blockedIpv4 || blockedIpv6) {
50
458
  throw catalogError('CATALOG_URL_FORBIDDEN', 'catalog URL may not target a loopback, link-local, or private host');
@@ -64,17 +472,117 @@ export function verifySignedCapabilityCatalog(payload, { clock = () => new Date(
64
472
  return validateCapabilitySnapshot(catalog, { clock });
65
473
  }
66
474
 
67
- function readSignedCatalogFile(path) {
68
- return parseSignedPayload(JSON.parse(readFileSync(path, 'utf8')));
475
+ const PROJECTED_MODEL_FIELDS = Object.freeze([
476
+ 'provider_id', 'model_id', 'actions', 'adjudication_priority',
477
+ 'highest_reasoning_configuration', 'routing_priority', 'tier', 'natural_role',
478
+ ]);
479
+
480
+ function projectCatalogModel(model) {
481
+ return Object.fromEntries(PROJECTED_MODEL_FIELDS
482
+ .filter((field) => model[field] !== undefined)
483
+ .map((field) => [field, model[field]]));
69
484
  }
70
485
 
71
- function writeCache(projectDir, payload) {
72
- const path = join(projectDir, CATALOG_CACHE_PATH);
73
- mkdirSync(dirname(path), { recursive: true });
74
- const temporary = `${path}.tmp-${process.pid}`;
75
- writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
76
- renameSync(temporary, path);
77
- return CATALOG_CACHE_PATH;
486
+ /**
487
+ * Proves that a wizard projection still matches a catalog signed by CHATI.
488
+ * Runtime never trusts a locally recomputed installation digest as catalog
489
+ * authority. The signed source may be the project cache or bundled release.
490
+ */
491
+ export function verifyInstallationCatalogProvenance(projectDir, snapshot, { clock = () => new Date(), publicKeyPem } = {}) {
492
+ if (!snapshot?.source_catalog_ref) {
493
+ throw catalogError('CATALOG_PROVENANCE_REQUIRED', 'installation model projection requires a signed source catalog reference');
494
+ }
495
+ const payloads = [];
496
+ const cache = projectDir ? readCatalogCacheFile(projectDir) : null;
497
+ if (cache !== null) {
498
+ try { payloads.push(parseSignedPayload(JSON.parse(cache.content))); } catch { /* invalid cache cannot establish provenance */ }
499
+ }
500
+ try { payloads.push(readBundledPayload()); } catch { /* invalid bundle cannot establish provenance */ }
501
+ for (const payload of payloads) {
502
+ let catalog;
503
+ try { catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem }); } catch { continue; }
504
+ if (catalog.snapshot_id !== snapshot.source_catalog_ref
505
+ || Date.parse(catalog.expires_at) !== Date.parse(snapshot.expires_at)) continue;
506
+ const valid = snapshot.models.every((selected) => {
507
+ const source = catalog.models.find((candidate) =>
508
+ candidate.provider_id === selected.provider_id && candidate.model_id === selected.model_id);
509
+ return source && canonicalSerialize(projectCatalogModel(source)) === canonicalSerialize(projectCatalogModel(selected));
510
+ });
511
+ if (valid) return true;
512
+ }
513
+ throw catalogError('CATALOG_PROVENANCE_INVALID', 'installation model projection does not match an available signed source catalog');
514
+ }
515
+
516
+ function availableSignedPayloads(projectDir) {
517
+ const payloads = [];
518
+ const cache = projectDir ? readCatalogCacheFile(projectDir) : null;
519
+ if (cache !== null) {
520
+ try { payloads.push(parseSignedPayload(JSON.parse(cache.content))); } catch { /* invalid cache is never authority */ }
521
+ }
522
+ try { payloads.push(readBundledPayload()); } catch { /* invalid bundle is never authority */ }
523
+ return payloads;
524
+ }
525
+
526
+ function projectionMatchesCatalog(snapshot, catalog) {
527
+ if (Date.parse(catalog.expires_at) !== Date.parse(snapshot.expires_at)) return false;
528
+ return snapshot.models.every((selected) => {
529
+ const source = catalog.models.find((candidate) =>
530
+ candidate.provider_id === selected.provider_id && candidate.model_id === selected.model_id);
531
+ if (!source) return false;
532
+ return Object.entries(projectCatalogModel(selected)).every(([field, value]) =>
533
+ canonicalSerialize(source[field]) === canonicalSerialize(value));
534
+ });
535
+ }
536
+
537
+ /**
538
+ * One-time compatibility repair for early v2 wizard artifacts that projected
539
+ * a signed catalog but omitted source_catalog_ref. It never infers models from
540
+ * vendor APIs: the exact projection must match an available signed CHATI
541
+ * catalog. Original bytes are preserved before the atomic replacement.
542
+ */
543
+ export function backfillInstallationCatalogProvenance(projectDir, artifact, {
544
+ clock = () => new Date(), publicKeyPem, source = null,
545
+ } = {}) {
546
+ if (artifact?.capability_snapshot?.source_catalog_ref) return Object.freeze(artifact);
547
+ if (!artifact || typeof artifact !== 'object' || Array.isArray(artifact)) {
548
+ throw catalogError('INVALID_INSTALLATION_ARTIFACT', 'legacy installation artifact must be an object');
549
+ }
550
+ const { digest: legacyDigest, installation_artifact_id, ...legacyCore } = artifact;
551
+ if (typeof legacyDigest !== 'string' || !/^[a-f0-9]{64}$/.test(legacyDigest)
552
+ || sha256(legacyCore) !== legacyDigest) {
553
+ throw catalogError('INSTALLATION_ARTIFACT_DRIFT', 'legacy installation artifact digest does not match its content');
554
+ }
555
+ const catalog = availableSignedPayloads(projectDir).map((payload) => {
556
+ try { return verifySignedCapabilityCatalog(payload, { clock, publicKeyPem }); } catch { return null; }
557
+ }).filter(Boolean).find((candidate) => projectionMatchesCatalog(artifact?.capability_snapshot ?? {}, candidate));
558
+ if (!catalog) {
559
+ throw catalogError(
560
+ 'CATALOG_PROVENANCE_UPGRADE_REQUIRED',
561
+ 'legacy v2 installation cannot be matched to a signed catalog; run the installer with --reconfigure',
562
+ );
563
+ }
564
+ const upgradedCore = canonicalize({
565
+ ...legacyCore,
566
+ capability_snapshot: { ...legacyCore.capability_snapshot, source_catalog_ref: catalog.snapshot_id },
567
+ });
568
+ const upgraded = canonicalize({
569
+ ...upgradedCore,
570
+ installation_artifact_id,
571
+ digest: sha256(upgradedCore),
572
+ });
573
+ const artifactPath = join(projectDir, '.chati/v2/installation.json');
574
+ const original = source ?? readInstallationArtifactFile(projectDir, { allowMissing: false });
575
+ let originalArtifact;
576
+ try { originalArtifact = JSON.parse(original.content); }
577
+ catch { throw catalogError('INVALID_INSTALLATION_ARTIFACT', 'legacy installation backup source is not valid JSON'); }
578
+ if (sha256(originalArtifact) !== sha256(artifact)) {
579
+ throw catalogError('INSTALLATION_STORAGE_CONFLICT', 'legacy installation changed before catalog provenance upgrade');
580
+ }
581
+ const backupDir = join(projectDir, '.chati/v2/backups', `catalog-provenance-${legacyDigest}`);
582
+ const backupPath = join(backupDir, 'installation.json');
583
+ persistImmutableInstallationBackup(backupPath, original.content);
584
+ replaceInstallationArtifact(artifactPath, `${canonicalSerialize(upgraded)}\n`, original);
585
+ return Object.freeze(upgraded);
78
586
  }
79
587
 
80
588
  function readBundledPayload() {
@@ -124,10 +632,10 @@ export async function resolveCapabilityCatalog({
124
632
  }
125
633
 
126
634
  if (projectDir) {
127
- const cachePath = join(projectDir, CATALOG_CACHE_PATH);
128
- if (existsSync(cachePath)) {
635
+ const cache = readCatalogCacheFile(projectDir);
636
+ if (cache !== null) {
129
637
  try {
130
- const payload = readSignedCatalogFile(cachePath);
638
+ const payload = parseSignedPayload(JSON.parse(cache.content));
131
639
  const catalog = verifySignedCapabilityCatalog(payload, { clock, publicKeyPem });
132
640
  candidates.push({ source: 'cache', cache_path: CATALOG_CACHE_PATH, payload, catalog });
133
641
  } catch (error) {
@@ -154,7 +662,7 @@ export async function resolveCapabilityCatalog({
154
662
  );
155
663
  const selected = candidates[0];
156
664
  const cache_path = selected.source === 'remote' && projectDir
157
- ? writeCache(projectDir, selected.payload)
665
+ ? persistCatalogCache(projectDir, selected.payload)
158
666
  : (selected.cache_path ?? null);
159
667
  return Object.freeze({
160
668
  source: selected.source,