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.
- package/README.md +8 -3
- package/bin/chati.js +124 -66
- package/framework/agents/build/dev.md +5 -5
- package/framework/agents/deploy/devops.md +7 -7
- package/framework/agents/discover/brief.md +4 -4
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +2 -2
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/agents/plan/ux.md +2 -2
- package/framework/agents/quality/qa-implementation.md +11 -10
- package/framework/agents/quality/qa-planning.md +3 -3
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +18 -18
- package/framework/context/protocols.md +1 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +4 -4
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/orchestrator.yaml +2 -2
- package/framework/domains/constitution.yaml +1 -1
- package/framework/hooks/advance-trigger.js +4 -6
- package/framework/hooks/git-push-authority.js +45 -37
- package/framework/hooks/mode-governance.js +149 -40
- package/framework/hooks/model-governance.js +13 -20
- package/framework/hooks/prism-engine.js +74 -92
- package/framework/hooks/reasoning-escalator.js +23 -40
- package/framework/hooks/session-digest.js +12 -13
- package/framework/hooks/session-reader.js +224 -0
- package/framework/hooks/session-writer.js +195 -0
- package/framework/hooks/team-quality-gate.js +34 -24
- package/framework/i18n/en.yaml +2 -2
- package/framework/i18n/es.yaml +2 -2
- package/framework/i18n/fr.yaml +2 -2
- package/framework/i18n/pt.yaml +2 -2
- package/framework/intelligence/context-engine.md +4 -4
- package/framework/intelligence/memory-layer.md +1 -1
- package/framework/manifest.json +129 -119
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +252 -25
- package/framework/orchestrator/chati.md +238 -70
- package/framework/schemas/session.schema.json +1 -1
- package/framework/tasks/orchestrator-deviation.md +1 -1
- package/framework/tasks/orchestrator-escalate.md +1 -1
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +5 -9
- package/framework/tasks/orchestrator-mode-switch.md +3 -7
- package/framework/tasks/orchestrator-resume.md +10 -14
- package/framework/tasks/orchestrator-route.md +3 -3
- package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
- package/framework/tasks/orchestrator-status.md +9 -9
- package/framework/tasks/orchestrator-suggest-mode.md +1 -1
- package/framework/tasks/qa-impl-consolidate.md +2 -2
- package/framework/tasks/qa-impl-performance-test.md +4 -4
- package/framework/tasks/qa-impl-regression-check.md +4 -4
- package/framework/tasks/qa-impl-sast-scan.md +1 -1
- package/framework/tasks/qa-impl-test-execute.md +1 -1
- package/framework/tasks/qa-impl-verdict.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +2 -2
- package/framework/tasks/qa-planning-gate-define.md +4 -4
- package/framework/tasks/qa-planning-risk-matrix.md +4 -4
- package/framework/tasks/qa-planning-test-strategy.md +2 -2
- package/node_modules/@chati/browser-capability/src/index.js +12 -2
- package/node_modules/@chati/planning/src/index.js +24 -4
- package/node_modules/@chati/provider-registry/src/index.js +11 -0
- package/node_modules/@chati/rail/src/index.js +1967 -83
- package/node_modules/@chati/release-lane/README.md +12 -8
- package/node_modules/@chati/release-lane/package.json +1 -1
- package/node_modules/@chati/release-lane/src/index.js +1426 -58
- package/node_modules/@chati/review-council/src/index.js +63 -0
- package/node_modules/@chati/tracking-clickup/README.md +13 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
- package/package-artifact-manifest.json +1 -0
- package/package-artifact-manifest.sig +1 -0
- package/package.json +16 -7
- package/scripts/verify-real-harness-e2e.js +1581 -0
- package/src/config/framework-adapter.js +4 -4
- package/src/installer/core.js +148 -53
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +65 -20
- package/src/installer-v2/catalog-client.js +531 -23
- package/src/installer-v2/index.js +91 -34
- package/src/installer-v2/installation-authority.js +327 -0
- package/src/installer-v2/provider-executable.js +247 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1545 -174
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +16 -3
- package/src/orchestrator/index.js +16 -0
- package/src/orchestrator/planning-runtime.js +20 -2
- package/src/orchestrator/rail-adjudication-evidence.js +234 -0
- package/src/orchestrator/rail-evidence-authority.js +147 -0
- package/src/orchestrator/rail-execution-evidence.js +45 -0
- package/src/orchestrator/rail-runtime.js +836 -56
- package/src/orchestrator/release-runtime.js +65 -6
- package/src/orchestrator/review-runtime.js +186 -41
- package/src/orchestrator/runtime-installation-v2.js +236 -20
- package/src/orchestrator/session-manager.js +1167 -53
- package/src/terminal/adapters/claude-adapter.js +3 -1
- package/src/terminal/adapters/codex-adapter.js +2 -0
- package/src/terminal/adapters/grok-adapter.js +7 -4
- package/src/terminal/handoff-parser.js +19 -1
- package/src/terminal/prompt-builder.js +9 -1
- package/src/terminal/provider-preflight.js +36 -3
- package/src/terminal/rail-execution-worktree.js +213 -0
- package/src/terminal/rail-prompts.js +169 -0
- package/src/terminal/rail-readonly-workspace.js +324 -0
- package/src/terminal/run-agent.js +384 -24
- package/src/terminal/run-parallel.js +5 -0
- package/src/terminal/run-rail-adjudication.js +323 -0
- package/src/terminal/run-rail-review.js +291 -0
- package/src/terminal/run-rail-rework.js +325 -0
- package/src/terminal/run-rail-task.js +380 -0
- package/src/terminal/run-team.js +5 -0
- package/src/terminal/spawner.js +1225 -77
- package/src/wizard/index.js +3 -2
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {
|
|
2
|
+
closeSync, constants, fstatSync, fsyncSync, linkSync, mkdirSync,
|
|
3
|
+
openSync, readSync, unlinkSync, writeSync,
|
|
4
|
+
} from 'node:fs';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { basename, dirname, join, parse, resolve, sep } from 'node:path';
|
|
7
|
+
import { canonicalize, sha256 } from '@chati/core';
|
|
8
|
+
|
|
9
|
+
function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
|
|
10
|
+
const MAX_EVIDENCE_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function sameIdentity(left, right) {
|
|
13
|
+
return left?.dev === right?.dev && left?.ino === right?.ino;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sameSnapshot(left, right) {
|
|
17
|
+
return sameIdentity(left, right)
|
|
18
|
+
&& left?.mode === right?.mode
|
|
19
|
+
&& left?.uid === right?.uid
|
|
20
|
+
&& left?.gid === right?.gid
|
|
21
|
+
&& left?.nlink === right?.nlink
|
|
22
|
+
&& left?.size === right?.size
|
|
23
|
+
&& left?.mtimeMs === right?.mtimeMs
|
|
24
|
+
&& left?.ctimeMs === right?.ctimeMs;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function runSecurityTestHook(name, payload) {
|
|
28
|
+
const hook = globalThis[Symbol.for('chati.rail-adjudication-evidence.security-test-hooks')]?.[name];
|
|
29
|
+
if (typeof hook === 'function') hook(payload);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readDescriptorBounded(descriptor) {
|
|
33
|
+
const bytes = Buffer.allocUnsafe(MAX_EVIDENCE_BYTES + 1);
|
|
34
|
+
let offset = 0;
|
|
35
|
+
while (offset < bytes.length) {
|
|
36
|
+
const count = readSync(descriptor, bytes, offset, bytes.length - offset, null);
|
|
37
|
+
if (count === 0) break;
|
|
38
|
+
offset += count;
|
|
39
|
+
}
|
|
40
|
+
return bytes.subarray(0, offset);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function openPinnedEvidenceDirectory(path, { create = true } = {}) {
|
|
44
|
+
if (process.platform !== 'linux') {
|
|
45
|
+
fail('UNSUPPORTED_ADJUDICATION_EVIDENCE_PLATFORM', 'RAIL adjudication evidence requires Linux descriptor-relative filesystem operations');
|
|
46
|
+
}
|
|
47
|
+
const absolute = resolve(path);
|
|
48
|
+
const root = parse(absolute).root;
|
|
49
|
+
const segments = absolute.slice(root.length).split(sep).filter(Boolean);
|
|
50
|
+
let descriptor = openSync(root, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
51
|
+
try {
|
|
52
|
+
for (const segment of segments) {
|
|
53
|
+
const childPath = join(`/proc/self/fd/${descriptor}`, segment);
|
|
54
|
+
let childDescriptor;
|
|
55
|
+
try {
|
|
56
|
+
childDescriptor = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (error?.code !== 'ENOENT' || !create) {
|
|
59
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) {
|
|
60
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_DIRECTORY', 'adjudication evidence directory chain must contain only real directories');
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
try { mkdirSync(childPath, { mode: 0o700 }); }
|
|
65
|
+
catch (mkdirError) { if (mkdirError?.code !== 'EEXIST') throw mkdirError; }
|
|
66
|
+
childDescriptor = openSync(childPath, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
67
|
+
}
|
|
68
|
+
const stat = fstatSync(childDescriptor);
|
|
69
|
+
if (!stat.isDirectory()) {
|
|
70
|
+
closeSync(childDescriptor);
|
|
71
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_DIRECTORY', 'adjudication evidence directory chain must contain only real directories');
|
|
72
|
+
}
|
|
73
|
+
closeSync(descriptor);
|
|
74
|
+
descriptor = childDescriptor;
|
|
75
|
+
}
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
absolute,
|
|
78
|
+
descriptor,
|
|
79
|
+
identity: fstatSync(descriptor),
|
|
80
|
+
reference: `/proc/self/fd/${descriptor}`,
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
closeSync(descriptor);
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function evidenceEntryPath(directory, name) {
|
|
89
|
+
if (basename(name) !== name || name === '.' || name === '..') {
|
|
90
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_FILE', 'adjudication evidence entry must be one path segment');
|
|
91
|
+
}
|
|
92
|
+
return join(directory.reference, name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function assertPinnedEvidenceDirectory(directory) {
|
|
96
|
+
const descriptorStat = fstatSync(directory.descriptor);
|
|
97
|
+
if (!descriptorStat.isDirectory() || !sameIdentity(descriptorStat, directory.identity)) {
|
|
98
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_DIRECTORY', 'pinned adjudication evidence directory changed');
|
|
99
|
+
}
|
|
100
|
+
let current;
|
|
101
|
+
try { current = openPinnedEvidenceDirectory(directory.absolute, { create: false }); }
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error?.code === 'UNSAFE_ADJUDICATION_EVIDENCE_DIRECTORY') throw error;
|
|
104
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_DIRECTORY', 'adjudication evidence directory chain changed during persistence');
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
if (!sameIdentity(current.identity, directory.identity)) {
|
|
108
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_DIRECTORY', 'adjudication evidence directory chain changed during persistence');
|
|
109
|
+
}
|
|
110
|
+
} finally { closeSync(current.descriptor); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readRegular(path, { allowMissing = false, directory = null } = {}) {
|
|
114
|
+
let activeDirectory = directory;
|
|
115
|
+
const ownedDirectory = activeDirectory === null;
|
|
116
|
+
if (ownedDirectory) {
|
|
117
|
+
try { activeDirectory = openPinnedEvidenceDirectory(dirname(path), { create: false }); }
|
|
118
|
+
catch (error) { if (allowMissing && error?.code === 'ENOENT') return null; throw error; }
|
|
119
|
+
}
|
|
120
|
+
const entry = evidenceEntryPath(activeDirectory, basename(path));
|
|
121
|
+
let descriptor;
|
|
122
|
+
try {
|
|
123
|
+
descriptor = openSync(entry, constants.O_RDONLY | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0));
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (ownedDirectory) closeSync(activeDirectory.descriptor);
|
|
126
|
+
if (allowMissing && error?.code === 'ENOENT') return null;
|
|
127
|
+
if (['ELOOP', 'ENXIO', 'EISDIR'].includes(error?.code)) {
|
|
128
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_FILE', 'adjudication evidence must not be a symlink, FIFO or directory');
|
|
129
|
+
}
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
const before = fstatSync(descriptor);
|
|
134
|
+
if (!before.isFile() || before.nlink !== 1) {
|
|
135
|
+
fail('UNSAFE_ADJUDICATION_EVIDENCE_FILE', 'adjudication evidence must be a regular single-link file');
|
|
136
|
+
}
|
|
137
|
+
if (before.size > MAX_EVIDENCE_BYTES) {
|
|
138
|
+
fail('ADJUDICATION_EVIDENCE_SIZE_LIMIT', 'adjudication evidence exceeds the 4 MiB safety limit');
|
|
139
|
+
}
|
|
140
|
+
runSecurityTestHook('afterEvidenceStat', { path, descriptor });
|
|
141
|
+
const content = readDescriptorBounded(descriptor);
|
|
142
|
+
if (content.length > MAX_EVIDENCE_BYTES) {
|
|
143
|
+
fail('ADJUDICATION_EVIDENCE_SIZE_LIMIT', 'adjudication evidence exceeds the 4 MiB safety limit');
|
|
144
|
+
}
|
|
145
|
+
const after = fstatSync(descriptor);
|
|
146
|
+
if (!after.isFile() || after.nlink !== 1 || !sameSnapshot(before, after)) {
|
|
147
|
+
fail('ADJUDICATION_EVIDENCE_STORAGE_CONFLICT', 'adjudication evidence changed while it was being read');
|
|
148
|
+
}
|
|
149
|
+
assertPinnedEvidenceDirectory(activeDirectory);
|
|
150
|
+
return { content: content.toString('utf8'), stat: after };
|
|
151
|
+
} finally {
|
|
152
|
+
closeSync(descriptor);
|
|
153
|
+
if (ownedDirectory) closeSync(activeDirectory.descriptor);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function persistImmutable(path, serialized) {
|
|
158
|
+
const bytes = Buffer.from(serialized);
|
|
159
|
+
if (bytes.length > MAX_EVIDENCE_BYTES) {
|
|
160
|
+
fail('ADJUDICATION_EVIDENCE_SIZE_LIMIT', 'adjudication evidence exceeds the 4 MiB safety limit');
|
|
161
|
+
}
|
|
162
|
+
const directory = openPinnedEvidenceDirectory(dirname(path));
|
|
163
|
+
const name = basename(path);
|
|
164
|
+
const temporaryName = `${name}.${process.pid}.${randomUUID()}.tmp`;
|
|
165
|
+
const temporary = evidenceEntryPath(directory, temporaryName);
|
|
166
|
+
const destination = evidenceEntryPath(directory, name);
|
|
167
|
+
let temporaryDescriptor;
|
|
168
|
+
try {
|
|
169
|
+
const existing = readRegular(path, { allowMissing: true, directory });
|
|
170
|
+
if (existing !== null) {
|
|
171
|
+
if (existing.content !== serialized) fail('ADJUDICATION_EVIDENCE_CONFLICT', 'adjudication evidence id already exists with different material');
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
runSecurityTestHook('beforeEvidenceTempOpen', { path, temporary, directory: directory.absolute });
|
|
175
|
+
temporaryDescriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
176
|
+
runSecurityTestHook('afterEvidenceTempOpen', { path, temporary, directory: directory.absolute });
|
|
177
|
+
let offset = 0;
|
|
178
|
+
while (offset < bytes.length) offset += writeSync(temporaryDescriptor, bytes, offset, bytes.length - offset);
|
|
179
|
+
fsyncSync(temporaryDescriptor);
|
|
180
|
+
closeSync(temporaryDescriptor);
|
|
181
|
+
temporaryDescriptor = undefined;
|
|
182
|
+
assertPinnedEvidenceDirectory(directory);
|
|
183
|
+
runSecurityTestHook('beforeEvidenceCommit', { path, temporary, directory: directory.absolute });
|
|
184
|
+
try { linkSync(temporary, destination); }
|
|
185
|
+
catch (error) {
|
|
186
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
187
|
+
const raced = readRegular(path, { directory });
|
|
188
|
+
if (raced.content !== serialized) fail('ADJUDICATION_EVIDENCE_CONFLICT', 'adjudication evidence raced with different material');
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
runSecurityTestHook('afterEvidenceCommit', { path, temporary, directory: directory.absolute });
|
|
192
|
+
assertPinnedEvidenceDirectory(directory);
|
|
193
|
+
fsyncSync(directory.descriptor);
|
|
194
|
+
return true;
|
|
195
|
+
} finally {
|
|
196
|
+
if (temporaryDescriptor !== undefined) closeSync(temporaryDescriptor);
|
|
197
|
+
try { unlinkSync(temporary); } catch { /* preserve the primary result */ }
|
|
198
|
+
fsyncSync(directory.descriptor);
|
|
199
|
+
closeSync(directory.descriptor);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function safeId(value, label) {
|
|
203
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9._-]+$/.test(value)) fail('INVALID_ADJUDICATION_EVIDENCE_ID', `${label} is not path-safe`);
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function persistRailAdjudicationEvidence({ projectDir, handoff_id, attempt_id, review_ref, rounds, final_decision } = {}) {
|
|
208
|
+
safeId(handoff_id, 'handoff_id');
|
|
209
|
+
safeId(attempt_id, 'attempt_id');
|
|
210
|
+
if (typeof review_ref !== 'string' || !Array.isArray(rounds) || rounds.length === 0 || !final_decision) {
|
|
211
|
+
fail('INVALID_ADJUDICATION_EVIDENCE', 'review_ref, non-empty rounds and final_decision are required');
|
|
212
|
+
}
|
|
213
|
+
const core = canonicalize({
|
|
214
|
+
schema_version: 1,
|
|
215
|
+
evidence_type: 'rail-ai-adjudication',
|
|
216
|
+
handoff_id,
|
|
217
|
+
attempt_id,
|
|
218
|
+
review_ref,
|
|
219
|
+
rounds,
|
|
220
|
+
final_decision,
|
|
221
|
+
});
|
|
222
|
+
const digest = sha256(core);
|
|
223
|
+
const evidence_id = `adjudication-${digest.slice(0, 24)}`;
|
|
224
|
+
const record = canonicalize({ ...core, evidence_id, digest });
|
|
225
|
+
const directory = resolve(projectDir, '.chati/v2/rail/adjudications', attempt_id);
|
|
226
|
+
const path = join(directory, `${evidence_id}.json`);
|
|
227
|
+
const serialized = `${JSON.stringify(record, null, 2)}\n`;
|
|
228
|
+
persistImmutable(path, serialized);
|
|
229
|
+
return Object.freeze({
|
|
230
|
+
path,
|
|
231
|
+
evidence_ref: `rail-adjudication://${handoff_id}/${attempt_id}/${evidence_id}`,
|
|
232
|
+
record: Object.freeze(record),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createPublicKey, verify as verifySignature } from 'node:crypto';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { canonicalSerialize, sha256 } from '@chati/core';
|
|
5
|
+
import { loadRuntimeInstallationV2 } from './runtime-installation-v2.js';
|
|
6
|
+
import { readSafeInstallationStorageFile } from '../installer-v2/catalog-client.js';
|
|
7
|
+
|
|
8
|
+
const CAPABILITIES = new WeakMap();
|
|
9
|
+
const CONSTRUCTION_TOKEN = Symbol('chati.rail-evidence.certified-verifier');
|
|
10
|
+
const MAX_CERTIFICATE_BYTES = 32 * 1024;
|
|
11
|
+
const MAX_EVIDENCE_AGE_MS = 24 * 60 * 60 * 1000;
|
|
12
|
+
const FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
function fail(code, message) {
|
|
15
|
+
throw Object.assign(new Error(message), { code });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function plainObject(value) {
|
|
19
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
20
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
21
|
+
&& Object.getOwnPropertySymbols(value).length === 0
|
|
22
|
+
&& Object.values(Object.getOwnPropertyDescriptors(value)).every((descriptor) => Object.hasOwn(descriptor, 'value'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class CertifiedRailEvidenceVerifier {
|
|
26
|
+
constructor(certificate, token) {
|
|
27
|
+
if (token !== CONSTRUCTION_TOKEN) fail('UNTRUSTED_RAIL_EVIDENCE_VERIFIER', 'RAIL evidence verifier must be minted from a valid CHATI certificate');
|
|
28
|
+
let key;
|
|
29
|
+
try { key = createPublicKey(certificate.public_key); }
|
|
30
|
+
catch { fail('INVALID_RAIL_EVIDENCE_CERTIFICATE', 'RAIL evidence authority public key is invalid'); }
|
|
31
|
+
if (key.type !== 'public' || key.asymmetricKeyType !== 'ed25519') fail('INVALID_RAIL_EVIDENCE_CERTIFICATE', 'RAIL evidence authority key must be Ed25519');
|
|
32
|
+
CAPABILITIES.set(this, Object.freeze({
|
|
33
|
+
issuer_id: certificate.issuer_id,
|
|
34
|
+
project_id: certificate.project_id,
|
|
35
|
+
not_before: certificate.not_before,
|
|
36
|
+
expires_at: certificate.expires_at,
|
|
37
|
+
key,
|
|
38
|
+
}));
|
|
39
|
+
Object.freeze(this);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function railEvidenceAuthorityCertificatePayload(input = {}) {
|
|
44
|
+
return Object.freeze({
|
|
45
|
+
schema_version: 1,
|
|
46
|
+
certificate_type: 'chati-rail-evidence-authority',
|
|
47
|
+
project_id: input.project_id,
|
|
48
|
+
issuer_id: input.issuer_id,
|
|
49
|
+
public_key: input.public_key,
|
|
50
|
+
not_before: input.not_before,
|
|
51
|
+
expires_at: input.expires_at,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function signedRailEvidencePayload(input = {}) {
|
|
56
|
+
return Object.freeze({
|
|
57
|
+
schema_version: 1,
|
|
58
|
+
purpose: 'rail_evidence',
|
|
59
|
+
authority_kind: input.authority_kind,
|
|
60
|
+
issuer_id: input.issuer_id,
|
|
61
|
+
project_id: input.project_id,
|
|
62
|
+
attempt_id: input.attempt_id,
|
|
63
|
+
commit_ref: input.commit_ref,
|
|
64
|
+
verdict: input.verdict,
|
|
65
|
+
evidence_digest: input.evidence_digest,
|
|
66
|
+
observed_at: input.observed_at,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function verifyRailEvidenceAuthorityCertificate({ certificate, expected_project_id, root_public_key, clock = () => new Date() } = {}) {
|
|
71
|
+
const keys = ['certificate_type', 'expires_at', 'issuer_id', 'not_before', 'project_id', 'public_key', 'schema_version', 'signature'];
|
|
72
|
+
if (!plainObject(certificate) || Object.keys(certificate).sort().join(',') !== keys.join(',')
|
|
73
|
+
|| certificate.schema_version !== 1
|
|
74
|
+
|| certificate.certificate_type !== 'chati-rail-evidence-authority'
|
|
75
|
+
|| certificate.project_id !== expected_project_id
|
|
76
|
+
|| typeof certificate.issuer_id !== 'string' || !certificate.issuer_id.trim()
|
|
77
|
+
|| typeof certificate.public_key !== 'string' || !certificate.public_key.trim()
|
|
78
|
+
|| typeof certificate.signature !== 'string') {
|
|
79
|
+
fail('INVALID_RAIL_EVIDENCE_CERTIFICATE', 'RAIL evidence authority certificate is invalid or belongs to another project');
|
|
80
|
+
}
|
|
81
|
+
const now = clock();
|
|
82
|
+
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
|
83
|
+
const notBeforeMs = Date.parse(certificate.not_before);
|
|
84
|
+
const expiresMs = Date.parse(certificate.expires_at);
|
|
85
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(notBeforeMs) || !Number.isFinite(expiresMs)
|
|
86
|
+
|| nowMs < notBeforeMs || nowMs >= expiresMs) fail('RAIL_EVIDENCE_CERTIFICATE_EXPIRED', 'RAIL evidence authority certificate is not currently valid');
|
|
87
|
+
let rootKey;
|
|
88
|
+
let signature;
|
|
89
|
+
try {
|
|
90
|
+
rootKey = root_public_key?.type === 'public' ? root_public_key : createPublicKey(root_public_key);
|
|
91
|
+
signature = Buffer.from(certificate.signature, 'base64');
|
|
92
|
+
} catch { fail('INVALID_RAIL_EVIDENCE_CERTIFICATE', 'RAIL evidence certificate key material is invalid'); }
|
|
93
|
+
const valid = rootKey.type === 'public' && rootKey.asymmetricKeyType === 'ed25519'
|
|
94
|
+
&& signature.length === 64 && signature.toString('base64') === certificate.signature
|
|
95
|
+
&& verifySignature(null, Buffer.from(canonicalSerialize(railEvidenceAuthorityCertificatePayload(certificate))), rootKey, signature);
|
|
96
|
+
if (!valid) fail('INVALID_RAIL_EVIDENCE_CERTIFICATE', 'RAIL evidence authority certificate signature is invalid');
|
|
97
|
+
return new CertifiedRailEvidenceVerifier(certificate, CONSTRUCTION_TOKEN);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function verifyRailEvidenceReceipt(verifier, receipt, expected = {}, clock = () => new Date()) {
|
|
101
|
+
const capability = CAPABILITIES.get(verifier);
|
|
102
|
+
if (!capability || !plainObject(receipt)) return false;
|
|
103
|
+
const keys = ['attempt_id', 'authority_kind', 'commit_ref', 'evidence_digest', 'issuer_id', 'observed_at', 'project_id', 'purpose', 'schema_version', 'signature', 'verdict'];
|
|
104
|
+
if (Object.keys(receipt).sort().join(',') !== keys.join(',')
|
|
105
|
+
|| receipt.schema_version !== 1 || receipt.purpose !== 'rail_evidence'
|
|
106
|
+
|| receipt.issuer_id !== capability.issuer_id || receipt.project_id !== capability.project_id
|
|
107
|
+
|| typeof receipt.signature !== 'string') return false;
|
|
108
|
+
if (!Object.entries(expected).every(([key, value]) => sha256(receipt[key]) === sha256(value))) return false;
|
|
109
|
+
const now = clock();
|
|
110
|
+
const nowMs = (now instanceof Date ? now : new Date(now)).getTime();
|
|
111
|
+
const observedMs = Date.parse(receipt.observed_at);
|
|
112
|
+
if (!Number.isFinite(nowMs) || !Number.isFinite(observedMs)
|
|
113
|
+
|| nowMs < Date.parse(capability.not_before) || nowMs >= Date.parse(capability.expires_at)
|
|
114
|
+
|| observedMs > nowMs + FUTURE_SKEW_MS || nowMs - observedMs > MAX_EVIDENCE_AGE_MS) return false;
|
|
115
|
+
let signature;
|
|
116
|
+
try { signature = Buffer.from(receipt.signature, 'base64'); }
|
|
117
|
+
catch { return false; }
|
|
118
|
+
return signature.length === 64 && signature.toString('base64') === receipt.signature
|
|
119
|
+
&& verifySignature(null, Buffer.from(canonicalSerialize(signedRailEvidencePayload(receipt))), capability.key, signature);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function loadProjectRailEvidenceAuthority({ projectDir, clock = () => new Date() } = {}) {
|
|
123
|
+
const artifact = loadRuntimeInstallationV2(projectDir, { clock });
|
|
124
|
+
if (!artifact) fail('V2_INSTALLATION_REQUIRED', 'RAIL evidence authority requires a v2 installation');
|
|
125
|
+
const path = join(projectDir, '.chati/v2/rail/evidence-authority.json');
|
|
126
|
+
let source;
|
|
127
|
+
try {
|
|
128
|
+
source = readSafeInstallationStorageFile(path, {
|
|
129
|
+
allowMissing: true,
|
|
130
|
+
unsafeCode: 'UNSAFE_RAIL_EVIDENCE_CERTIFICATE',
|
|
131
|
+
sizeCode: 'UNSAFE_RAIL_EVIDENCE_CERTIFICATE',
|
|
132
|
+
label: 'RAIL evidence certificate',
|
|
133
|
+
maxBytes: MAX_CERTIFICATE_BYTES,
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error?.code) throw error;
|
|
137
|
+
fail('UNSAFE_RAIL_EVIDENCE_CERTIFICATE', 'RAIL evidence certificate cannot be read safely');
|
|
138
|
+
}
|
|
139
|
+
if (source === null) fail('RAIL_EVIDENCE_AUTHORITY_NOT_CONFIGURED', 'install a CHATI-signed RAIL evidence authority certificate before completion');
|
|
140
|
+
let certificate;
|
|
141
|
+
try { certificate = JSON.parse(source.content); }
|
|
142
|
+
catch { fail('INVALID_RAIL_EVIDENCE_CERTIFICATE', 'RAIL evidence authority certificate is not valid JSON'); }
|
|
143
|
+
const rootPublicKey = readFileSync(new URL('../installer/signing-public-key.pem', import.meta.url), 'utf8');
|
|
144
|
+
return verifyRailEvidenceAuthorityCertificate({
|
|
145
|
+
certificate, expected_project_id: artifact.project_id, root_public_key: rootPublicKey, clock,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
function fail(code, message) { const error = new Error(message); error.code = code; throw error; }
|
|
4
|
+
|
|
5
|
+
function trustedGitEnvironment(source = process.env) {
|
|
6
|
+
const environment = Object.fromEntries(Object.entries(source).filter(([key]) => !key.startsWith('GIT_')));
|
|
7
|
+
return {
|
|
8
|
+
...environment,
|
|
9
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
10
|
+
GIT_CONFIG_GLOBAL: process.platform === 'win32' ? 'NUL' : '/dev/null',
|
|
11
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function parseRailExecutionEnvelope(output) {
|
|
16
|
+
const text = String(output || '');
|
|
17
|
+
const matches = [...text.matchAll(/<CHATI_RAIL_EXECUTION>\s*([\s\S]*?)\s*<\/CHATI_RAIL_EXECUTION>/g)];
|
|
18
|
+
if (matches.length !== 1) fail('RAIL_EXECUTION_OUTPUT_INVALID', 'successful execution must return exactly one CHATI_RAIL_EXECUTION envelope');
|
|
19
|
+
let payload;
|
|
20
|
+
try { payload = JSON.parse(matches[0][1]); }
|
|
21
|
+
catch (error) { return fail('RAIL_EXECUTION_OUTPUT_INVALID', `execution returned invalid result JSON: ${error.message}`); }
|
|
22
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|
|
23
|
+
|| typeof payload.result_commit_ref !== 'string'
|
|
24
|
+
|| !/^[a-f0-9]{40,64}$/.test(payload.result_commit_ref)
|
|
25
|
+
|| typeof payload.launch_nonce !== 'string'
|
|
26
|
+
|| !/^[A-Za-z0-9._-]{16,128}$/.test(payload.launch_nonce)) {
|
|
27
|
+
fail('RAIL_EXECUTION_OUTPUT_INVALID', 'execution must return a full lowercase Git object id and the exact launch_nonce');
|
|
28
|
+
}
|
|
29
|
+
return Object.freeze({ result_commit_ref: payload.result_commit_ref, launch_nonce: payload.launch_nonce });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verifyRailResultCommit({ git_dir, base_commit_ref, result_commit_ref } = {}) {
|
|
33
|
+
for (const [label, value] of Object.entries({ git_dir, base_commit_ref, result_commit_ref })) {
|
|
34
|
+
if (typeof value !== 'string' || !value.trim()) fail('RAIL_RESULT_COMMIT_INVALID', `${label} is required`);
|
|
35
|
+
}
|
|
36
|
+
if (base_commit_ref === result_commit_ref) fail('RAIL_RESULT_COMMIT_REQUIRED', 'execution result commit must differ from its bound base commit');
|
|
37
|
+
try {
|
|
38
|
+
execFileSync('git', ['-C', git_dir, 'cat-file', '-e', `${base_commit_ref}^{commit}`], { stdio: 'ignore', env: trustedGitEnvironment() });
|
|
39
|
+
execFileSync('git', ['-C', git_dir, 'cat-file', '-e', `${result_commit_ref}^{commit}`], { stdio: 'ignore', env: trustedGitEnvironment() });
|
|
40
|
+
execFileSync('git', ['-C', git_dir, 'merge-base', '--is-ancestor', base_commit_ref, result_commit_ref], { stdio: 'ignore', env: trustedGitEnvironment() });
|
|
41
|
+
} catch {
|
|
42
|
+
fail('RAIL_RESULT_NOT_DESCENDANT', 'execution result commit must exist locally and descend from its bound base commit');
|
|
43
|
+
}
|
|
44
|
+
return Object.freeze({ base_commit_ref, result_commit_ref });
|
|
45
|
+
}
|