@skill-harness/core 0.17.0 → 0.18.0
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 +1 -2
- package/dist/adapters/types.d.ts +0 -12
- package/dist/capture-trace-types.d.ts +0 -96
- package/dist/capture-trace-types.js +0 -4
- package/dist/command-cost.d.ts +1 -1
- package/dist/command-cost.js +0 -5
- package/dist/execution-trace.js +1 -1
- package/dist/index.d.ts +3 -23
- package/dist/index.js +3 -23
- package/dist/instruction-coverage.d.ts +0 -7
- package/dist/instruction-coverage.js +5 -7
- package/dist/journal.d.ts +0 -8
- package/dist/metrics.d.ts +18 -0
- package/dist/metrics.js +28 -0
- package/dist/redaction.d.ts +6 -0
- package/dist/redaction.js +55 -0
- package/dist/regate.js +6 -71
- package/dist/regrade.d.ts +1 -3
- package/dist/regrade.js +1 -21
- package/dist/report.d.ts +1 -15
- package/dist/report.js +1 -13
- package/dist/reps.d.ts +1 -2
- package/dist/reps.js +0 -2
- package/dist/results.d.ts +0 -9
- package/dist/results.js +2 -19
- package/dist/run.js +11 -72
- package/dist/seeded.d.ts +1 -7
- package/dist/seeded.js +5 -11
- package/dist/sources.js +4 -9
- package/dist/spec.d.ts +0 -13
- package/dist/spec.js +6 -48
- package/dist/trajectory-events.d.ts +58 -0
- package/dist/trajectory-events.js +119 -0
- package/package.json +1 -1
- package/schemas/results-v2.schema.json +376 -75
- package/schemas/specification-v1.schema.json +128 -174
- package/dist/adjudication.d.ts +0 -219
- package/dist/adjudication.js +0 -422
- package/dist/adoption.d.ts +0 -128
- package/dist/adoption.js +0 -102
- package/dist/affected.d.ts +0 -88
- package/dist/affected.js +0 -222
- package/dist/capture.d.ts +0 -193
- package/dist/capture.js +0 -344
- package/dist/comparison.d.ts +0 -97
- package/dist/comparison.js +0 -189
- package/dist/factory-calibration.d.ts +0 -63
- package/dist/factory-calibration.js +0 -115
- package/dist/intervention-results.d.ts +0 -6
- package/dist/intervention-results.js +0 -52
- package/dist/intervention.d.ts +0 -123
- package/dist/intervention.js +0 -218
- package/dist/investigation.d.ts +0 -94
- package/dist/investigation.js +0 -148
- package/dist/judge-agreement.d.ts +0 -19
- package/dist/judge-agreement.js +0 -31
- package/dist/qualification-capture.d.ts +0 -44
- package/dist/qualification-capture.js +0 -163
- package/dist/qualification-config.d.ts +0 -233
- package/dist/qualification-config.js +0 -694
- package/dist/qualification-lock.d.ts +0 -15
- package/dist/qualification-lock.js +0 -87
- package/dist/qualification-oauth-directory.d.ts +0 -79
- package/dist/qualification-oauth-directory.js +0 -444
- package/dist/qualification-panel-store.d.ts +0 -14
- package/dist/qualification-panel-store.js +0 -188
- package/dist/qualification-panels.d.ts +0 -99
- package/dist/qualification-panels.js +0 -91
- package/dist/qualification-process.d.ts +0 -13
- package/dist/qualification-process.js +0 -190
- package/dist/qualification-runner.d.ts +0 -203
- package/dist/qualification-runner.js +0 -2037
- package/dist/qualification-store.d.ts +0 -187
- package/dist/qualification-store.js +0 -833
- package/dist/qualification-validation.d.ts +0 -12
- package/dist/qualification-validation.js +0 -8
- package/dist/screen.d.ts +0 -31
- package/dist/screen.js +0 -105
- package/dist/trajectory-gates.d.ts +0 -137
- package/dist/trajectory-gates.js +0 -576
- package/dist/trusted-host-supervision.d.ts +0 -25
- package/dist/trusted-host-supervision.js +0 -94
- package/dist/work-capture-schema.d.ts +0 -38
- package/dist/work-capture-schema.js +0 -33
- package/dist/work-capture.d.ts +0 -43
- package/dist/work-capture.js +0 -81
- package/dist/work-signals.d.ts +0 -55
- package/dist/work-signals.js +0 -71
- package/schemas/qualification-cell-result-v1.schema.json +0 -32
- package/schemas/qualification-config-v1.schema.json +0 -244
- package/schemas/qualification-invocation-request-v1.schema.json +0 -59
- package/schemas/qualification-judge-panel-result-v1.schema.json +0 -42
- package/schemas/trajectory-event-v1.schema.json +0 -80
|
@@ -1,833 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { closeSync, constants, existsSync, fsyncSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
-
import { dirname, extname, join, resolve } from "node:path";
|
|
4
|
-
import { qualificationLockOwnerIsLive, readQualificationLockOwner, reclaimQualificationLock, releaseQualificationLock, tryPublishQualificationLock, } from "./qualification-lock.js";
|
|
5
|
-
import { QUALIFICATION_ACCOUNTING_POLICY, QUALIFICATION_PANEL_ACCOUNTING_POLICY, QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2, QUALIFICATION_TERMINAL_RECEIPT_VERSION_V3, qualificationOAuthDirectoryPolicy, parseQualificationConfig, parseQualificationRequest, qualificationCanonicalJson, qualificationConfigDigest, qualificationSha256, verifyQualificationExecutable, verifyQualificationPins, verifyQualificationResource, } from "./qualification-config.js";
|
|
6
|
-
export const QUALIFICATION_INVOCATION_VERSION = "qualification-invocation-v1";
|
|
7
|
-
export const QUALIFICATION_INVOCATION_VERSION_V2 = "qualification-invocation-v2";
|
|
8
|
-
export const QUALIFICATION_INVOCATION_VERSION_V3 = "qualification-invocation-v3";
|
|
9
|
-
export const QUALIFICATION_INVOCATION_VERSION_V4 = "qualification-invocation-v4";
|
|
10
|
-
export const QUALIFICATION_INVOCATION_INPUT_BINDING_VERSION = "qualification-invocation-input-binding-v1";
|
|
11
|
-
export const QUALIFICATION_LIFECYCLE_VERSION = "qualification-lifecycle-v1";
|
|
12
|
-
export const QUALIFICATION_ACCOUNTING_VERSION = "qualification-accounting-v1";
|
|
13
|
-
export function qualificationSpoolPaths(spoolDir, invocationId) {
|
|
14
|
-
if (invocationId !== undefined)
|
|
15
|
-
assertQualificationIdentifier(invocationId, "invocation_id");
|
|
16
|
-
const root = resolve(spoolDir);
|
|
17
|
-
const invocation = invocationId ? join(root, "invocations", invocationId) : undefined;
|
|
18
|
-
return {
|
|
19
|
-
root,
|
|
20
|
-
configuration: join(root, "configuration.json"),
|
|
21
|
-
accounting: join(root, "accounting.json"),
|
|
22
|
-
lock: join(root, ".qualification.lock"),
|
|
23
|
-
invocations: join(root, "invocations"),
|
|
24
|
-
artifacts: join(root, "artifacts"),
|
|
25
|
-
invocation,
|
|
26
|
-
invocationRecord: invocation ? join(invocation, "invocation.json") : undefined,
|
|
27
|
-
lifecycle: invocation ? join(invocation, "lifecycle.json") : undefined,
|
|
28
|
-
invocationLock: invocation ? join(invocation, ".lock") : undefined,
|
|
29
|
-
terminal: invocation ? join(invocation, "terminal") : undefined,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
export function prepareQualificationInvocation(options) {
|
|
33
|
-
const config = parseQualificationConfig(parseJsonFile(options.config_path, "qualification configuration"));
|
|
34
|
-
const request = parseQualificationRequest(parseJsonFile(options.request_path, "qualification invocation request"), config);
|
|
35
|
-
const approvedConfigSha = qualificationConfigDigest(config);
|
|
36
|
-
if (config.mode === "production" && (!/^[a-f0-9]{64}$/.test(options.expected_configuration_sha256 ?? "") || options.expected_configuration_sha256 !== approvedConfigSha)) {
|
|
37
|
-
throw new Error("qualification production prepare requires the externally approved exact configuration SHA-256");
|
|
38
|
-
}
|
|
39
|
-
verifyQualificationPins(config);
|
|
40
|
-
if (options.test_hooks && config.mode !== "test")
|
|
41
|
-
throw new Error("qualification prepare test hooks are prohibited in production");
|
|
42
|
-
const now = options.now ?? (() => new Date().toISOString());
|
|
43
|
-
assertRegularInput(request.scenario.input_path, request.scenario.input_sha256);
|
|
44
|
-
options.test_hooks?.after_initial_input_validation?.();
|
|
45
|
-
assertWorkingDirectory(request.scenario.working_directory);
|
|
46
|
-
const paths = qualificationSpoolPaths(options.spool_dir, request.invocation_id);
|
|
47
|
-
mkdirSync(paths.root, { recursive: true, mode: 0o700 });
|
|
48
|
-
assertDirectory(paths.root, "qualification spool");
|
|
49
|
-
return withQualificationFileLock(paths.lock, () => {
|
|
50
|
-
mkdirSync(paths.invocations, { recursive: true, mode: 0o700 });
|
|
51
|
-
mkdirSync(paths.artifacts, { recursive: true, mode: 0o700 });
|
|
52
|
-
const configSha = approvedConfigSha;
|
|
53
|
-
initializeSpoolConfiguration(paths.configuration, config, configSha);
|
|
54
|
-
initializeAccounting(paths.accounting, config.accounting);
|
|
55
|
-
if (config.judge_panel) {
|
|
56
|
-
const existingIds = readdirSync(paths.invocations, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
57
|
-
for (const existingId of existingIds) {
|
|
58
|
-
const existing = readQualificationInvocation(paths.root, existingId);
|
|
59
|
-
if (request.panel && existing.panel?.id === request.panel.id && existing.panel.member_ordinal === request.panel.member_ordinal) {
|
|
60
|
-
throw new Error(`qualification panel ${request.panel.id} already has member ordinal ${request.panel.member_ordinal}`);
|
|
61
|
-
}
|
|
62
|
-
if (request.role === "subject" && existing.role === "subject" && existing.scenario.id === request.scenario.id &&
|
|
63
|
-
existing.repetition === request.repetition && existing.arms.selected === request.selected_arm) {
|
|
64
|
-
throw new Error("qualification approved board already has this subject scenario/arm/repetition invocation");
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
const expectedArtifact = `artifacts/${request.invocation_id}.jsonl`;
|
|
69
|
-
const artifactPath = join(paths.root, expectedArtifact);
|
|
70
|
-
if (existsSync(artifactPath))
|
|
71
|
-
throw new Error(`qualification artifact path already exists: ${expectedArtifact}`);
|
|
72
|
-
try {
|
|
73
|
-
mkdirSync(paths.invocation, { mode: 0o700 });
|
|
74
|
-
}
|
|
75
|
-
catch (error) {
|
|
76
|
-
if (error.code === "EEXIST")
|
|
77
|
-
throw new Error(`duplicate invocation id ${request.invocation_id}`);
|
|
78
|
-
throw error;
|
|
79
|
-
}
|
|
80
|
-
try {
|
|
81
|
-
const selected = config.arms.find((arm) => arm.id === request.selected_arm);
|
|
82
|
-
const executableIdentity = verifyQualificationExecutable(selected.executable);
|
|
83
|
-
const preparedResources = selected.resources.map((resource, index) => {
|
|
84
|
-
verifyQualificationResource(resource);
|
|
85
|
-
const suffix = extname(resource.path).replace(/[^A-Za-z0-9.]/g, "");
|
|
86
|
-
const path = join(paths.invocation, "resources", `${String(index).padStart(4, "0")}${suffix}`);
|
|
87
|
-
atomicWriteBytes(path, readFileSync(resource.path), true);
|
|
88
|
-
return { ...resource, path };
|
|
89
|
-
});
|
|
90
|
-
const workingStat = lstatSync(request.scenario.working_directory);
|
|
91
|
-
const workingDirectoryIdentity = { realpath: realpathSync(request.scenario.working_directory), device: workingStat.dev, inode: workingStat.ino };
|
|
92
|
-
// Snapshot the already-digested private input before publishing the immutable
|
|
93
|
-
// invocation record. The child never races a later edit to the coordinator's
|
|
94
|
-
// source path; the spool copy is the launch material bound by input_sha256.
|
|
95
|
-
const preparedInputPath = join(paths.invocation, "input.bin");
|
|
96
|
-
const exactInputBytes = readExactSourceInput(request.scenario.input_path, request.scenario.input_sha256);
|
|
97
|
-
let parsedInput;
|
|
98
|
-
if (config.terminal_receipt_version === QUALIFICATION_TERMINAL_RECEIPT_VERSION_V3) {
|
|
99
|
-
try {
|
|
100
|
-
parsedInput = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(exactInputBytes));
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
throw new Error("qualification terminal receipt v3 invocation input must be valid UTF-8 application/json bytes");
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
if (request.panel && !jsonContainsExactString(parsedInput, request.panel.subject_artifact_sha256)) {
|
|
107
|
-
throw new Error("qualification judge-panel input must structurally contain its bound subject artifact SHA-256");
|
|
108
|
-
}
|
|
109
|
-
atomicWriteBytes(preparedInputPath, exactInputBytes, true);
|
|
110
|
-
const oauthDirectoryPolicy = qualificationOAuthDirectoryPolicy(config);
|
|
111
|
-
const v3Input = config.terminal_receipt_version === QUALIFICATION_TERMINAL_RECEIPT_VERSION_V3
|
|
112
|
-
? inspectQualificationInvocationInput(preparedInputPath)
|
|
113
|
-
: null;
|
|
114
|
-
const baseRecord = {
|
|
115
|
-
schema_version: v3Input
|
|
116
|
-
? request.panel ? QUALIFICATION_INVOCATION_VERSION_V4 : QUALIFICATION_INVOCATION_VERSION_V3
|
|
117
|
-
: oauthDirectoryPolicy === QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2
|
|
118
|
-
? QUALIFICATION_INVOCATION_VERSION_V2
|
|
119
|
-
: QUALIFICATION_INVOCATION_VERSION,
|
|
120
|
-
...(oauthDirectoryPolicy === QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2
|
|
121
|
-
? { oauth_directory_policy: QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2 }
|
|
122
|
-
: {}),
|
|
123
|
-
...(v3Input ? {
|
|
124
|
-
terminal_receipt_version: QUALIFICATION_TERMINAL_RECEIPT_VERSION_V3,
|
|
125
|
-
invocation_input: v3Input.binding,
|
|
126
|
-
invocation_input_occurrence: v3Input.occurrence,
|
|
127
|
-
prepared_attempt: 0,
|
|
128
|
-
accounting_event_sha256: null,
|
|
129
|
-
} : {}),
|
|
130
|
-
invocation_id: request.invocation_id,
|
|
131
|
-
measurement_identity_sha256: request.measurement_identity_sha256,
|
|
132
|
-
continuation_authority_sha256: request.continuation_authority_sha256,
|
|
133
|
-
continuation_authority_expires_at: request.continuation_authority_expires_at,
|
|
134
|
-
scenario: { ...structuredClone(request.scenario), input_path: preparedInputPath },
|
|
135
|
-
role: request.role,
|
|
136
|
-
counts_as_measurement: request.counts_as_measurement,
|
|
137
|
-
arms: { subject: request.arms.subject, judge: request.arms.judge, selected: request.selected_arm },
|
|
138
|
-
repetition: request.repetition,
|
|
139
|
-
...(request.panel ? { panel: structuredClone(request.panel) } : {}),
|
|
140
|
-
requested: { provider: selected.provider, model: selected.model },
|
|
141
|
-
authentication: selected.authentication,
|
|
142
|
-
pins: {
|
|
143
|
-
product: structuredClone(config.product),
|
|
144
|
-
engine: structuredClone(config.engine),
|
|
145
|
-
producer: structuredClone(config.producer),
|
|
146
|
-
runner: structuredClone(config.runner),
|
|
147
|
-
},
|
|
148
|
-
configuration_sha256: configSha,
|
|
149
|
-
created_at: validTimestamp(now(), "qualification creation time"),
|
|
150
|
-
ceiling_policy: structuredClone(config.accounting),
|
|
151
|
-
execution: {
|
|
152
|
-
executable: structuredClone(selected.executable),
|
|
153
|
-
executable_identity: executableIdentity,
|
|
154
|
-
resources: preparedResources,
|
|
155
|
-
working_directory_identity: workingDirectoryIdentity,
|
|
156
|
-
arguments: [...selected.arguments],
|
|
157
|
-
allowed_environment_names: [...selected.allowed_environment_names],
|
|
158
|
-
timeout_ms: selected.timeout_ms,
|
|
159
|
-
output_limit_bytes: selected.output_limit_bytes,
|
|
160
|
-
fallback: false,
|
|
161
|
-
metered_override: false,
|
|
162
|
-
},
|
|
163
|
-
expected_artifact: { path: expectedArtifact, type: "pi-jsonl" },
|
|
164
|
-
};
|
|
165
|
-
const record = {
|
|
166
|
-
...baseRecord,
|
|
167
|
-
invocation_sha256: qualificationSha256(qualificationCanonicalJson(baseRecord)),
|
|
168
|
-
};
|
|
169
|
-
atomicWriteCanonical(paths.invocationRecord, record, true);
|
|
170
|
-
atomicWriteCanonical(paths.lifecycle, createQualificationLifecycle(record.invocation_id, record.created_at), true);
|
|
171
|
-
return record;
|
|
172
|
-
}
|
|
173
|
-
catch (error) {
|
|
174
|
-
rmSync(paths.invocation, { recursive: true, force: true });
|
|
175
|
-
throw error;
|
|
176
|
-
}
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
export function readQualificationInvocation(spoolDir, invocationId) {
|
|
180
|
-
const path = qualificationSpoolPaths(spoolDir, invocationId).invocationRecord;
|
|
181
|
-
const value = readCanonicalJson(path, `qualification invocation ${invocationId}`);
|
|
182
|
-
validateQualificationInvocationRecord(value);
|
|
183
|
-
if (value.invocation_id !== invocationId)
|
|
184
|
-
throw new Error(`qualification invocation ${invocationId} record identity mismatch`);
|
|
185
|
-
if (isInputBoundInvocation(value))
|
|
186
|
-
verifyQualificationInvocationInput(value);
|
|
187
|
-
return value;
|
|
188
|
-
}
|
|
189
|
-
export function readQualificationLifecycle(spoolDir, invocationId) {
|
|
190
|
-
const path = qualificationSpoolPaths(spoolDir, invocationId).lifecycle;
|
|
191
|
-
const value = readCanonicalJson(path, `qualification lifecycle ${invocationId}`);
|
|
192
|
-
return validateQualificationLifecycle(value, invocationId);
|
|
193
|
-
}
|
|
194
|
-
export function writeQualificationLifecycle(spoolDir, lifecycle) {
|
|
195
|
-
validateQualificationLifecycle(lifecycle, lifecycle.invocation_id);
|
|
196
|
-
atomicWriteCanonical(qualificationSpoolPaths(spoolDir, lifecycle.invocation_id).lifecycle, lifecycle, false);
|
|
197
|
-
}
|
|
198
|
-
export function transitionQualificationLifecycle(lifecycle, to, options) {
|
|
199
|
-
validateQualificationLifecycle(lifecycle, lifecycle.invocation_id);
|
|
200
|
-
const allowed = {
|
|
201
|
-
prepared: ["launch-claimed", "terminal"],
|
|
202
|
-
"launch-claimed": ["running", "terminal"],
|
|
203
|
-
running: ["terminal"],
|
|
204
|
-
terminal: [],
|
|
205
|
-
};
|
|
206
|
-
if (!allowed[lifecycle.phase].includes(to))
|
|
207
|
-
throw new Error(`illegal qualification lifecycle transition ${lifecycle.phase} -> ${to}`);
|
|
208
|
-
const terminalStatus = to === "terminal" ? options.terminal_status ?? null : null;
|
|
209
|
-
if (to === "terminal" && !terminalStatus)
|
|
210
|
-
throw new Error("qualification terminal transition requires terminal_status");
|
|
211
|
-
if (to !== "terminal" && options.terminal_status)
|
|
212
|
-
throw new Error("qualification nonterminal transition cannot carry terminal_status");
|
|
213
|
-
const base = {
|
|
214
|
-
seq: lifecycle.events.length + 1,
|
|
215
|
-
from: lifecycle.phase,
|
|
216
|
-
to,
|
|
217
|
-
terminal_status: terminalStatus,
|
|
218
|
-
at: validTimestamp(options.at, "qualification lifecycle time"),
|
|
219
|
-
detail: sanitizeDetail(options.detail ?? {}),
|
|
220
|
-
previous_sha256: lifecycle.chain_head,
|
|
221
|
-
};
|
|
222
|
-
const event = { ...base, event_sha256: qualificationSha256(qualificationCanonicalJson(base)) };
|
|
223
|
-
return {
|
|
224
|
-
...lifecycle,
|
|
225
|
-
phase: to,
|
|
226
|
-
terminal_status: terminalStatus,
|
|
227
|
-
events: [...lifecycle.events, event],
|
|
228
|
-
chain_head: event.event_sha256,
|
|
229
|
-
};
|
|
230
|
-
}
|
|
231
|
-
export function createQualificationAccountingLedger(policy = QUALIFICATION_ACCOUNTING_POLICY) {
|
|
232
|
-
assertKnownAccountingPolicy(policy);
|
|
233
|
-
return {
|
|
234
|
-
schema_version: QUALIFICATION_ACCOUNTING_VERSION,
|
|
235
|
-
policy: structuredClone(policy),
|
|
236
|
-
events: [],
|
|
237
|
-
chain_head: null,
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
export function appendQualificationAccountingEvent(inputLedger, input) {
|
|
241
|
-
const report = validateQualificationAccountingLedger(inputLedger);
|
|
242
|
-
validateAccountingInput(input);
|
|
243
|
-
if (inputLedger.events.some((event) => event.invocation_id === input.invocation_id)) {
|
|
244
|
-
throw new Error(`duplicate invocation id ${input.invocation_id} in qualification accounting ledger`);
|
|
245
|
-
}
|
|
246
|
-
const next = report.counts[input.call_class] + 1;
|
|
247
|
-
const ceiling = inputLedger.policy.ceilings[input.call_class];
|
|
248
|
-
if (next > ceiling)
|
|
249
|
-
throw new Error(`qualification ${input.call_class} ceiling ${ceiling} would be exceeded before launch claim`);
|
|
250
|
-
const base = {
|
|
251
|
-
seq: inputLedger.events.length + 1,
|
|
252
|
-
invocation_id: input.invocation_id,
|
|
253
|
-
role: input.role,
|
|
254
|
-
call_class: input.call_class,
|
|
255
|
-
counts_as_measurement: input.counts_as_measurement,
|
|
256
|
-
launched_at: validTimestamp(input.launched_at, "qualification accounting launch time"),
|
|
257
|
-
previous_sha256: inputLedger.chain_head,
|
|
258
|
-
};
|
|
259
|
-
const event = { ...base, event_sha256: qualificationSha256(qualificationCanonicalJson(base)) };
|
|
260
|
-
return { ...inputLedger, events: [...inputLedger.events, event], chain_head: event.event_sha256 };
|
|
261
|
-
}
|
|
262
|
-
export function validateQualificationAccountingLedger(value) {
|
|
263
|
-
if (!plainObject(value))
|
|
264
|
-
throw new Error("qualification accounting ledger must be an object");
|
|
265
|
-
exactObjectKeys(value, ["schema_version", "policy", "events", "chain_head"], "qualification accounting ledger");
|
|
266
|
-
if (value.schema_version !== QUALIFICATION_ACCOUNTING_VERSION)
|
|
267
|
-
throw new Error(`unsupported qualification accounting schema ${String(value.schema_version)}`);
|
|
268
|
-
assertKnownAccountingPolicy(value.policy);
|
|
269
|
-
const policy = value.policy;
|
|
270
|
-
if (!Array.isArray(value.events))
|
|
271
|
-
throw new Error("qualification accounting events must be an array");
|
|
272
|
-
let previous = null;
|
|
273
|
-
const ids = new Set();
|
|
274
|
-
const roles = {};
|
|
275
|
-
const counts = { subject: 0, judge: 0, measurement: 0, total: 0 };
|
|
276
|
-
value.events.forEach((raw, index) => {
|
|
277
|
-
if (!plainObject(raw))
|
|
278
|
-
throw new Error(`qualification accounting event ${index + 1} must be an object`);
|
|
279
|
-
exactObjectKeys(raw, ["seq", "invocation_id", "role", "call_class", "counts_as_measurement", "launched_at", "previous_sha256", "event_sha256"], `qualification accounting event ${index + 1}`);
|
|
280
|
-
const input = raw;
|
|
281
|
-
validateAccountingInput(input);
|
|
282
|
-
if (input.seq !== index + 1)
|
|
283
|
-
throw new Error(`qualification accounting sequence is reordered or non-contiguous at event ${index + 1}`);
|
|
284
|
-
if (input.previous_sha256 !== previous)
|
|
285
|
-
throw new Error(`qualification accounting hash chain is broken at event ${index + 1}`);
|
|
286
|
-
const { event_sha256, ...base } = input;
|
|
287
|
-
if (!/^[a-f0-9]{64}$/.test(String(event_sha256)) || qualificationSha256(qualificationCanonicalJson(base)) !== event_sha256) {
|
|
288
|
-
throw new Error(`qualification accounting event ${index + 1} digest mismatch`);
|
|
289
|
-
}
|
|
290
|
-
if (ids.has(input.invocation_id))
|
|
291
|
-
throw new Error(`duplicate invocation id ${input.invocation_id} in qualification accounting ledger`);
|
|
292
|
-
ids.add(input.invocation_id);
|
|
293
|
-
previous = event_sha256;
|
|
294
|
-
counts[input.call_class] += 1;
|
|
295
|
-
counts.total += 1;
|
|
296
|
-
if (input.counts_as_measurement)
|
|
297
|
-
counts.measurement += 1;
|
|
298
|
-
roles[input.role] = (roles[input.role] ?? 0) + 1;
|
|
299
|
-
if (counts[input.call_class] > policy.ceilings[input.call_class]) {
|
|
300
|
-
throw new Error(`qualification ${input.call_class} ceiling ${policy.ceilings[input.call_class]} exceeded`);
|
|
301
|
-
}
|
|
302
|
-
});
|
|
303
|
-
if (value.chain_head !== previous)
|
|
304
|
-
throw new Error("qualification accounting chain head does not match the event ledger");
|
|
305
|
-
return { counts, roles, chain_head: previous };
|
|
306
|
-
}
|
|
307
|
-
export function readQualificationAccounting(spoolDir) {
|
|
308
|
-
const path = qualificationSpoolPaths(spoolDir).accounting;
|
|
309
|
-
const value = readCanonicalJson(path, "qualification accounting ledger");
|
|
310
|
-
validateQualificationAccountingLedger(value);
|
|
311
|
-
return value;
|
|
312
|
-
}
|
|
313
|
-
export function writeQualificationAccounting(spoolDir, ledger) {
|
|
314
|
-
validateQualificationAccountingLedger(ledger);
|
|
315
|
-
atomicWriteCanonical(qualificationSpoolPaths(spoolDir).accounting, ledger, false);
|
|
316
|
-
}
|
|
317
|
-
export function validateQualificationSpool(spoolDir) {
|
|
318
|
-
const paths = qualificationSpoolPaths(spoolDir);
|
|
319
|
-
const envelope = readCanonicalJson(paths.configuration, "qualification spool configuration");
|
|
320
|
-
if (!plainObject(envelope))
|
|
321
|
-
throw new Error("qualification spool configuration must be an object");
|
|
322
|
-
exactObjectKeys(envelope, ["schema_version", "configuration_sha256", "configuration"], "qualification spool configuration");
|
|
323
|
-
if (envelope.schema_version !== "qualification-spool-config-v1")
|
|
324
|
-
throw new Error("qualification spool configuration schema is unsupported");
|
|
325
|
-
const config = parseQualificationConfig(envelope.configuration);
|
|
326
|
-
const configSha = qualificationConfigDigest(config);
|
|
327
|
-
if (envelope.configuration_sha256 !== configSha)
|
|
328
|
-
throw new Error("qualification spool configuration digest mismatch");
|
|
329
|
-
const ledger = readQualificationAccounting(paths.root);
|
|
330
|
-
const accounting = validateQualificationAccountingLedger(ledger);
|
|
331
|
-
if (qualificationCanonicalJson(ledger.policy) !== qualificationCanonicalJson(config.accounting))
|
|
332
|
-
throw new Error("qualification accounting ledger policy does not match spool configuration");
|
|
333
|
-
const invocationEntries = existsSync(paths.invocations) ? readdirSync(paths.invocations, { withFileTypes: true }) : [];
|
|
334
|
-
const invalidEntry = invocationEntries.find((entry) => !entry.isDirectory());
|
|
335
|
-
if (invalidEntry)
|
|
336
|
-
throw new Error(`qualification invocations contain undeclared non-directory entry ${invalidEntry.name}`);
|
|
337
|
-
const dirs = invocationEntries.map((entry) => entry.name).sort();
|
|
338
|
-
const records = new Map();
|
|
339
|
-
for (const id of dirs) {
|
|
340
|
-
const record = readQualificationInvocation(paths.root, id);
|
|
341
|
-
if (record.configuration_sha256 !== configSha)
|
|
342
|
-
throw new Error(`qualification invocation ${id} has the wrong configuration digest`);
|
|
343
|
-
const expectedOAuthPolicy = qualificationOAuthDirectoryPolicy(config);
|
|
344
|
-
const expectedInvocationVersion = config.terminal_receipt_version === QUALIFICATION_TERMINAL_RECEIPT_VERSION_V3
|
|
345
|
-
? record.panel ? QUALIFICATION_INVOCATION_VERSION_V4 : QUALIFICATION_INVOCATION_VERSION_V3
|
|
346
|
-
: expectedOAuthPolicy === QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2
|
|
347
|
-
? QUALIFICATION_INVOCATION_VERSION_V2
|
|
348
|
-
: QUALIFICATION_INVOCATION_VERSION;
|
|
349
|
-
if (record.schema_version !== expectedInvocationVersion ||
|
|
350
|
-
(expectedOAuthPolicy === QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2 && record.oauth_directory_policy !== expectedOAuthPolicy) ||
|
|
351
|
-
(expectedOAuthPolicy !== QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2 && Object.hasOwn(record, "oauth_directory_policy"))) {
|
|
352
|
-
throw new Error(`qualification invocation ${id} OAuth directory policy does not match its configuration identity`);
|
|
353
|
-
}
|
|
354
|
-
const lifecycle = readQualificationLifecycle(paths.root, id);
|
|
355
|
-
if (lifecycle.invocation_id !== id)
|
|
356
|
-
throw new Error(`qualification invocation ${id} lifecycle identity mismatch`);
|
|
357
|
-
records.set(id, record);
|
|
358
|
-
}
|
|
359
|
-
for (const event of ledger.events) {
|
|
360
|
-
const record = records.get(event.invocation_id);
|
|
361
|
-
if (!record)
|
|
362
|
-
throw new Error(`qualification accounting event references missing invocation ${event.invocation_id}`);
|
|
363
|
-
const arm = config.arms.find((candidate) => candidate.id === record.arms.selected);
|
|
364
|
-
if (!arm || arm.kind !== event.call_class || record.role !== event.role || record.counts_as_measurement !== event.counts_as_measurement) {
|
|
365
|
-
throw new Error(`qualification accounting event contradicts invocation ${event.invocation_id}`);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
return { ok: true, configuration_sha256: configSha, invocations: records.size, accounting };
|
|
369
|
-
}
|
|
370
|
-
export function readQualificationSpoolConfig(spoolDir) {
|
|
371
|
-
const envelope = readCanonicalJson(qualificationSpoolPaths(spoolDir).configuration, "qualification spool configuration");
|
|
372
|
-
if (!plainObject(envelope) || !Object.hasOwn(envelope, "configuration"))
|
|
373
|
-
throw new Error("qualification spool configuration is malformed");
|
|
374
|
-
const config = parseQualificationConfig(envelope.configuration);
|
|
375
|
-
if (envelope.configuration_sha256 !== qualificationConfigDigest(config))
|
|
376
|
-
throw new Error("qualification spool configuration digest mismatch");
|
|
377
|
-
return config;
|
|
378
|
-
}
|
|
379
|
-
export function withQualificationFileLock(lockPath, action) {
|
|
380
|
-
const token = randomBytes(16).toString("hex");
|
|
381
|
-
const io = { read: readCanonicalJson, write: atomicWriteCanonical };
|
|
382
|
-
while (true) {
|
|
383
|
-
if (tryPublishQualificationLock(lockPath, token, io))
|
|
384
|
-
break;
|
|
385
|
-
const owner = readQualificationLockOwner(lockPath, io);
|
|
386
|
-
if (!owner)
|
|
387
|
-
continue;
|
|
388
|
-
if (qualificationLockOwnerIsLive(owner))
|
|
389
|
-
throw new Error(`qualification state is busy: lock exists at ${lockPath}`);
|
|
390
|
-
reclaimQualificationLock(lockPath, owner.token, token, io);
|
|
391
|
-
}
|
|
392
|
-
try {
|
|
393
|
-
return action();
|
|
394
|
-
}
|
|
395
|
-
finally {
|
|
396
|
-
releaseQualificationLock(lockPath, token, io);
|
|
397
|
-
}
|
|
398
|
-
}
|
|
399
|
-
export function atomicWriteCanonical(path, value, exclusive) {
|
|
400
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
401
|
-
const text = `${qualificationCanonicalJson(value)}\n`;
|
|
402
|
-
const temporary = join(dirname(path), `.${randomBytes(12).toString("hex")}.tmp`);
|
|
403
|
-
let fd;
|
|
404
|
-
try {
|
|
405
|
-
fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
406
|
-
writeFileSync(fd, text, "utf8");
|
|
407
|
-
fsyncSync(fd);
|
|
408
|
-
closeSync(fd);
|
|
409
|
-
fd = undefined;
|
|
410
|
-
if (exclusive) {
|
|
411
|
-
try {
|
|
412
|
-
linkSync(temporary, path);
|
|
413
|
-
}
|
|
414
|
-
catch (error) {
|
|
415
|
-
if (error.code === "EEXIST")
|
|
416
|
-
throw new Error(`qualification immutable record already exists: ${path}`);
|
|
417
|
-
throw error;
|
|
418
|
-
}
|
|
419
|
-
unlinkSync(temporary);
|
|
420
|
-
}
|
|
421
|
-
else {
|
|
422
|
-
renameSync(temporary, path);
|
|
423
|
-
}
|
|
424
|
-
fsyncQualificationDirectory(dirname(path));
|
|
425
|
-
}
|
|
426
|
-
finally {
|
|
427
|
-
if (fd !== undefined)
|
|
428
|
-
closeSync(fd);
|
|
429
|
-
rmSync(temporary, { force: true });
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
export function atomicWriteBytes(path, bytes, exclusive) {
|
|
433
|
-
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
434
|
-
const temporary = join(dirname(path), `.${randomBytes(12).toString("hex")}.tmp`);
|
|
435
|
-
let fd;
|
|
436
|
-
try {
|
|
437
|
-
fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
438
|
-
writeFileSync(fd, bytes);
|
|
439
|
-
fsyncSync(fd);
|
|
440
|
-
closeSync(fd);
|
|
441
|
-
fd = undefined;
|
|
442
|
-
if (exclusive) {
|
|
443
|
-
linkSync(temporary, path);
|
|
444
|
-
unlinkSync(temporary);
|
|
445
|
-
}
|
|
446
|
-
else
|
|
447
|
-
renameSync(temporary, path);
|
|
448
|
-
fsyncQualificationDirectory(dirname(path));
|
|
449
|
-
}
|
|
450
|
-
finally {
|
|
451
|
-
if (fd !== undefined)
|
|
452
|
-
closeSync(fd);
|
|
453
|
-
rmSync(temporary, { force: true });
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
export function readCanonicalJson(path, ctx) {
|
|
457
|
-
let text;
|
|
458
|
-
let fd;
|
|
459
|
-
try {
|
|
460
|
-
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
461
|
-
const stat = fstatSync(fd);
|
|
462
|
-
if (!stat.isFile())
|
|
463
|
-
throw new Error(`${ctx} is not a regular file`);
|
|
464
|
-
text = readFileSync(fd, "utf8");
|
|
465
|
-
}
|
|
466
|
-
catch (error) {
|
|
467
|
-
if (error instanceof Error && error.message.includes("not a regular file"))
|
|
468
|
-
throw error;
|
|
469
|
-
throw new Error(`${ctx} is missing or unreadable: ${path}`);
|
|
470
|
-
}
|
|
471
|
-
finally {
|
|
472
|
-
if (fd !== undefined)
|
|
473
|
-
closeSync(fd);
|
|
474
|
-
}
|
|
475
|
-
let value;
|
|
476
|
-
try {
|
|
477
|
-
value = JSON.parse(text);
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
throw new Error(`${ctx} contains invalid JSON`);
|
|
481
|
-
}
|
|
482
|
-
if (text !== `${qualificationCanonicalJson(value)}\n`)
|
|
483
|
-
throw new Error(`${ctx} is not canonical JSON`);
|
|
484
|
-
return value;
|
|
485
|
-
}
|
|
486
|
-
function initializeSpoolConfiguration(path, config, digest) {
|
|
487
|
-
const envelope = { schema_version: "qualification-spool-config-v1", configuration_sha256: digest, configuration: config };
|
|
488
|
-
if (!existsSync(path)) {
|
|
489
|
-
atomicWriteCanonical(path, envelope, true);
|
|
490
|
-
return;
|
|
491
|
-
}
|
|
492
|
-
const existing = readCanonicalJson(path, "qualification spool configuration");
|
|
493
|
-
if (qualificationCanonicalJson(existing) !== qualificationCanonicalJson(envelope)) {
|
|
494
|
-
throw new Error("qualification spool is already bound to a different configuration identity");
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
function initializeAccounting(path, policy) {
|
|
498
|
-
if (!existsSync(path))
|
|
499
|
-
atomicWriteCanonical(path, createQualificationAccountingLedger(policy), true);
|
|
500
|
-
else {
|
|
501
|
-
const existing = readCanonicalJson(path, "qualification accounting ledger");
|
|
502
|
-
validateQualificationAccountingLedger(existing);
|
|
503
|
-
if (qualificationCanonicalJson(existing.policy) !== qualificationCanonicalJson(policy))
|
|
504
|
-
throw new Error("qualification accounting ledger policy does not match spool configuration");
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
function createQualificationLifecycle(invocationId, at) {
|
|
508
|
-
const base = { seq: 1, from: null, to: "prepared", terminal_status: null, at, detail: {}, previous_sha256: null };
|
|
509
|
-
const event = { ...base, event_sha256: qualificationSha256(qualificationCanonicalJson(base)) };
|
|
510
|
-
return {
|
|
511
|
-
schema_version: QUALIFICATION_LIFECYCLE_VERSION,
|
|
512
|
-
invocation_id: invocationId,
|
|
513
|
-
phase: "prepared",
|
|
514
|
-
terminal_status: null,
|
|
515
|
-
events: [event],
|
|
516
|
-
chain_head: event.event_sha256,
|
|
517
|
-
};
|
|
518
|
-
}
|
|
519
|
-
function validateQualificationLifecycle(value, invocationId) {
|
|
520
|
-
if (!plainObject(value))
|
|
521
|
-
throw new Error(`qualification lifecycle ${invocationId} must be an object`);
|
|
522
|
-
exactObjectKeys(value, ["schema_version", "invocation_id", "phase", "terminal_status", "events", "chain_head"], `qualification lifecycle ${invocationId}`);
|
|
523
|
-
if (value.schema_version !== QUALIFICATION_LIFECYCLE_VERSION || value.invocation_id !== invocationId)
|
|
524
|
-
throw new Error(`qualification lifecycle ${invocationId} identity/version mismatch`);
|
|
525
|
-
if (!Array.isArray(value.events) || value.events.length === 0)
|
|
526
|
-
throw new Error(`qualification lifecycle ${invocationId} has no events`);
|
|
527
|
-
let previous = null;
|
|
528
|
-
let phase = null;
|
|
529
|
-
let terminal = null;
|
|
530
|
-
const allowed = { "<root>": ["prepared"], prepared: ["launch-claimed", "terminal"], "launch-claimed": ["running", "terminal"], running: ["terminal"], terminal: [] };
|
|
531
|
-
value.events.forEach((raw, index) => {
|
|
532
|
-
if (!plainObject(raw))
|
|
533
|
-
throw new Error(`qualification lifecycle ${invocationId} event ${index + 1} must be an object`);
|
|
534
|
-
exactObjectKeys(raw, ["seq", "from", "to", "terminal_status", "at", "detail", "previous_sha256", "event_sha256"], `qualification lifecycle ${invocationId} event ${index + 1}`);
|
|
535
|
-
const event = raw;
|
|
536
|
-
if (event.seq !== index + 1 || event.from !== phase || !allowed[phase ?? "<root>"].includes(event.to))
|
|
537
|
-
throw new Error(`qualification lifecycle ${invocationId} is reordered or contradictory at event ${index + 1}`);
|
|
538
|
-
if (event.previous_sha256 !== previous)
|
|
539
|
-
throw new Error(`qualification lifecycle ${invocationId} hash chain is broken at event ${index + 1}`);
|
|
540
|
-
validTimestamp(event.at, `qualification lifecycle ${invocationId} event time`);
|
|
541
|
-
if (!plainObject(event.detail))
|
|
542
|
-
throw new Error(`qualification lifecycle ${invocationId} event detail must be an object`);
|
|
543
|
-
const { event_sha256, ...base } = event;
|
|
544
|
-
if (qualificationSha256(qualificationCanonicalJson(base)) !== event_sha256)
|
|
545
|
-
throw new Error(`qualification lifecycle ${invocationId} event digest mismatch`);
|
|
546
|
-
if (event.to === "terminal" && !isTerminalStatus(event.terminal_status))
|
|
547
|
-
throw new Error(`qualification lifecycle ${invocationId} terminal event lacks status`);
|
|
548
|
-
if (event.to !== "terminal" && event.terminal_status !== null)
|
|
549
|
-
throw new Error(`qualification lifecycle ${invocationId} nonterminal event carries status`);
|
|
550
|
-
phase = event.to;
|
|
551
|
-
terminal = event.terminal_status;
|
|
552
|
-
previous = event.event_sha256;
|
|
553
|
-
});
|
|
554
|
-
if (value.phase !== phase || value.terminal_status !== terminal || value.chain_head !== previous)
|
|
555
|
-
throw new Error(`qualification lifecycle ${invocationId} snapshot contradicts its event chain`);
|
|
556
|
-
return value;
|
|
557
|
-
}
|
|
558
|
-
function validateQualificationInvocationRecord(value) {
|
|
559
|
-
if (!plainObject(value))
|
|
560
|
-
throw new Error("qualification invocation record must be an object");
|
|
561
|
-
const historical = ["schema_version", "invocation_id", "measurement_identity_sha256", "continuation_authority_sha256", "continuation_authority_expires_at", "scenario", "role", "counts_as_measurement", "arms", "repetition", "requested", "authentication", "pins", "configuration_sha256", "created_at", "ceiling_policy", "execution", "expected_artifact", "invocation_sha256"];
|
|
562
|
-
if (value.schema_version === QUALIFICATION_INVOCATION_VERSION) {
|
|
563
|
-
exactObjectKeys(value, historical, `qualification invocation ${String(value.invocation_id)}`);
|
|
564
|
-
}
|
|
565
|
-
else if (value.schema_version === QUALIFICATION_INVOCATION_VERSION_V2) {
|
|
566
|
-
exactObjectKeys(value, [...historical, "oauth_directory_policy"], `qualification invocation ${String(value.invocation_id)}`);
|
|
567
|
-
if (value.oauth_directory_policy !== QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2)
|
|
568
|
-
throw new Error("qualification invocation v2 OAuth directory policy is invalid");
|
|
569
|
-
}
|
|
570
|
-
else if (value.schema_version === QUALIFICATION_INVOCATION_VERSION_V3 || value.schema_version === QUALIFICATION_INVOCATION_VERSION_V4) {
|
|
571
|
-
const panelVersion = value.schema_version === QUALIFICATION_INVOCATION_VERSION_V4;
|
|
572
|
-
exactObjectKeys(value, [...historical, "oauth_directory_policy", "terminal_receipt_version", "invocation_input", "invocation_input_occurrence", "prepared_attempt", "accounting_event_sha256", ...(panelVersion ? ["panel"] : [])], `qualification invocation ${String(value.invocation_id)}`);
|
|
573
|
-
if (value.oauth_directory_policy !== QUALIFICATION_OAUTH_DIRECTORY_POLICY_V2)
|
|
574
|
-
throw new Error("qualification invocation v3 OAuth directory policy is invalid");
|
|
575
|
-
if (value.terminal_receipt_version !== QUALIFICATION_TERMINAL_RECEIPT_VERSION_V3)
|
|
576
|
-
throw new Error("qualification invocation v3 terminal receipt selection is invalid");
|
|
577
|
-
validateInvocationInputBinding(value.invocation_input);
|
|
578
|
-
validateInvocationInputOccurrence(value.invocation_input_occurrence);
|
|
579
|
-
if (value.prepared_attempt !== 0 || value.accounting_event_sha256 !== null)
|
|
580
|
-
throw new Error("qualification invocation input-bound prepared accounting identity is invalid");
|
|
581
|
-
if (panelVersion)
|
|
582
|
-
validateInvocationPanel(value.panel);
|
|
583
|
-
}
|
|
584
|
-
else {
|
|
585
|
-
throw new Error("qualification invocation version/identity is invalid");
|
|
586
|
-
}
|
|
587
|
-
assertQualificationIdentifier(value.invocation_id, "invocation_id");
|
|
588
|
-
if (!/^[a-f0-9]{64}$/.test(String(value.measurement_identity_sha256)) || !/^[a-f0-9]{64}$/.test(String(value.continuation_authority_sha256)) || !/^[a-f0-9]{64}$/.test(String(value.configuration_sha256)))
|
|
589
|
-
throw new Error(`qualification invocation ${value.invocation_id} digest identity is invalid`);
|
|
590
|
-
validTimestamp(String(value.continuation_authority_expires_at), `qualification invocation ${value.invocation_id} continuation authority expiry`);
|
|
591
|
-
validTimestamp(String(value.created_at), `qualification invocation ${value.invocation_id} creation time`);
|
|
592
|
-
assertKnownAccountingPolicy(value.ceiling_policy);
|
|
593
|
-
const { invocation_sha256: recordedDigest, ...digestInput } = value;
|
|
594
|
-
if (typeof recordedDigest !== "string" || !/^[a-f0-9]{64}$/.test(recordedDigest) || qualificationSha256(qualificationCanonicalJson(digestInput)) !== recordedDigest) {
|
|
595
|
-
throw new Error(`qualification invocation ${value.invocation_id} immutable digest mismatch`);
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
function assertKnownAccountingPolicy(value) {
|
|
599
|
-
const encoded = qualificationCanonicalJson(value);
|
|
600
|
-
if (encoded !== qualificationCanonicalJson(QUALIFICATION_ACCOUNTING_POLICY) && encoded !== qualificationCanonicalJson(QUALIFICATION_PANEL_ACCOUNTING_POLICY)) {
|
|
601
|
-
throw new Error("qualification accounting policy is corrupt");
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
function validateAccountingInput(value) {
|
|
605
|
-
if (!value || typeof value !== "object")
|
|
606
|
-
throw new Error("qualification accounting event input must be an object");
|
|
607
|
-
if (typeof value.invocation_id !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.invocation_id))
|
|
608
|
-
throw new Error("qualification accounting invocation_id is invalid");
|
|
609
|
-
if (!["holdout-author", "holdout-reviewer", "subject", "judge", "calibration", "canary"].includes(value.role))
|
|
610
|
-
throw new Error("qualification accounting role is invalid");
|
|
611
|
-
if (value.call_class !== "subject" && value.call_class !== "judge")
|
|
612
|
-
throw new Error("qualification accounting call_class is invalid");
|
|
613
|
-
if (typeof value.counts_as_measurement !== "boolean")
|
|
614
|
-
throw new Error("qualification accounting measurement flag is invalid");
|
|
615
|
-
const requiredMeasurement = value.role === "subject" || value.role === "judge";
|
|
616
|
-
if (value.counts_as_measurement !== requiredMeasurement) {
|
|
617
|
-
throw new Error(`qualification ${value.role} accounting must be ${requiredMeasurement ? "measurement" : "non-measurement"}`);
|
|
618
|
-
}
|
|
619
|
-
validTimestamp(value.launched_at, "qualification accounting launch time");
|
|
620
|
-
}
|
|
621
|
-
function validateInvocationInputBinding(value) {
|
|
622
|
-
if (!plainObject(value))
|
|
623
|
-
throw new Error("qualification invocation input binding must be an object");
|
|
624
|
-
exactObjectKeys(value, ["schema_version", "content_type", "bytes", "sha256"], "qualification invocation input binding");
|
|
625
|
-
if (value.schema_version !== QUALIFICATION_INVOCATION_INPUT_BINDING_VERSION)
|
|
626
|
-
throw new Error("qualification invocation input binding version is invalid");
|
|
627
|
-
if (value.content_type !== "application/json")
|
|
628
|
-
throw new Error("qualification invocation input content type is invalid");
|
|
629
|
-
if (!Number.isSafeInteger(value.bytes) || Number(value.bytes) < 1)
|
|
630
|
-
throw new Error("qualification invocation input byte count is invalid");
|
|
631
|
-
if (typeof value.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(value.sha256))
|
|
632
|
-
throw new Error("qualification invocation input digest is invalid");
|
|
633
|
-
}
|
|
634
|
-
function validateInvocationInputOccurrence(value) {
|
|
635
|
-
if (!plainObject(value))
|
|
636
|
-
throw new Error("qualification invocation input occurrence must be an object");
|
|
637
|
-
exactObjectKeys(value, ["path", "realpath", "device", "inode", "mode", "uid", "gid", "link_count", "bytes", "sha256"], "qualification invocation input occurrence");
|
|
638
|
-
for (const key of ["path", "realpath", "sha256"])
|
|
639
|
-
if (typeof value[key] !== "string")
|
|
640
|
-
throw new Error(`qualification invocation input occurrence ${key} is invalid`);
|
|
641
|
-
for (const key of ["device", "inode", "mode", "uid", "gid", "link_count", "bytes"])
|
|
642
|
-
if (!Number.isSafeInteger(value[key]))
|
|
643
|
-
throw new Error(`qualification invocation input occurrence ${key} is invalid`);
|
|
644
|
-
if (!/^[a-f0-9]{64}$/.test(String(value.sha256)) || Number(value.bytes) < 1 || Number(value.link_count) !== 1)
|
|
645
|
-
throw new Error("qualification invocation input occurrence identity is invalid");
|
|
646
|
-
}
|
|
647
|
-
function inspectQualificationInvocationInput(path) {
|
|
648
|
-
let fd;
|
|
649
|
-
try {
|
|
650
|
-
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
651
|
-
const stat = fstatSync(fd);
|
|
652
|
-
if (!stat.isFile())
|
|
653
|
-
throw new Error("qualification governed invocation input must be a regular file");
|
|
654
|
-
if (realpathSync(path) !== resolve(path))
|
|
655
|
-
throw new Error("qualification governed invocation input path traverses a symlink");
|
|
656
|
-
if (process.platform !== "win32" && (stat.uid !== process.getuid?.() || (stat.mode & 0o777) !== 0o600))
|
|
657
|
-
throw new Error("qualification governed invocation input must be private and owned by the qualification user");
|
|
658
|
-
if (stat.nlink !== 1)
|
|
659
|
-
throw new Error("qualification governed invocation input must have exactly one hard link");
|
|
660
|
-
const bytes = readFileSync(fd);
|
|
661
|
-
if (bytes.length < 1 || bytes.length !== stat.size)
|
|
662
|
-
throw new Error("qualification governed invocation input byte count is invalid");
|
|
663
|
-
const sha256 = qualificationSha256(bytes);
|
|
664
|
-
const binding = { schema_version: QUALIFICATION_INVOCATION_INPUT_BINDING_VERSION, content_type: "application/json", bytes: bytes.length, sha256 };
|
|
665
|
-
const occurrence = { path, realpath: realpathSync(path), device: stat.dev, inode: stat.ino, mode: stat.mode & 0o777, uid: stat.uid, gid: stat.gid, link_count: stat.nlink, bytes: bytes.length, sha256 };
|
|
666
|
-
return { binding, occurrence };
|
|
667
|
-
}
|
|
668
|
-
catch (error) {
|
|
669
|
-
if (error instanceof Error && error.message.startsWith("qualification governed invocation input"))
|
|
670
|
-
throw error;
|
|
671
|
-
throw new Error(`qualification governed invocation input is missing, replaced, or unsafe: ${path}`);
|
|
672
|
-
}
|
|
673
|
-
finally {
|
|
674
|
-
if (fd !== undefined)
|
|
675
|
-
closeSync(fd);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
function isInputBoundInvocation(invocation) {
|
|
679
|
-
return invocation.schema_version === QUALIFICATION_INVOCATION_VERSION_V3 || invocation.schema_version === QUALIFICATION_INVOCATION_VERSION_V4;
|
|
680
|
-
}
|
|
681
|
-
function validateInvocationPanel(value) {
|
|
682
|
-
if (!plainObject(value))
|
|
683
|
-
throw new Error("qualification invocation v4 panel binding must be an object");
|
|
684
|
-
exactObjectKeys(value, ["id", "member_ordinal", "subject_invocation_id", "subject_artifact_sha256"], "qualification invocation v4 panel binding");
|
|
685
|
-
assertQualificationIdentifier(value.id, "panel.id");
|
|
686
|
-
assertQualificationIdentifier(value.subject_invocation_id, "panel.subject_invocation_id");
|
|
687
|
-
if (![1, 2, 3].includes(Number(value.member_ordinal)) || !/^[a-f0-9]{64}$/.test(String(value.subject_artifact_sha256)))
|
|
688
|
-
throw new Error("qualification invocation v4 panel binding is invalid");
|
|
689
|
-
}
|
|
690
|
-
export function verifyQualificationInvocationInput(invocation) {
|
|
691
|
-
if (!isInputBoundInvocation(invocation) || !invocation.invocation_input || !invocation.invocation_input_occurrence)
|
|
692
|
-
throw new Error("qualification invocation does not carry an input binding");
|
|
693
|
-
const inspected = inspectQualificationInvocationInput(invocation.scenario.input_path);
|
|
694
|
-
if (qualificationCanonicalJson(inspected.binding) !== qualificationCanonicalJson(invocation.invocation_input))
|
|
695
|
-
throw new Error("qualification governed invocation input binding mismatch");
|
|
696
|
-
if (qualificationCanonicalJson(inspected.occurrence) !== qualificationCanonicalJson(invocation.invocation_input_occurrence))
|
|
697
|
-
throw new Error("qualification governed invocation input occurrence mismatch");
|
|
698
|
-
return inspected.binding;
|
|
699
|
-
}
|
|
700
|
-
function readExactSourceInput(path, expectedSha) {
|
|
701
|
-
let fd;
|
|
702
|
-
try {
|
|
703
|
-
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
704
|
-
const stat = fstatSync(fd);
|
|
705
|
-
if (!stat.isFile() || stat.nlink !== 1 || realpathSync(path) !== resolve(path))
|
|
706
|
-
throw new Error("qualification source input occurrence is unsafe");
|
|
707
|
-
const bytes = readFileSync(fd);
|
|
708
|
-
if (bytes.length !== stat.size || qualificationSha256(bytes) !== expectedSha)
|
|
709
|
-
throw new Error(`qualification input digest mismatch: expected ${expectedSha}, got ${qualificationSha256(bytes)}`);
|
|
710
|
-
return bytes;
|
|
711
|
-
}
|
|
712
|
-
catch (error) {
|
|
713
|
-
if (error instanceof Error && (error.message.includes("input digest mismatch") || error.message.includes("source input occurrence")))
|
|
714
|
-
throw error;
|
|
715
|
-
throw new Error(`qualification source input is missing, replaced, or unsafe: ${path}`);
|
|
716
|
-
}
|
|
717
|
-
finally {
|
|
718
|
-
if (fd !== undefined)
|
|
719
|
-
closeSync(fd);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
function jsonContainsExactString(value, expected) {
|
|
723
|
-
if (value === expected)
|
|
724
|
-
return true;
|
|
725
|
-
if (Array.isArray(value))
|
|
726
|
-
return value.some((entry) => jsonContainsExactString(entry, expected));
|
|
727
|
-
if (plainObject(value))
|
|
728
|
-
return Object.values(value).some((entry) => jsonContainsExactString(entry, expected));
|
|
729
|
-
return false;
|
|
730
|
-
}
|
|
731
|
-
function assertQualificationIdentifier(value, label) {
|
|
732
|
-
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value))
|
|
733
|
-
throw new Error(`qualification ${label} must be a bounded ASCII identifier`);
|
|
734
|
-
}
|
|
735
|
-
function assertRegularInput(path, expectedSha) {
|
|
736
|
-
let stat;
|
|
737
|
-
try {
|
|
738
|
-
stat = lstatSync(path);
|
|
739
|
-
}
|
|
740
|
-
catch {
|
|
741
|
-
throw new Error(`qualification input file does not exist: ${path}`);
|
|
742
|
-
}
|
|
743
|
-
if (stat.isSymbolicLink() || !stat.isFile() || realpathSync(path) !== resolve(path))
|
|
744
|
-
throw new Error(`qualification input must be a regular non-symlink file with no symlink path components: ${path}`);
|
|
745
|
-
const actual = qualificationSha256(readFileSync(path));
|
|
746
|
-
if (actual !== expectedSha)
|
|
747
|
-
throw new Error(`qualification input digest mismatch: expected ${expectedSha}, got ${actual}`);
|
|
748
|
-
}
|
|
749
|
-
function assertWorkingDirectory(path) {
|
|
750
|
-
let stat;
|
|
751
|
-
try {
|
|
752
|
-
stat = lstatSync(path);
|
|
753
|
-
}
|
|
754
|
-
catch {
|
|
755
|
-
throw new Error(`qualification working directory does not exist: ${path}`);
|
|
756
|
-
}
|
|
757
|
-
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
758
|
-
throw new Error(`qualification working directory must be a regular directory: ${path}`);
|
|
759
|
-
}
|
|
760
|
-
function assertDirectory(path, ctx) {
|
|
761
|
-
const stat = lstatSync(path);
|
|
762
|
-
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
763
|
-
throw new Error(`${ctx} must be a non-symlink directory: ${path}`);
|
|
764
|
-
if (process.platform !== "win32" && ((stat.mode & 0o077) !== 0 || stat.uid !== process.getuid?.())) {
|
|
765
|
-
throw new Error(`${ctx} must be private and owned by the qualification user: ${path}`);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
function parseJsonFile(path, ctx) {
|
|
769
|
-
let text;
|
|
770
|
-
try {
|
|
771
|
-
const stat = lstatSync(path);
|
|
772
|
-
if (stat.isSymbolicLink() || !stat.isFile())
|
|
773
|
-
throw new Error(`${ctx} path is not a regular file`);
|
|
774
|
-
text = readFileSync(path, "utf8");
|
|
775
|
-
}
|
|
776
|
-
catch (error) {
|
|
777
|
-
if (error instanceof Error && error.message.includes("not a regular file"))
|
|
778
|
-
throw error;
|
|
779
|
-
throw new Error(`${ctx} is missing or unreadable: ${path}`);
|
|
780
|
-
}
|
|
781
|
-
try {
|
|
782
|
-
return JSON.parse(text);
|
|
783
|
-
}
|
|
784
|
-
catch {
|
|
785
|
-
throw new Error(`${ctx} contains invalid JSON`);
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
function validTimestamp(value, ctx) {
|
|
789
|
-
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value) || !Number.isFinite(Date.parse(value)))
|
|
790
|
-
throw new Error(`${ctx} must be an RFC 3339 UTC timestamp`);
|
|
791
|
-
return value;
|
|
792
|
-
}
|
|
793
|
-
function isTerminalStatus(value) {
|
|
794
|
-
return ["completed", "failed", "timed-out", "aborted", "refused", "invalid-artifact"].includes(String(value));
|
|
795
|
-
}
|
|
796
|
-
function plainObject(value) {
|
|
797
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
798
|
-
return false;
|
|
799
|
-
const prototype = Object.getPrototypeOf(value);
|
|
800
|
-
return prototype === Object.prototype || prototype === null;
|
|
801
|
-
}
|
|
802
|
-
function exactObjectKeys(value, keys, ctx) {
|
|
803
|
-
const allowed = new Set(keys);
|
|
804
|
-
const unknown = Object.keys(value).find((key) => !allowed.has(key));
|
|
805
|
-
if (unknown)
|
|
806
|
-
throw new Error(`${ctx} contains unknown field ${unknown}`);
|
|
807
|
-
const missing = keys.find((key) => !Object.hasOwn(value, key));
|
|
808
|
-
if (missing)
|
|
809
|
-
throw new Error(`${ctx} is missing field ${missing}`);
|
|
810
|
-
}
|
|
811
|
-
function sanitizeDetail(value) {
|
|
812
|
-
const encoded = qualificationCanonicalJson(value);
|
|
813
|
-
if (Buffer.byteLength(encoded) > 16 * 1024)
|
|
814
|
-
throw new Error("qualification lifecycle detail exceeds 16384 bytes");
|
|
815
|
-
const secretLike = Object.keys(value).find((key) => /(?:secret|token|credential|api.?key)/i.test(key));
|
|
816
|
-
if (secretLike)
|
|
817
|
-
throw new Error(`qualification lifecycle detail must not contain secret-like field ${secretLike}`);
|
|
818
|
-
return JSON.parse(encoded);
|
|
819
|
-
}
|
|
820
|
-
export function fsyncQualificationDirectory(path) {
|
|
821
|
-
if (process.platform === "win32")
|
|
822
|
-
return;
|
|
823
|
-
let fd;
|
|
824
|
-
try {
|
|
825
|
-
fd = openSync(path, constants.O_RDONLY);
|
|
826
|
-
fsyncSync(fd);
|
|
827
|
-
}
|
|
828
|
-
finally {
|
|
829
|
-
if (fd !== undefined)
|
|
830
|
-
closeSync(fd);
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
//# sourceMappingURL=qualification-store.js.map
|