@tiphys/kernel 0.0.0 → 0.1.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/AGENTS.md +611 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +278 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +14 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +59 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +828 -0
- package/dist/src/checks.js +3314 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +57 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +320 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/doctor.d.ts +35 -0
- package/dist/src/commands/doctor.js +605 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +128 -0
- package/dist/src/commands/lock.d.ts +1 -0
- package/dist/src/commands/lock.js +229 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +128 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +146 -0
- package/dist/src/commands/status.d.ts +19 -0
- package/dist/src/commands/status.js +120 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +79 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +360 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/exec/env.d.ts +128 -0
- package/dist/src/exec/env.js +190 -0
- package/dist/src/fleet.d.ts +51 -0
- package/dist/src/fleet.js +80 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1163 -0
- package/dist/src/gates/coverage.d.ts +284 -0
- package/dist/src/gates/coverage.js +701 -0
- package/dist/src/gates/credentials.d.ts +74 -0
- package/dist/src/gates/credentials.js +533 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +390 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +566 -0
- package/dist/src/gates/run.js +1536 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +927 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +32 -0
- package/dist/src/hooks.js +62 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +178 -0
- package/dist/src/lock.js +500 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +2 -0
- package/dist/src/path-identity.js +10 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +130 -0
- package/dist/src/pool.js +721 -0
- package/dist/src/roles.d.ts +430 -0
- package/dist/src/roles.js +734 -0
- package/dist/src/spawn.d.ts +177 -0
- package/dist/src/spawn.js +332 -0
- package/dist/src/status.d.ts +91 -0
- package/dist/src/status.js +119 -0
- package/dist/src/task.d.ts +264 -0
- package/dist/src/task.js +305 -0
- package/dist/src/teardown.d.ts +32 -0
- package/dist/src/teardown.js +314 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +230 -0
- package/dist/src/validate.js +732 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +859 -0
- package/dist/src/witness/run.d.ts +274 -0
- package/dist/src/witness/run.js +1327 -0
- package/dist/src/witness/spec.d.ts +102 -0
- package/dist/src/witness/spec.js +253 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +390 -0
- package/gates.manifest.json +195 -0
- package/package.json +57 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +460 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +81 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +166 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +289 -0
- package/schemas/work-history.schema.json +183 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { readRegularFileIfPresent, refuseOpenForWrite, runStep, singleLine, } from "../task.js";
|
|
8
|
+
import { exitCodeForStatus, makeGateResult, renderGateResult, } from "./result.js";
|
|
9
|
+
import { loadSchema, validate, formatDiagnostics } from "./validate.js";
|
|
10
|
+
/**
|
|
11
|
+
* THE RELEASE-VERIFICATION CONTRACT (kernel plan M2, M2-P7 step 3; DR-0014;
|
|
12
|
+
* delivery/verification/release-verification-interface.md).
|
|
13
|
+
*
|
|
14
|
+
* ONE post-merge verification contract with two registry entries (`deploy`
|
|
15
|
+
* and `migrations`). The kernel owns the SUBJECT, the CLOCK, the LOOP and
|
|
16
|
+
* the OUTCOME MAPPING; an adapter owns the platform mapping and nothing
|
|
17
|
+
* else. An adapter is an executable named by committed configuration,
|
|
18
|
+
* spawned as a subprocess with a request file path in argv, performing ONE
|
|
19
|
+
* bounded observation per invocation and writing its response to a
|
|
20
|
+
* kernel-supplied record path. It returns an OUTCOME, never a status path:
|
|
21
|
+
* JSON-pointer extraction exists only inside the http-json adapter's own
|
|
22
|
+
* configuration, and the kernel never learns what a statusPath is.
|
|
23
|
+
*
|
|
24
|
+
* WHY THE BOUNDARY IS A PROCESS (M2-D-07, investigation section 5): it
|
|
25
|
+
* works for any language, and it makes the per-attempt timeout ENFORCEABLE
|
|
26
|
+
* rather than promised. The kernel terminates an attempt that overruns
|
|
27
|
+
* (`spawnSync`'s `timeout` and `killSignal` options) and records that
|
|
28
|
+
* attempt as `error`, so an adapter that hangs cannot hang the kernel.
|
|
29
|
+
*
|
|
30
|
+
* C-2 EXEMPTION, STATED WHERE THE TERMINATION HAPPENS AND NOWHERE ELSE.
|
|
31
|
+
* C-2 forbids pid, process liveness, signals and /proc FOR IDENTITY OR
|
|
32
|
+
* EXCLUSION. The per-attempt termination above is neither: it is a timeout
|
|
33
|
+
* bound on a child this kernel itself spawned and still holds the handle
|
|
34
|
+
* of, it probes nothing, identifies nothing and excludes nothing, and no
|
|
35
|
+
* pid is ever read, recorded or compared. That is the one place any kill
|
|
36
|
+
* may appear in this module or its adapters, and it appears as a spawn
|
|
37
|
+
* option, not as a process.kill call.
|
|
38
|
+
*
|
|
39
|
+
* NO FAILURE VOCABULARY SHIPS (plan step 2, T-003 lesson 4). No
|
|
40
|
+
* non-success platform state was ever captured, so none is named anywhere
|
|
41
|
+
* in this module or its adapters. The safe rule instead: one satisfying
|
|
42
|
+
* value per configured adapter; every other observed value is recorded
|
|
43
|
+
* verbatim and treated as `pending` until the deadline, at which point it
|
|
44
|
+
* becomes red naming the last observed value. Unknown never becomes green.
|
|
45
|
+
*
|
|
46
|
+
* NEVER auto-background (C-3): the loop is a foreground await, every spawn
|
|
47
|
+
* is spawnSync, nothing is detached and nothing is unref'd.
|
|
48
|
+
*/
|
|
49
|
+
/** The one declaration path (design decision D-p7-1 in the work history). */
|
|
50
|
+
export const DECLARATION_PATH = "release-verification.json";
|
|
51
|
+
/** The contract versions this kernel accepts (fail-closed rule 6). */
|
|
52
|
+
export const ACCEPTED_CONTRACT_VERSIONS = ["1"];
|
|
53
|
+
export const SUBJECT_FIELDS = [
|
|
54
|
+
"repository",
|
|
55
|
+
"integrationRef",
|
|
56
|
+
"mergedSha",
|
|
57
|
+
"mergedAt",
|
|
58
|
+
"phaseId",
|
|
59
|
+
];
|
|
60
|
+
export const VERIFICATION_OUTCOMES = [
|
|
61
|
+
"satisfied",
|
|
62
|
+
"failed",
|
|
63
|
+
"pending",
|
|
64
|
+
"absent",
|
|
65
|
+
"not-applicable",
|
|
66
|
+
"error",
|
|
67
|
+
];
|
|
68
|
+
/**
|
|
69
|
+
* THE TOTAL MAPPING to GateResult status, implemented in exactly one place
|
|
70
|
+
* (plan step 3). `satisfied` is green with `units` 1 per verification
|
|
71
|
+
* satisfied (or the adapter's own examined count, D-p7-3). `pending` and
|
|
72
|
+
* `absent` are NEVER terminal: they are loop states, and the two entries
|
|
73
|
+
* below are reachable only through the deadline conversion in
|
|
74
|
+
* `runVerification`, which turns both into red with TEXTUALLY DISTINCT
|
|
75
|
+
* reasons: `deadline reached, last observed <value>` for pending and
|
|
76
|
+
* `deadline reached, no release object for subject` for absent. An adapter
|
|
77
|
+
* cannot make pending mean pass.
|
|
78
|
+
*/
|
|
79
|
+
export const OUTCOME_TO_STATUS = {
|
|
80
|
+
satisfied: "green",
|
|
81
|
+
failed: "red",
|
|
82
|
+
pending: "red",
|
|
83
|
+
absent: "red",
|
|
84
|
+
"not-applicable": "not-applicable",
|
|
85
|
+
error: "error",
|
|
86
|
+
};
|
|
87
|
+
export const DEADLINE_REASON_ABSENT = "deadline reached, no release object for subject";
|
|
88
|
+
export function deadlineReasonPending(lastObserved) {
|
|
89
|
+
return `deadline reached, last observed ${lastObserved}`;
|
|
90
|
+
}
|
|
91
|
+
/** Schema loading, same resolution pattern as src/gates/manifest.ts. */
|
|
92
|
+
const schemaDirectory = new URL("./schemas/", import.meta.url);
|
|
93
|
+
let cachedResponseSchema;
|
|
94
|
+
let cachedDeclarationSchema;
|
|
95
|
+
function readSchemaDocument(name) {
|
|
96
|
+
const path = fileURLToPath(new URL(name, schemaDirectory));
|
|
97
|
+
const read = readRegularFileIfPresent(path);
|
|
98
|
+
if (read.kind !== "read") {
|
|
99
|
+
throw new Error(read.kind === "absent"
|
|
100
|
+
? `schema document ${path} is missing from this installation`
|
|
101
|
+
: read.reason);
|
|
102
|
+
}
|
|
103
|
+
const loaded = loadSchema(JSON.parse(read.body), name);
|
|
104
|
+
if (!loaded.ok) {
|
|
105
|
+
throw new Error(loaded.reason);
|
|
106
|
+
}
|
|
107
|
+
return loaded.schema;
|
|
108
|
+
}
|
|
109
|
+
export function responseSchema() {
|
|
110
|
+
if (cachedResponseSchema === undefined) {
|
|
111
|
+
cachedResponseSchema = readSchemaDocument("release-record.schema.json");
|
|
112
|
+
}
|
|
113
|
+
return cachedResponseSchema;
|
|
114
|
+
}
|
|
115
|
+
export function declarationSchema() {
|
|
116
|
+
if (cachedDeclarationSchema === undefined) {
|
|
117
|
+
cachedDeclarationSchema = readSchemaDocument("verifier-config.schema.json");
|
|
118
|
+
}
|
|
119
|
+
return cachedDeclarationSchema;
|
|
120
|
+
}
|
|
121
|
+
export function validateAdapterResponse(body, subject) {
|
|
122
|
+
let parsed;
|
|
123
|
+
try {
|
|
124
|
+
parsed = JSON.parse(body);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
rule: 2,
|
|
130
|
+
reason: `fail-closed rule 2: response does not parse as JSON: ${error.message}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const schemaDiagnostics = validate(responseSchema(), parsed);
|
|
134
|
+
const conditional = conditionalResponseDiagnostics(parsed);
|
|
135
|
+
const diagnostics = formatDiagnostics([...schemaDiagnostics, ...conditional]);
|
|
136
|
+
if (diagnostics.length > 0) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
rule: 2,
|
|
140
|
+
reason: `fail-closed rule 2: response fails schema validation: ${diagnostics.join("; ")}`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const response = parsed;
|
|
144
|
+
if (!ACCEPTED_CONTRACT_VERSIONS.includes(response.contractVersion)) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
rule: 6,
|
|
148
|
+
reason: `fail-closed rule 6: contractVersion ${JSON.stringify(response.contractVersion)} ` +
|
|
149
|
+
`is not recognized; this kernel accepts ${ACCEPTED_CONTRACT_VERSIONS.join(", ")}`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
// Rule 3, BEFORE the outcome is read (field by field, never a serialized
|
|
153
|
+
// comparison, so key order cannot fake a mismatch or hide one).
|
|
154
|
+
for (const field of SUBJECT_FIELDS) {
|
|
155
|
+
if (response.subject[field] !== subject[field]) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
rule: 3,
|
|
159
|
+
reason: `fail-closed rule 3: echoed subject differs from the passed subject at ` +
|
|
160
|
+
`${field}: passed ${JSON.stringify(subject[field])}, ` +
|
|
161
|
+
`echoed ${JSON.stringify(response.subject[field])}; ` +
|
|
162
|
+
`the outcome was not read`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!VERIFICATION_OUTCOMES.includes(response.outcome)) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
rule: 7,
|
|
170
|
+
reason: `fail-closed rule 7: outcome ${JSON.stringify(response.outcome)} is outside the ` +
|
|
171
|
+
`enum (${VERIFICATION_OUTCOMES.join(", ")}) and is never coerced`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (response.outcome === "satisfied" && response.resolved === undefined) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
rule: 4,
|
|
178
|
+
reason: "fail-closed rule 4: satisfied with no resolved identity; a verifier " +
|
|
179
|
+
"that cannot say what it looked at did not look",
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
if (response.outcome === "satisfied" && response.observation === undefined) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
rule: 5,
|
|
186
|
+
reason: "fail-closed rule 5: satisfied with no observation; a claim with no " +
|
|
187
|
+
"captured evidence behind it is treated as unknown",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return { ok: true, response };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The conditional half of the response schema (reason required for failed,
|
|
194
|
+
* absent, not-applicable and error), checked in code because conditional
|
|
195
|
+
* composition is outside the closed keyword set. Same diagnostic shape as
|
|
196
|
+
* the schema's own, so rule 2's message names the field either way.
|
|
197
|
+
*/
|
|
198
|
+
function conditionalResponseDiagnostics(parsed) {
|
|
199
|
+
const found = [];
|
|
200
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
const record = parsed;
|
|
204
|
+
const outcome = record["outcome"];
|
|
205
|
+
const needsReason = outcome === "failed" ||
|
|
206
|
+
outcome === "absent" ||
|
|
207
|
+
outcome === "not-applicable" ||
|
|
208
|
+
outcome === "error";
|
|
209
|
+
if (needsReason && typeof record["reason"] !== "string") {
|
|
210
|
+
found.push({
|
|
211
|
+
pointer: "#/reason",
|
|
212
|
+
message: `required property reason is missing for outcome ${String(outcome)}`,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
if (needsReason && typeof record["reason"] === "string" && record["reason"] === "") {
|
|
216
|
+
found.push({
|
|
217
|
+
pointer: "#/reason",
|
|
218
|
+
message: `required property reason is empty for outcome ${String(outcome)}`,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The encoded forms of a credential value the kernel can derive from the
|
|
225
|
+
* value alone (CR-P7H-3). Redacting only the verbatim bytes let a trivially
|
|
226
|
+
* reversible copy through: an adapter emitting the token as a standalone
|
|
227
|
+
* base64 blob (an HTTP Basic `Authorization: Basic <base64(token)>` of the
|
|
228
|
+
* token) leaked a recoverable secret into the stderr evidence. Each form here
|
|
229
|
+
* is a DETERMINISTIC, enumerable transform of the same value, so the set is
|
|
230
|
+
* bounded and cheap.
|
|
231
|
+
*
|
|
232
|
+
* WHAT THIS DOES NOT COVER, stated so a green is auditable rather than
|
|
233
|
+
* silently partial (never soften a work history): forms that fold in bytes
|
|
234
|
+
* the kernel does not hold, e.g. base64 of `"user:" + value` for a full Basic
|
|
235
|
+
* credential PAIR (the username is the project's, not the kernel's), or a
|
|
236
|
+
* value re-encoded by a transport the kernel never sees (gzip, hex, a second
|
|
237
|
+
* base64 round). Those are residue the reference adapters do not produce; a
|
|
238
|
+
* third-party adapter that composes a credential with unknown surrounding
|
|
239
|
+
* bytes owns that redaction, and the guarantee scoped here is the value and
|
|
240
|
+
* its own single-step base64 and percent encodings.
|
|
241
|
+
*/
|
|
242
|
+
export function secretForms(value) {
|
|
243
|
+
const forms = new Set([value]);
|
|
244
|
+
forms.add(Buffer.from(value, "utf8").toString("base64"));
|
|
245
|
+
forms.add(encodeURIComponent(value));
|
|
246
|
+
return [...forms].filter((form) => form !== "");
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Replace every resolved credential VALUE, and its enumerable encoded forms
|
|
250
|
+
* (see secretForms), with a named placeholder, everywhere in the text.
|
|
251
|
+
*/
|
|
252
|
+
export function redactSecrets(text, secrets) {
|
|
253
|
+
let redacted = text;
|
|
254
|
+
for (const secret of secrets) {
|
|
255
|
+
if (secret.value === "") {
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const placeholder = `<redacted:${secret.name}>`;
|
|
259
|
+
for (const form of secretForms(secret.value)) {
|
|
260
|
+
redacted = redacted.split(form).join(placeholder);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return redacted;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* THE ONE KERNEL-SIDE WRITE into the evidence directory. The mechanism the
|
|
267
|
+
* request-file write already obeyed, now applied to EVERY write the kernel
|
|
268
|
+
* makes there without exception: establish the path's TYPE before opening it.
|
|
269
|
+
*
|
|
270
|
+
* WHY (CR-P7H-1). A hostile adapter is handed a record path inside the
|
|
271
|
+
* evidence directory, so it can derive and pre-create any deterministic
|
|
272
|
+
* sibling path (the next attempt's stdout, stderr or attempt record). An
|
|
273
|
+
* open-for-write of a FIFO with no reader BLOCKS forever, and the per-attempt
|
|
274
|
+
* `spawnSync` timeout bounds only the CHILD, not these kernel-side writes that
|
|
275
|
+
* happen after it returns. So `refuseOpenForWrite` gates the open: an
|
|
276
|
+
* irregular or unexaminable entry is refused by name and observed type and the
|
|
277
|
+
* caller returns a bounded error, never a blocking `writeFileSync`. Returns
|
|
278
|
+
* undefined on a completed write, or the reason on refusal or a raised write
|
|
279
|
+
* error. This is the single writer; no kernel-side evidence write bypasses it.
|
|
280
|
+
*/
|
|
281
|
+
function guardedEvidenceWrite(path, body) {
|
|
282
|
+
const refusal = refuseOpenForWrite(path);
|
|
283
|
+
if (refusal !== undefined) {
|
|
284
|
+
return refusal;
|
|
285
|
+
}
|
|
286
|
+
const wrote = runStep(`writing ${path}`, () => writeFileSync(path, body));
|
|
287
|
+
return wrote.ok ? undefined : wrote.reason;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* THE KERNEL-OWNED LOOP. One bounded adapter observation per attempt, one
|
|
291
|
+
* record per attempt, foreground polling (C-3), verdict at the deadline.
|
|
292
|
+
* `pending` and `absent` loop; everything else is terminal, including every
|
|
293
|
+
* fail-closed violation (design decision D-p7-5: a broken adapter cannot
|
|
294
|
+
* reach a verdict, and M2-C-3 makes that `error` now, not red later).
|
|
295
|
+
*/
|
|
296
|
+
export async function runVerification(options) {
|
|
297
|
+
const attempts = [];
|
|
298
|
+
const evidence = [];
|
|
299
|
+
const secrets = options.secrets ?? [];
|
|
300
|
+
const startMs = Date.now();
|
|
301
|
+
const deadlineAtMs = startMs + options.clock.deadlineMs;
|
|
302
|
+
const deadlineIso = new Date(deadlineAtMs).toISOString();
|
|
303
|
+
let lastLoopOutcome;
|
|
304
|
+
let lastObservedDetail = "pending";
|
|
305
|
+
let attempt = 0;
|
|
306
|
+
const finish = (verdict) => ({
|
|
307
|
+
verdict,
|
|
308
|
+
attempts,
|
|
309
|
+
evidence,
|
|
310
|
+
});
|
|
311
|
+
for (;;) {
|
|
312
|
+
attempt += 1;
|
|
313
|
+
const at = new Date().toISOString();
|
|
314
|
+
const requestName = `${options.verification}-request-${String(attempt)}.json`;
|
|
315
|
+
const responseName = `${options.verification}-response-${String(attempt)}.json`;
|
|
316
|
+
const attemptName = `${options.verification}-attempt-${String(attempt)}.json`;
|
|
317
|
+
const requestPath = join(options.evidenceDir, requestName);
|
|
318
|
+
const responsePath = join(options.evidenceDir, responseName);
|
|
319
|
+
const request = {
|
|
320
|
+
contractVersion: ACCEPTED_CONTRACT_VERSIONS[0],
|
|
321
|
+
verification: options.verification,
|
|
322
|
+
subject: options.subject,
|
|
323
|
+
config: options.config,
|
|
324
|
+
attempt: { number: attempt, deadline: deadlineIso },
|
|
325
|
+
recordPath: responsePath,
|
|
326
|
+
};
|
|
327
|
+
// M2-C-6 both ways: the request path is probed before writing, and the
|
|
328
|
+
// stale-response clear removes whatever a previous attempt (or a hostile
|
|
329
|
+
// adapter) left at the response path, FIFO included, so the read below
|
|
330
|
+
// never opens an entry this attempt's adapter did not just write.
|
|
331
|
+
const requestRefusal = guardedEvidenceWrite(requestPath, `${JSON.stringify(request, null, 2)}\n`);
|
|
332
|
+
if (requestRefusal !== undefined) {
|
|
333
|
+
return finish({ kind: "error", reason: requestRefusal });
|
|
334
|
+
}
|
|
335
|
+
evidence.push(requestName);
|
|
336
|
+
const cleared = runStep(`clearing ${responsePath}`, () => rmSync(responsePath, { force: true }));
|
|
337
|
+
if (!cleared.ok) {
|
|
338
|
+
return finish({ kind: "error", reason: cleared.reason });
|
|
339
|
+
}
|
|
340
|
+
// The per-attempt bound. The kill signal is 9 (SIGKILL) BY NUMBER, and
|
|
341
|
+
// the number is load-bearing twice over. MEASURED 2026-08-06 (probe in
|
|
342
|
+
// the phase work history): with the default signal, a child that traps
|
|
343
|
+
// SIGTERM makes spawnSync never return, so the timeout would be
|
|
344
|
+
// advisory exactly where it must be enforceable ("an adapter that
|
|
345
|
+
// hangs cannot hang the kernel", plan M2-P7 step 3); with 9 the call
|
|
346
|
+
// returns at the timeout with the child dead. And it is numeric
|
|
347
|
+
// because the delivered M2-P1 structural witness (test/gates.test.ts,
|
|
348
|
+
// "the gate runner uses no pid, process liveness, signals or proc")
|
|
349
|
+
// forbids signal NAMES in any src/gates code, having been written
|
|
350
|
+
// before this phase's plan-mandated bound existed; the C-2 exemption
|
|
351
|
+
// in this module's header is the documented carve-out, and the seam is
|
|
352
|
+
// reported to the orchestrator in the phase work history rather than
|
|
353
|
+
// resolved by editing another phase's test.
|
|
354
|
+
const child = spawnSync(options.adapter[0], [...options.adapter.slice(1), requestPath], {
|
|
355
|
+
encoding: "utf8",
|
|
356
|
+
timeout: options.clock.attemptTimeoutMs,
|
|
357
|
+
killSignal: 9,
|
|
358
|
+
});
|
|
359
|
+
const terminatedByTimeout = child.signal !== null ||
|
|
360
|
+
child.error?.code === "ETIMEDOUT";
|
|
361
|
+
const stdoutName = `${options.verification}-attempt-${String(attempt)}-stdout.txt`;
|
|
362
|
+
const stderrName = `${options.verification}-attempt-${String(attempt)}-stderr.txt`;
|
|
363
|
+
const stdoutRefusal = guardedEvidenceWrite(join(options.evidenceDir, stdoutName), redactSecrets(child.stdout ?? "", secrets));
|
|
364
|
+
if (stdoutRefusal !== undefined) {
|
|
365
|
+
return finish({ kind: "error", reason: stdoutRefusal });
|
|
366
|
+
}
|
|
367
|
+
const stderrRefusal = guardedEvidenceWrite(join(options.evidenceDir, stderrName), redactSecrets(child.stderr ?? "", secrets));
|
|
368
|
+
if (stderrRefusal !== undefined) {
|
|
369
|
+
return finish({ kind: "error", reason: stderrRefusal });
|
|
370
|
+
}
|
|
371
|
+
evidence.push(stdoutName, stderrName);
|
|
372
|
+
const record = {
|
|
373
|
+
attempt,
|
|
374
|
+
at,
|
|
375
|
+
outcome: "error",
|
|
376
|
+
detail: "",
|
|
377
|
+
transport: {
|
|
378
|
+
exitCode: child.status,
|
|
379
|
+
signal: child.signal,
|
|
380
|
+
terminatedByTimeout,
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
const writeAttempt = () => {
|
|
384
|
+
attempts.push(record);
|
|
385
|
+
const refusal = guardedEvidenceWrite(join(options.evidenceDir, attemptName), `${JSON.stringify(record, null, 2)}\n`);
|
|
386
|
+
if (refusal !== undefined) {
|
|
387
|
+
return refusal;
|
|
388
|
+
}
|
|
389
|
+
evidence.push(attemptName);
|
|
390
|
+
return undefined;
|
|
391
|
+
};
|
|
392
|
+
// Write the attempt record, then return the given verdict, EXCEPT when the
|
|
393
|
+
// attempt-record path is itself a planted FIFO or other non-regular entry:
|
|
394
|
+
// the guarded write refuses it (no block), and the bounded return names
|
|
395
|
+
// that hazard rather than the terminal verdict it displaced.
|
|
396
|
+
const recordAndReturn = (verdict) => {
|
|
397
|
+
const refusal = writeAttempt();
|
|
398
|
+
if (refusal !== undefined) {
|
|
399
|
+
return finish({ kind: "error", reason: refusal });
|
|
400
|
+
}
|
|
401
|
+
return finish(verdict);
|
|
402
|
+
};
|
|
403
|
+
if (terminatedByTimeout) {
|
|
404
|
+
record.detail =
|
|
405
|
+
`adapter overran the per-attempt timeout of ` +
|
|
406
|
+
`${String(options.clock.attemptTimeoutMs)} ms and was terminated; ` +
|
|
407
|
+
`the attempt is error and the kernel returns`;
|
|
408
|
+
return recordAndReturn({ kind: "error", reason: record.detail });
|
|
409
|
+
}
|
|
410
|
+
if (child.error !== undefined) {
|
|
411
|
+
record.detail = `adapter could not be run: ${singleLine(String(child.error))}`;
|
|
412
|
+
return recordAndReturn({ kind: "error", reason: record.detail });
|
|
413
|
+
}
|
|
414
|
+
const read = readRegularFileIfPresent(responsePath);
|
|
415
|
+
if (read.kind === "absent") {
|
|
416
|
+
record.detail =
|
|
417
|
+
`fail-closed rule 1: adapter exited ${String(child.status)} without ` +
|
|
418
|
+
`writing a response record at ${responsePath}; exit 0 with no ` +
|
|
419
|
+
`response is error, not success`;
|
|
420
|
+
return recordAndReturn({ kind: "error", reason: record.detail });
|
|
421
|
+
}
|
|
422
|
+
if (read.kind === "refused") {
|
|
423
|
+
// M2-C-6: present and not a regular file. Named, never opened.
|
|
424
|
+
record.detail = read.reason;
|
|
425
|
+
return recordAndReturn({ kind: "error", reason: record.detail });
|
|
426
|
+
}
|
|
427
|
+
// Redaction before anything else touches the body: the response was
|
|
428
|
+
// written by another program, and if a credential value leaked into it,
|
|
429
|
+
// the leak must not survive under the evidence directory (criterion 11).
|
|
430
|
+
const redactedBody = redactSecrets(read.body, secrets);
|
|
431
|
+
if (redactedBody !== read.body) {
|
|
432
|
+
// The response path was just read as a regular file, but the rewrite is
|
|
433
|
+
// routed through the one guarded writer too, so no kernel-side evidence
|
|
434
|
+
// write is an exception to the type-before-open rule.
|
|
435
|
+
const rewriteRefusal = guardedEvidenceWrite(responsePath, redactedBody);
|
|
436
|
+
if (rewriteRefusal !== undefined) {
|
|
437
|
+
return finish({ kind: "error", reason: rewriteRefusal });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
evidence.push(responseName);
|
|
441
|
+
const validation = validateAdapterResponse(redactedBody, options.subject);
|
|
442
|
+
if (!validation.ok) {
|
|
443
|
+
record.outcome = "invalid";
|
|
444
|
+
record.detail = validation.reason;
|
|
445
|
+
return recordAndReturn({ kind: "error", reason: validation.reason });
|
|
446
|
+
}
|
|
447
|
+
const response = validation.response;
|
|
448
|
+
record.outcome = response.outcome;
|
|
449
|
+
record.detail =
|
|
450
|
+
response.observation?.detail ?? response.reason ?? response.outcome;
|
|
451
|
+
if (response.resolved !== undefined) {
|
|
452
|
+
record.resolved = response.resolved;
|
|
453
|
+
}
|
|
454
|
+
if (response.transport?.httpStatus !== undefined) {
|
|
455
|
+
record.transport.httpStatus = response.transport.httpStatus;
|
|
456
|
+
}
|
|
457
|
+
const older = releaseObjectOlderThanMerge(response, options.subject);
|
|
458
|
+
if (older !== undefined) {
|
|
459
|
+
record.releaseObjectOlderThanMerge = older;
|
|
460
|
+
}
|
|
461
|
+
const attemptRefusal = writeAttempt();
|
|
462
|
+
if (attemptRefusal !== undefined) {
|
|
463
|
+
return finish({ kind: "error", reason: attemptRefusal });
|
|
464
|
+
}
|
|
465
|
+
if (response.outcome === "satisfied") {
|
|
466
|
+
return finish({
|
|
467
|
+
kind: "satisfied",
|
|
468
|
+
units: response.units ?? 1,
|
|
469
|
+
detail: record.detail,
|
|
470
|
+
resolved: response.resolved,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
if (response.outcome === "failed") {
|
|
474
|
+
return finish({ kind: "failed", reason: response.reason });
|
|
475
|
+
}
|
|
476
|
+
if (response.outcome === "not-applicable") {
|
|
477
|
+
return finish({
|
|
478
|
+
kind: "not-applicable",
|
|
479
|
+
declared: false,
|
|
480
|
+
reason: response.reason,
|
|
481
|
+
preconditionId: response.precondition?.id ?? `${options.verification}-adapter-precondition`,
|
|
482
|
+
evidence: response.precondition?.evidence ?? [],
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
if (response.outcome === "error") {
|
|
486
|
+
return finish({ kind: "error", reason: response.reason });
|
|
487
|
+
}
|
|
488
|
+
// pending or absent: the loop states.
|
|
489
|
+
lastLoopOutcome = response.outcome;
|
|
490
|
+
lastObservedDetail = record.detail;
|
|
491
|
+
if (options.clock.maxAttempts !== undefined &&
|
|
492
|
+
attempt >= options.clock.maxAttempts) {
|
|
493
|
+
return finish(deadlineVerdict(lastLoopOutcome, lastObservedDetail, `attempt budget of ${String(options.clock.maxAttempts)} exhausted`));
|
|
494
|
+
}
|
|
495
|
+
const now = Date.now();
|
|
496
|
+
if (now >= deadlineAtMs) {
|
|
497
|
+
return finish(deadlineVerdict(lastLoopOutcome, lastObservedDetail));
|
|
498
|
+
}
|
|
499
|
+
await sleep(Math.min(options.clock.intervalMs, Math.max(0, deadlineAtMs - now)));
|
|
500
|
+
if (Date.now() >= deadlineAtMs) {
|
|
501
|
+
return finish(deadlineVerdict(lastLoopOutcome, lastObservedDetail));
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function deadlineVerdict(lastOutcome, lastObserved, cause) {
|
|
506
|
+
const base = lastOutcome === "absent"
|
|
507
|
+
? DEADLINE_REASON_ABSENT
|
|
508
|
+
: deadlineReasonPending(lastObserved);
|
|
509
|
+
return {
|
|
510
|
+
kind: "deadline",
|
|
511
|
+
lastOutcome,
|
|
512
|
+
reason: cause === undefined ? base : `${base} (${cause})`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* The older-than-the-merge observation. The adapter may record the release
|
|
517
|
+
* object's creation instant in resolved.createdAt (epoch milliseconds or an
|
|
518
|
+
* ISO-8601 string, platform's choice); if it parses and precedes mergedAt,
|
|
519
|
+
* the attempt record flags it. An observation, never an error (plan step 3;
|
|
520
|
+
* investigation section 8 item 8).
|
|
521
|
+
*/
|
|
522
|
+
function releaseObjectOlderThanMerge(response, subject) {
|
|
523
|
+
const created = response.resolved?.createdAt;
|
|
524
|
+
let createdMs;
|
|
525
|
+
if (typeof created === "number" && Number.isFinite(created)) {
|
|
526
|
+
createdMs = created;
|
|
527
|
+
}
|
|
528
|
+
else if (typeof created === "string") {
|
|
529
|
+
const parsed = Date.parse(created);
|
|
530
|
+
if (!Number.isNaN(parsed)) {
|
|
531
|
+
createdMs = parsed;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (createdMs === undefined) {
|
|
535
|
+
return undefined;
|
|
536
|
+
}
|
|
537
|
+
const mergedMs = Date.parse(subject.mergedAt);
|
|
538
|
+
if (Number.isNaN(mergedMs)) {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
return createdMs < mergedMs;
|
|
542
|
+
}
|
|
543
|
+
function git(args, cwd) {
|
|
544
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
545
|
+
if (result.error !== undefined) {
|
|
546
|
+
return { ok: false, status: null, stderr: singleLine(String(result.error)) };
|
|
547
|
+
}
|
|
548
|
+
if (result.status !== 0) {
|
|
549
|
+
return { ok: false, status: result.status, stderr: singleLine(result.stderr ?? "") };
|
|
550
|
+
}
|
|
551
|
+
return { ok: true, stdout: result.stdout ?? "" };
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Read the declaration from a COMMITTED ref, never from the working tree
|
|
555
|
+
* (design decision D-p7-2; the anti-widening rule of plan step 7, same as
|
|
556
|
+
* the scope auditor's: a phase must not be able to switch off, inside its
|
|
557
|
+
* own branch, the check that would have caught it). The record carries the
|
|
558
|
+
* declaration blob's sha256 so a reviewer can see which text authorized
|
|
559
|
+
* whatever happened.
|
|
560
|
+
*/
|
|
561
|
+
export function loadDeclaration(ref, cwd) {
|
|
562
|
+
const resolved = git(["rev-parse", `${ref}^{commit}`], cwd);
|
|
563
|
+
if (!resolved.ok) {
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
reason: `cannot resolve ref ${ref}: git exited ${String(resolved.status)}: ${resolved.stderr}`,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
const refSha = resolved.stdout.trim();
|
|
570
|
+
const shown = git(["show", `${refSha}:${DECLARATION_PATH}`], cwd);
|
|
571
|
+
if (!shown.ok) {
|
|
572
|
+
if (/does not exist|exists on disk, but not in/.test(shown.stderr)) {
|
|
573
|
+
return {
|
|
574
|
+
ok: false,
|
|
575
|
+
reason: `no release-verification declaration at ${refSha}:${DECLARATION_PATH}; ` +
|
|
576
|
+
`an absent declaration is error, not a quiet skip: silence is never permission ` +
|
|
577
|
+
`(plan M2-P7 step 7)`,
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
reason: `git show ${refSha}:${DECLARATION_PATH} exited ${String(shown.status)}: ${shown.stderr}`,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const body = shown.stdout;
|
|
586
|
+
let parsed;
|
|
587
|
+
try {
|
|
588
|
+
parsed = JSON.parse(body);
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
return {
|
|
592
|
+
ok: false,
|
|
593
|
+
reason: `${DECLARATION_PATH} at ${refSha} does not parse as JSON: ${error.message}`,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
const diagnostics = formatDiagnostics([
|
|
597
|
+
...validate(declarationSchema(), parsed),
|
|
598
|
+
...conditionalDeclarationDiagnostics(parsed),
|
|
599
|
+
]);
|
|
600
|
+
if (diagnostics.length > 0) {
|
|
601
|
+
return {
|
|
602
|
+
ok: false,
|
|
603
|
+
reason: `${DECLARATION_PATH} at ${refSha} is not a valid declaration: ${diagnostics.join("; ")}`,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
ok: true,
|
|
608
|
+
declaration: parsed,
|
|
609
|
+
sha256: createHash("sha256").update(body).digest("hex"),
|
|
610
|
+
ref,
|
|
611
|
+
refSha,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/** Mode-conditional required fields, outside the closed keyword set. */
|
|
615
|
+
function conditionalDeclarationDiagnostics(parsed) {
|
|
616
|
+
const found = [];
|
|
617
|
+
const verifications = parsed?.verifications;
|
|
618
|
+
if (typeof verifications !== "object" || verifications === null) {
|
|
619
|
+
return found;
|
|
620
|
+
}
|
|
621
|
+
for (const [name, value] of Object.entries(verifications)) {
|
|
622
|
+
if (typeof value !== "object" || value === null) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const entry = value;
|
|
626
|
+
const pointer = (field) => `#/verifications/${name}/${field}`;
|
|
627
|
+
if (entry["mode"] === "none") {
|
|
628
|
+
if (typeof entry["reason"] !== "string" || entry["reason"] === "") {
|
|
629
|
+
found.push({
|
|
630
|
+
pointer: pointer("reason"),
|
|
631
|
+
message: "required property reason is missing or empty for mode none; " +
|
|
632
|
+
"disabling verification costs a reason",
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
if (entry["mode"] === "adapter") {
|
|
637
|
+
for (const field of ["adapter", "config", "clock"]) {
|
|
638
|
+
if (entry[field] === undefined) {
|
|
639
|
+
found.push({
|
|
640
|
+
pointer: pointer(field),
|
|
641
|
+
message: `required property ${field} is missing for mode adapter`,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return found;
|
|
648
|
+
}
|
|
649
|
+
const UNIT_LABELS = {
|
|
650
|
+
deploy: "release verifications satisfied",
|
|
651
|
+
migrations: "migrations compared",
|
|
652
|
+
};
|
|
653
|
+
function usage(name) {
|
|
654
|
+
return (`usage: node src/gates/${name}.ts --result <file> --evidence <dir> ` +
|
|
655
|
+
`[--base <ref>] [--phase <id>]`);
|
|
656
|
+
}
|
|
657
|
+
function parseGateFlags(args) {
|
|
658
|
+
const flags = {};
|
|
659
|
+
const names = ["--result", "--evidence", "--base", "--phase", "--head"];
|
|
660
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
661
|
+
const flag = args[i];
|
|
662
|
+
const value = args[i + 1];
|
|
663
|
+
if (flag === undefined || !names.includes(flag) || value === undefined) {
|
|
664
|
+
return undefined;
|
|
665
|
+
}
|
|
666
|
+
if (flag === "--result") {
|
|
667
|
+
flags.result = value;
|
|
668
|
+
}
|
|
669
|
+
else if (flag === "--evidence") {
|
|
670
|
+
flags.evidence = value;
|
|
671
|
+
}
|
|
672
|
+
else if (flag === "--base") {
|
|
673
|
+
flags.base = value;
|
|
674
|
+
}
|
|
675
|
+
else if (flag === "--phase") {
|
|
676
|
+
flags.phase = value;
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
flags.head = value;
|
|
680
|
+
}
|
|
681
|
+
i += 1;
|
|
682
|
+
}
|
|
683
|
+
return flags;
|
|
684
|
+
}
|
|
685
|
+
function emit(resultPath, result) {
|
|
686
|
+
const refusal = refuseOpenForWrite(resultPath);
|
|
687
|
+
if (refusal !== undefined) {
|
|
688
|
+
process.stderr.write(`${result.gate}: ${refusal}\n`);
|
|
689
|
+
return exitCodeForStatus("error");
|
|
690
|
+
}
|
|
691
|
+
const written = runStep(`writing ${resultPath}`, () => writeFileSync(resultPath, renderGateResult(result)));
|
|
692
|
+
if (!written.ok) {
|
|
693
|
+
process.stderr.write(`${result.gate}: ${written.reason}\n`);
|
|
694
|
+
return exitCodeForStatus("error");
|
|
695
|
+
}
|
|
696
|
+
process.stdout.write(`${result.gate}: ${result.status} (${String(result.units)} ${result.unitLabel})\n`);
|
|
697
|
+
if (result.detail !== "") {
|
|
698
|
+
process.stdout.write(`${result.detail}\n`);
|
|
699
|
+
}
|
|
700
|
+
return exitCodeForStatus(result.status);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* THE GATE ENTRY, shared by the two thin entry points. The manifest keeps
|
|
704
|
+
* two static entries exactly as section 1.4 declares them; this function is
|
|
705
|
+
* what each names. Returns the process exit code.
|
|
706
|
+
*
|
|
707
|
+
* The governing ref is `--base` when supplied (the anti-widening read),
|
|
708
|
+
* else HEAD: always a COMMITTED state, so the subject's mergedSha and
|
|
709
|
+
* mergedAt are the resolved commit's, and an uncommitted edit can neither
|
|
710
|
+
* enable, disable nor reconfigure a verification. R-032's blocking half:
|
|
711
|
+
* the verdict record this gate writes is keyed to that sha in its detail
|
|
712
|
+
* line, and the consumption contract is that THE NEXT DISPATCH REQUIRES A
|
|
713
|
+
* GREEN VERDICT RECORD FOR THE MERGED SHA; the enforcement is wired at M4
|
|
714
|
+
* with the pilot (M2-D-11), and M2 adds no dispatch block to spawn.
|
|
715
|
+
*/
|
|
716
|
+
export async function runReleaseGate(name, args) {
|
|
717
|
+
const flags = parseGateFlags(args);
|
|
718
|
+
if (flags === undefined || flags.result === undefined || flags.evidence === undefined) {
|
|
719
|
+
process.stderr.write(`${usage(name)}\n`);
|
|
720
|
+
return 64;
|
|
721
|
+
}
|
|
722
|
+
const startedAt = new Date().toISOString();
|
|
723
|
+
const unitLabel = UNIT_LABELS[name];
|
|
724
|
+
const finish = (status, units, detail, precondition, evidence) => emit(flags.result, makeGateResult({
|
|
725
|
+
gate: name,
|
|
726
|
+
status,
|
|
727
|
+
units,
|
|
728
|
+
unitLabel,
|
|
729
|
+
startedAt,
|
|
730
|
+
endedAt: new Date().toISOString(),
|
|
731
|
+
detail,
|
|
732
|
+
precondition,
|
|
733
|
+
evidence,
|
|
734
|
+
}));
|
|
735
|
+
const cwd = process.cwd();
|
|
736
|
+
const ref = flags.base ?? "HEAD";
|
|
737
|
+
const loaded = loadDeclaration(ref, cwd);
|
|
738
|
+
if (!loaded.ok) {
|
|
739
|
+
return finish("error", 0, loaded.reason);
|
|
740
|
+
}
|
|
741
|
+
const provenance = `declaration ${DECLARATION_PATH} read from ${loaded.ref} ` +
|
|
742
|
+
`(${loaded.refSha}), blob sha256 ${loaded.sha256}`;
|
|
743
|
+
const declared = loaded.declaration.verifications[name];
|
|
744
|
+
if (declared === undefined) {
|
|
745
|
+
return finish("error", 0, `${DECLARATION_PATH} at ${loaded.refSha} does not configure verification ` +
|
|
746
|
+
`${name}; an absent configuration field is error, not none: silence is ` +
|
|
747
|
+
`never permission (plan M2-P7 step 7); ${provenance}`);
|
|
748
|
+
}
|
|
749
|
+
if (declared.mode === "none") {
|
|
750
|
+
return finish("not-applicable", 0, `not-applicable by declaration (declared: true): ${declared.reason}; ${provenance}`, {
|
|
751
|
+
id: `${name}-release-verification-declared-none`,
|
|
752
|
+
met: false,
|
|
753
|
+
reason: `declared none: ${declared.reason}`,
|
|
754
|
+
evidence: [
|
|
755
|
+
`declared: true`,
|
|
756
|
+
`declaration: ${DECLARATION_PATH} at ${loaded.refSha}`,
|
|
757
|
+
`blob sha256: ${loaded.sha256}`,
|
|
758
|
+
],
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
// mode adapter. Credentials are NAMES; an unresolvable name is error,
|
|
762
|
+
// never a silent unauthenticated request (plan step 8). The values are
|
|
763
|
+
// used for redaction only and are never written anywhere.
|
|
764
|
+
const secrets = [];
|
|
765
|
+
for (const credential of declared.credentials ?? []) {
|
|
766
|
+
const value = process.env[credential];
|
|
767
|
+
if (value === undefined || value === "") {
|
|
768
|
+
return finish("error", 0, `credential variable ${credential} is declared and not resolvable; a ` +
|
|
769
|
+
`named credential that cannot be resolved is error, never a silent ` +
|
|
770
|
+
`unauthenticated request (plan M2-P7 step 8); ${provenance}`);
|
|
771
|
+
}
|
|
772
|
+
secrets.push({ name: credential, value });
|
|
773
|
+
}
|
|
774
|
+
const mergedAtResult = git(["show", "-s", "--format=%cI", loaded.refSha], cwd);
|
|
775
|
+
if (!mergedAtResult.ok) {
|
|
776
|
+
return finish("error", 0, `cannot read the commit instant of ${loaded.refSha}: ${mergedAtResult.stderr}`);
|
|
777
|
+
}
|
|
778
|
+
const subject = {
|
|
779
|
+
repository: loaded.declaration.repository,
|
|
780
|
+
integrationRef: loaded.declaration.integrationRef,
|
|
781
|
+
mergedSha: loaded.refSha,
|
|
782
|
+
mergedAt: mergedAtResult.stdout.trim(),
|
|
783
|
+
phaseId: flags.phase ?? "unspecified",
|
|
784
|
+
};
|
|
785
|
+
const run = await runVerification({
|
|
786
|
+
verification: name,
|
|
787
|
+
subject,
|
|
788
|
+
adapter: declared.adapter,
|
|
789
|
+
config: declared.config,
|
|
790
|
+
clock: declared.clock,
|
|
791
|
+
evidenceDir: flags.evidence,
|
|
792
|
+
secrets,
|
|
793
|
+
});
|
|
794
|
+
const attemptsNote = `${String(run.attempts.length)} attempt(s)`;
|
|
795
|
+
const verdict = run.verdict;
|
|
796
|
+
if (verdict.kind === "satisfied") {
|
|
797
|
+
return finish("green", verdict.units, `verification ${name} satisfied for subject ${subject.mergedSha}: ` +
|
|
798
|
+
`${verdict.detail}; resolved ${JSON.stringify(verdict.resolved)}; ` +
|
|
799
|
+
`${attemptsNote}; ${provenance}`, undefined, run.evidence);
|
|
800
|
+
}
|
|
801
|
+
if (verdict.kind === "failed") {
|
|
802
|
+
return finish("red", 0, `verification ${name} failed for subject ${subject.mergedSha}: ` +
|
|
803
|
+
`${verdict.reason}; ${attemptsNote}; ${provenance}`, undefined, run.evidence);
|
|
804
|
+
}
|
|
805
|
+
if (verdict.kind === "deadline") {
|
|
806
|
+
return finish("red", 0, `verification ${name} for subject ${subject.mergedSha}: ${verdict.reason}; ` +
|
|
807
|
+
`${attemptsNote}; ${provenance}`, undefined, run.evidence);
|
|
808
|
+
}
|
|
809
|
+
if (verdict.kind === "not-applicable") {
|
|
810
|
+
return finish("not-applicable", 0, `not-applicable by adapter-evaluated precondition (declared: false): ` +
|
|
811
|
+
`${verdict.reason}; ${attemptsNote}; ${provenance}`, {
|
|
812
|
+
id: verdict.preconditionId,
|
|
813
|
+
met: false,
|
|
814
|
+
reason: verdict.reason,
|
|
815
|
+
evidence: [`declared: false`, ...verdict.evidence],
|
|
816
|
+
}, run.evidence);
|
|
817
|
+
}
|
|
818
|
+
return finish("error", 0, `verification ${name} for subject ${subject.mergedSha}: ${verdict.reason}; ` +
|
|
819
|
+
`${attemptsNote}; ${provenance}`, undefined, run.evidence);
|
|
820
|
+
}
|