@emilia-protocol/gate 0.18.1 → 0.19.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.
@@ -6,6 +6,7 @@
6
6
  * immutable AdmissionSnapshot may cross the one-time invocation boundary.
7
7
  */
8
8
 
9
+ import crypto from 'node:crypto';
9
10
  import { createAdmissionSnapshot } from './admission-store.js';
10
11
  import type {
11
12
  AdmissionSnapshot,
@@ -30,6 +31,14 @@ const REQUIRED_QUALIFICATION_CHECKS = Object.freeze([
30
31
  'assignment_in_scope',
31
32
  'protected_request_bound',
32
33
  ] as const);
34
+ const GATE_QUALIFICATION_V2_PROGRAM_DIGEST = `sha256:${crypto
35
+ .createHash('sha256')
36
+ .update(JSON.stringify({
37
+ version: GATE_QUALIFICATION_V2_VERSION,
38
+ checks: REQUIRED_QUALIFICATION_CHECKS,
39
+ requirements: ['qualification', 'aeb', 'aec', 'local_policy'],
40
+ }))
41
+ .digest('hex')}` as const;
33
42
  const DEFAULT_ADAPTER_TIMEOUT_MS = 30_000;
34
43
  const MAX_ADAPTER_TIMEOUT_MS = 300_000;
35
44
 
@@ -56,6 +65,8 @@ export interface BoundRequirementDecisionV2 {
56
65
  export interface BoundLocalPolicyDecisionV2 {
57
66
  readonly decision: 'allow' | 'deny';
58
67
  readonly policyId: string;
68
+ /** Digest of the relying-party authorization policy that made this decision. */
69
+ readonly policyDigest?: AdmissionDigestV2;
59
70
  readonly caid: string;
60
71
  readonly actionDigest: AdmissionDigestV2;
61
72
  readonly evidenceDigest: AdmissionDigestV2;
@@ -85,6 +96,7 @@ export interface GateQualificationDecisionV2 {
85
96
  readonly caid: string;
86
97
  readonly actionDigest: string;
87
98
  readonly snapshotDigest: string;
99
+ readonly programDigest: AdmissionDigestV2;
88
100
  readonly effectKey: string;
89
101
  readonly requirements: Readonly<{
90
102
  qualificationEvidenceDigest: string;
@@ -222,6 +234,7 @@ export type GateQualificationExecutionResultV2 =
222
234
  | {
223
235
  readonly status: 'refused';
224
236
  readonly reason: string;
237
+ readonly programDigest: AdmissionDigestV2;
225
238
  readonly decision?: Readonly<GateQualificationDecisionV2>;
226
239
  }
227
240
  | {
@@ -505,6 +518,11 @@ function validateLocalPolicy(
505
518
  if (!validString(leg.policyId)) {
506
519
  addReason(reasons, 'localPolicy_policy_missing');
507
520
  }
521
+ if (!validDigest(leg.policyDigest)) {
522
+ addReason(reasons, 'localPolicy_program_digest_missing');
523
+ } else if (leg.policyDigest !== snapshot.body.authorization_policy_digest) {
524
+ addReason(reasons, 'localPolicy_program_digest_mismatch');
525
+ }
508
526
  if (leg.caid !== snapshot.body.caid
509
527
  || leg.actionDigest !== snapshot.body.action_digest) {
510
528
  addReason(reasons, 'localPolicy_binding_mismatch');
@@ -580,6 +598,7 @@ function emptyDecision(reasons: readonly string[]): GateQualificationDecisionV2
580
598
  caid: '',
581
599
  actionDigest: '',
582
600
  snapshotDigest: '',
601
+ programDigest: GATE_QUALIFICATION_V2_PROGRAM_DIGEST,
583
602
  effectKey: '',
584
603
  requirements: deepFreeze({
585
604
  qualificationEvidenceDigest: '',
@@ -613,6 +632,7 @@ function composeCanonical(
613
632
  caid: snapshot.body.caid,
614
633
  actionDigest: snapshot.body.action_digest,
615
634
  snapshotDigest: snapshot.snapshot_digest,
635
+ programDigest: snapshot.body.authorization_policy_digest,
616
636
  effectKey: actuationKey(snapshot),
617
637
  requirements: deepFreeze({
618
638
  qualificationEvidenceDigest:
@@ -785,6 +805,20 @@ function reconciliationRequired(
785
805
  return deepFreeze({ status: 'reconciliation_required', reason, admissionId });
786
806
  }
787
807
 
808
+ function refused(
809
+ reason: string,
810
+ decision?: Readonly<GateQualificationDecisionV2>,
811
+ programDigest: AdmissionDigestV2 = decision?.programDigest
812
+ ?? GATE_QUALIFICATION_V2_PROGRAM_DIGEST,
813
+ ): Readonly<GateQualificationExecutionResultV2> {
814
+ return deepFreeze({
815
+ status: 'refused',
816
+ reason,
817
+ programDigest,
818
+ ...(decision ? { decision } : {}),
819
+ });
820
+ }
821
+
788
822
  export class GateQualificationV2 {
789
823
  readonly mode: QualificationModeV2;
790
824
  readonly #store?: AdmissionStore;
@@ -1022,7 +1056,7 @@ export class GateQualificationV2 {
1022
1056
  decision: Readonly<GateQualificationDecisionV2>,
1023
1057
  ): Promise<GateQualificationExecutionResultV2> {
1024
1058
  if (storeReason !== 'admission_exists') {
1025
- return deepFreeze({ status: 'refused', reason: storeReason, decision });
1059
+ return refused(storeReason, decision);
1026
1060
  }
1027
1061
  try {
1028
1062
  const record = await this.#store!.read(reference(snapshot));
@@ -1037,7 +1071,7 @@ export class GateQualificationV2 {
1037
1071
  'admission_read_ambiguous',
1038
1072
  );
1039
1073
  }
1040
- return deepFreeze({ status: 'refused', reason: storeReason, decision });
1074
+ return refused(storeReason, decision);
1041
1075
  }
1042
1076
 
1043
1077
  async #verifyProviderEvidence(
@@ -1228,11 +1262,10 @@ export class GateQualificationV2 {
1228
1262
  : emptyDecision(['admission_snapshot_invalid']);
1229
1263
  if (this.mode === 'shadow') return this.#shadow(input, decision);
1230
1264
  if (!checked.ok || !decision.allow) {
1231
- return deepFreeze({
1232
- status: 'refused',
1233
- reason: decision.reasons[0] ?? 'qualification_refused',
1265
+ return refused(
1266
+ decision.reasons[0] ?? 'qualification_refused',
1234
1267
  decision,
1235
- });
1268
+ );
1236
1269
  }
1237
1270
 
1238
1271
  const snapshot = checked.snapshot;
@@ -1240,11 +1273,7 @@ export class GateQualificationV2 {
1240
1273
  try {
1241
1274
  reserved = await this.#store!.reserve(snapshot);
1242
1275
  } catch {
1243
- return deepFreeze({
1244
- status: 'refused',
1245
- reason: 'admission_reserve_failed',
1246
- decision,
1247
- });
1276
+ return refused('admission_reserve_failed', decision);
1248
1277
  }
1249
1278
  if (!reserved.ok) {
1250
1279
  return this.#existingAdmissionResult(snapshot, reserved.reason, decision);
@@ -1263,11 +1292,7 @@ export class GateQualificationV2 {
1263
1292
  } catch {
1264
1293
  // No provider entry occurred; the reservation remains closed.
1265
1294
  }
1266
- return deepFreeze({
1267
- status: 'refused',
1268
- reason: 'reserve_snapshot_mismatch',
1269
- decision,
1270
- });
1295
+ return refused('reserve_snapshot_mismatch', decision);
1271
1296
  }
1272
1297
 
1273
1298
  // Reread the candidate, qualification status, AEB, AEC, local policy and
@@ -1290,11 +1315,7 @@ export class GateQualificationV2 {
1290
1315
  } catch {
1291
1316
  // No execution right was consumed and no provider entry occurred.
1292
1317
  }
1293
- return deepFreeze({
1294
- status: 'refused',
1295
- reason: 'invocation_remeasurement_failed',
1296
- decision,
1297
- });
1318
+ return refused('invocation_remeasurement_failed', decision);
1298
1319
  }
1299
1320
  const refreshedDecision = composeCanonical(
1300
1321
  reserved.snapshot,
@@ -1310,12 +1331,10 @@ export class GateQualificationV2 {
1310
1331
  } catch {
1311
1332
  // No execution right was consumed and no provider entry occurred.
1312
1333
  }
1313
- return deepFreeze({
1314
- status: 'refused',
1315
- reason: refreshedDecision.reasons[0]
1316
- ?? 'invocation_remeasurement_refused',
1317
- decision: refreshedDecision,
1318
- });
1334
+ return refused(
1335
+ refreshedDecision.reasons[0] ?? 'invocation_remeasurement_refused',
1336
+ refreshedDecision,
1337
+ );
1319
1338
  }
1320
1339
 
1321
1340
  let begun: Awaited<ReturnType<AdmissionStore['beginInvocation']>>;
@@ -1333,11 +1352,7 @@ export class GateQualificationV2 {
1333
1352
  || begun.reason === 'revision_conflict') {
1334
1353
  return this.#beginAmbiguity(reserved.snapshot, reserved.owner_token);
1335
1354
  }
1336
- return deepFreeze({
1337
- status: 'refused',
1338
- reason: begun.reason,
1339
- decision,
1340
- });
1355
+ return refused(begun.reason, decision);
1341
1356
  }
1342
1357
 
1343
1358
  const begunSnapshot = canonicalSnapshot(begun.snapshot);
@@ -1404,14 +1419,11 @@ export class GateQualificationV2 {
1404
1419
  >>,
1405
1420
  ): Promise<GateQualificationExecutionResultV2> {
1406
1421
  if (this.mode !== 'enforce') {
1407
- return deepFreeze({
1408
- status: 'refused',
1409
- reason: 'reconciliation_unavailable_in_shadow',
1410
- });
1422
+ return refused('reconciliation_unavailable_in_shadow');
1411
1423
  }
1412
1424
  if (!input || !validString(input.tenant_id)
1413
1425
  || !validString(input.admission_id)) {
1414
- return deepFreeze({ status: 'refused', reason: 'admission_reference_invalid' });
1426
+ return refused('admission_reference_invalid');
1415
1427
  }
1416
1428
 
1417
1429
  let record: StoreRecordV2 | null;
@@ -1424,13 +1436,10 @@ export class GateQualificationV2 {
1424
1436
  );
1425
1437
  }
1426
1438
  if (!record) {
1427
- return deepFreeze({ status: 'refused', reason: 'admission_not_found' });
1439
+ return refused('admission_not_found');
1428
1440
  }
1429
1441
  if (record.execution_right !== 'CONSUMED') {
1430
- return deepFreeze({
1431
- status: 'refused',
1432
- reason: 'reconciliation_not_required',
1433
- });
1442
+ return refused('reconciliation_not_required');
1434
1443
  }
1435
1444
 
1436
1445
  let snapshot: Readonly<AdmissionSnapshot> | null;
@@ -1499,10 +1508,11 @@ export class GateQualificationV2 {
1499
1508
  const effectUnresolved = !record.effect_relation
1500
1509
  || record.effect_relation.value === 'INDETERMINATE';
1501
1510
  if (!providerUnresolved && !effectUnresolved) {
1502
- return deepFreeze({
1503
- status: 'refused',
1504
- reason: 'reconciliation_not_required',
1505
- });
1511
+ return refused(
1512
+ 'reconciliation_not_required',
1513
+ undefined,
1514
+ snapshot.body.authorization_policy_digest,
1515
+ );
1506
1516
  }
1507
1517
 
1508
1518
  const base = protectedInvocation(snapshot, authority.invocationToken);
package/src/index.ts CHANGED
@@ -236,6 +236,8 @@ export * from './autonomy-control-plane-profile.js';
236
236
  export * from './admission-store.js';
237
237
  export * from './admission-store-postgres.js';
238
238
  export * from './gate-qualification-v2.js';
239
+ export * from './referee.js';
240
+ export * from './referee-runner.js';
239
241
  export {
240
242
  ZK_RANGE_RECEIPT_VERSION,
241
243
  ZK_RANGE_SCHEME,
@@ -0,0 +1,365 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Bounded subprocess transport for EMILIA protocol Referee runners.
4
+ *
5
+ * The caller pins an absolute executable, its SHA-256 byte digest, and the
6
+ * complete argument vector. The child receives only a closed JSON request on
7
+ * stdin and must return one closed JSON output on stdout. This is a bounded,
8
+ * no-shell subprocess self-test harness; it is not an OS sandbox and does not
9
+ * claim to confine a hostile executable's filesystem or network access.
10
+ */
11
+
12
+ import { spawn } from 'node:child_process';
13
+ import crypto from 'node:crypto';
14
+ import { createReadStream } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { TextDecoder } from 'node:util';
17
+
18
+ import {
19
+ REFEREE_EVALUATION_VERSION,
20
+ RefereeValidationError,
21
+ createIndeterminateRefereeResult,
22
+ evaluateReferee,
23
+ parseRefereeRunnerOutput,
24
+ parseRefereeRunnerPin,
25
+ parseRefereeRunnerRequest,
26
+ type RefereeResultV1,
27
+ type RefereeRunnerOutputV1,
28
+ type RefereeRunnerPinV1,
29
+ type RefereeRunnerRequestV1,
30
+ } from './referee.js';
31
+ import { strictJsonGate } from './strict-json.js';
32
+
33
+ export const REFEREE_RUNNER_MAX_INPUT_BYTES = 8 * 1024 * 1024;
34
+ export const REFEREE_RUNNER_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
35
+ export const REFEREE_RUNNER_MAX_TIMEOUT_MS = 300_000;
36
+
37
+ const FORCE_KILL_AFTER_MS = 100;
38
+ const INVOCATION_KEYS = Object.freeze([
39
+ 'runner_pin', 'request', 'timeout_ms',
40
+ ] as const);
41
+ const OPTION_KEYS = new Set(['signal']);
42
+
43
+ export type RefereeRunnerFailureCode =
44
+ | 'ABORTED'
45
+ | 'INPUT_TOO_LARGE'
46
+ | 'OUTPUT_TOO_LARGE'
47
+ | 'TIMEOUT'
48
+ | 'EXECUTABLE_DIGEST_MISMATCH'
49
+ | 'SPAWN_FAILED'
50
+ | 'NONZERO_EXIT'
51
+ | 'MALFORMED_OUTPUT'
52
+ | 'INVALID_OUTPUT_SCHEMA';
53
+
54
+ export interface RefereeRunnerInvocationV1 {
55
+ readonly runner_pin: RefereeRunnerPinV1;
56
+ readonly request: RefereeRunnerRequestV1;
57
+ readonly timeout_ms: number;
58
+ }
59
+
60
+ export interface RefereeRunnerOptions {
61
+ readonly signal?: AbortSignal;
62
+ }
63
+
64
+ export type RefereeRunnerExecutionResult =
65
+ | Readonly<{
66
+ ok: true;
67
+ output: Readonly<RefereeRunnerOutputV1>;
68
+ }>
69
+ | Readonly<{
70
+ ok: false;
71
+ code: RefereeRunnerFailureCode;
72
+ }>;
73
+
74
+ function fail(code: string): never {
75
+ throw new RefereeValidationError(code);
76
+ }
77
+
78
+ function plain(value: unknown): value is Record<string, unknown> {
79
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
80
+ return false;
81
+ }
82
+ const prototype = Object.getPrototypeOf(value);
83
+ return prototype === Object.prototype || prototype === null;
84
+ }
85
+
86
+ function exactInvocation(value: unknown): Record<(typeof INVOCATION_KEYS)[number], unknown> {
87
+ if (!plain(value)) fail('invalid_schema');
88
+ const ownKeys = Reflect.ownKeys(value);
89
+ if (ownKeys.some((key) => typeof key !== 'string')) fail('unknown_key');
90
+ const expected = new Set<string>(INVOCATION_KEYS);
91
+ for (const key of ownKeys as string[]) {
92
+ if (!expected.has(key)) fail('unknown_key');
93
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
94
+ if (!descriptor?.enumerable || !('value' in descriptor)) fail('invalid_schema');
95
+ }
96
+ for (const key of INVOCATION_KEYS) {
97
+ if (!Object.prototype.hasOwnProperty.call(value, key)) fail('missing_key');
98
+ }
99
+ return value as Record<(typeof INVOCATION_KEYS)[number], unknown>;
100
+ }
101
+
102
+ function parseOptions(value: unknown): Readonly<RefereeRunnerOptions> {
103
+ if (value === undefined) return Object.freeze({});
104
+ if (!plain(value)) fail('invalid_options');
105
+ const keys = Reflect.ownKeys(value);
106
+ if (keys.some((key) => typeof key !== 'string' || !OPTION_KEYS.has(key))) {
107
+ fail('unknown_key');
108
+ }
109
+ let signal: unknown;
110
+ for (const key of keys) {
111
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
112
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
113
+ fail('invalid_options');
114
+ }
115
+ if (key === 'signal') signal = descriptor.value;
116
+ }
117
+ if (signal !== undefined && !(signal instanceof AbortSignal)) {
118
+ fail('invalid_signal');
119
+ }
120
+ return Object.freeze(signal === undefined ? {} : { signal });
121
+ }
122
+
123
+ export function parseRefereeRunnerInvocation(
124
+ value: unknown,
125
+ ): Readonly<RefereeRunnerInvocationV1> {
126
+ const object = exactInvocation(value);
127
+ if (!Number.isSafeInteger(object.timeout_ms)
128
+ || (object.timeout_ms as number) < 1
129
+ || (object.timeout_ms as number) > REFEREE_RUNNER_MAX_TIMEOUT_MS) {
130
+ fail('invalid_timeout');
131
+ }
132
+ return Object.freeze({
133
+ runner_pin: parseRefereeRunnerPin(object.runner_pin),
134
+ request: parseRefereeRunnerRequest(object.request),
135
+ timeout_ms: object.timeout_ms as number,
136
+ });
137
+ }
138
+
139
+ function frozenFailure(code: RefereeRunnerFailureCode): RefereeRunnerExecutionResult {
140
+ return Object.freeze({ ok: false as const, code });
141
+ }
142
+
143
+ function frozenSuccess(
144
+ output: Readonly<RefereeRunnerOutputV1>,
145
+ ): RefereeRunnerExecutionResult {
146
+ return Object.freeze({ ok: true as const, output });
147
+ }
148
+
149
+ function decodeRunnerOutput(bytes: Buffer): RefereeRunnerExecutionResult {
150
+ let raw: string;
151
+ try {
152
+ raw = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
153
+ } catch {
154
+ return frozenFailure('MALFORMED_OUTPUT');
155
+ }
156
+ const gate = strictJsonGate(raw);
157
+ if (!gate.ok) return frozenFailure('MALFORMED_OUTPUT');
158
+
159
+ let parsed: unknown;
160
+ try {
161
+ parsed = JSON.parse(raw);
162
+ } catch {
163
+ return frozenFailure('MALFORMED_OUTPUT');
164
+ }
165
+ try {
166
+ return frozenSuccess(parseRefereeRunnerOutput(parsed));
167
+ } catch (error) {
168
+ if (error instanceof RefereeValidationError) {
169
+ return frozenFailure('INVALID_OUTPUT_SCHEMA');
170
+ }
171
+ return frozenFailure('INVALID_OUTPUT_SCHEMA');
172
+ }
173
+ }
174
+
175
+ function serializeRequest(
176
+ request: Readonly<RefereeRunnerRequestV1>,
177
+ ): string {
178
+ // parseRefereeRunnerRequest has already established an exact JSON tree, so
179
+ // JSON.stringify cannot silently drop or coerce any member here.
180
+ return JSON.stringify(request);
181
+ }
182
+
183
+ async function digestExecutable(
184
+ executable: string,
185
+ signal?: AbortSignal,
186
+ ): Promise<string | null> {
187
+ return new Promise((resolve) => {
188
+ const hash = crypto.createHash('sha256');
189
+ const stream = createReadStream(executable, { signal });
190
+ let complete = false;
191
+ const finish = (value: string | null) => {
192
+ if (complete) return;
193
+ complete = true;
194
+ resolve(value);
195
+ };
196
+ stream.on('data', (chunk) => hash.update(chunk));
197
+ stream.once('error', () => finish(null));
198
+ stream.once('end', () => finish(`sha256:${hash.digest('hex')}`));
199
+ });
200
+ }
201
+
202
+ async function executeParsed(
203
+ invocation: Readonly<RefereeRunnerInvocationV1>,
204
+ options: Readonly<RefereeRunnerOptions>,
205
+ ): Promise<RefereeRunnerExecutionResult> {
206
+ const serialized = serializeRequest(invocation.request);
207
+ if (Buffer.byteLength(serialized, 'utf8') > REFEREE_RUNNER_MAX_INPUT_BYTES) {
208
+ return frozenFailure('INPUT_TOO_LARGE');
209
+ }
210
+ if (options.signal?.aborted) return frozenFailure('ABORTED');
211
+
212
+ // This is intentionally the last asynchronous operation before spawn. It
213
+ // establishes byte equality with the relying party's pin; it does not turn
214
+ // this portable harness into a complete OS-level execution sandbox.
215
+ const observedExecutableDigest = await digestExecutable(
216
+ invocation.runner_pin.executable,
217
+ options.signal,
218
+ );
219
+ if (options.signal?.aborted) return frozenFailure('ABORTED');
220
+ if (observedExecutableDigest === null) return frozenFailure('SPAWN_FAILED');
221
+ if (observedExecutableDigest !== invocation.runner_pin.executable_sha256) {
222
+ return frozenFailure('EXECUTABLE_DIGEST_MISMATCH');
223
+ }
224
+
225
+ return new Promise<RefereeRunnerExecutionResult>((resolve) => {
226
+ const detached = process.platform !== 'win32';
227
+ let child;
228
+ try {
229
+ child = spawn(invocation.runner_pin.executable, [...invocation.runner_pin.args], {
230
+ cwd: path.parse(invocation.runner_pin.executable).root,
231
+ detached,
232
+ env: {},
233
+ shell: false,
234
+ stdio: ['pipe', 'pipe', 'pipe'],
235
+ windowsHide: true,
236
+ });
237
+ } catch {
238
+ resolve(frozenFailure('SPAWN_FAILED'));
239
+ return;
240
+ }
241
+
242
+ let settled = false;
243
+ let selectedFailure: RefereeRunnerFailureCode | null = null;
244
+ let totalOutputBytes = 0;
245
+ let stdoutBytes = 0;
246
+ const stdoutChunks: Buffer[] = [];
247
+ let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
248
+
249
+ const sendSignal = (signal: NodeJS.Signals) => {
250
+ if (child.pid !== undefined && detached) {
251
+ try {
252
+ process.kill(-child.pid, signal);
253
+ return;
254
+ } catch {
255
+ // The process may have exited between the event and this signal.
256
+ }
257
+ }
258
+ try {
259
+ child.kill(signal);
260
+ } catch {
261
+ // close/error remains the authoritative completion event.
262
+ }
263
+ };
264
+
265
+ const timeout = setTimeout(() => {
266
+ terminate('TIMEOUT');
267
+ }, invocation.timeout_ms);
268
+ timeout.unref?.();
269
+
270
+ const abort = () => terminate('ABORTED');
271
+ options.signal?.addEventListener('abort', abort, { once: true });
272
+
273
+ const cleanup = () => {
274
+ clearTimeout(timeout);
275
+ if (forceKillTimer !== null) clearTimeout(forceKillTimer);
276
+ options.signal?.removeEventListener('abort', abort);
277
+ };
278
+
279
+ const finish = (result: RefereeRunnerExecutionResult) => {
280
+ if (settled) return;
281
+ settled = true;
282
+ cleanup();
283
+ resolve(result);
284
+ };
285
+
286
+ function terminate(code: RefereeRunnerFailureCode) {
287
+ if (settled || selectedFailure !== null) return;
288
+ selectedFailure = code;
289
+ sendSignal('SIGTERM');
290
+ forceKillTimer = setTimeout(() => sendSignal('SIGKILL'), FORCE_KILL_AFTER_MS);
291
+ forceKillTimer.unref?.();
292
+ }
293
+
294
+ const accountOutput = (chunk: Buffer | string, capture: boolean) => {
295
+ if (settled || selectedFailure !== null) return;
296
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
297
+ totalOutputBytes += bytes.byteLength;
298
+ if (totalOutputBytes > REFEREE_RUNNER_MAX_OUTPUT_BYTES) {
299
+ terminate('OUTPUT_TOO_LARGE');
300
+ return;
301
+ }
302
+ if (capture) {
303
+ stdoutBytes += bytes.byteLength;
304
+ stdoutChunks.push(bytes);
305
+ }
306
+ };
307
+
308
+ child.stdout.on('data', (chunk: Buffer) => accountOutput(chunk, true));
309
+ child.stderr.on('data', (chunk: Buffer) => accountOutput(chunk, false));
310
+ child.stdin.on('error', () => {
311
+ // EPIPE is resolved by the child's close/error result, never by ambient
312
+ // error text or timing.
313
+ });
314
+ child.once('error', () => finish(frozenFailure(
315
+ selectedFailure ?? 'SPAWN_FAILED',
316
+ )));
317
+ child.once('close', (code) => {
318
+ if (selectedFailure !== null) {
319
+ finish(frozenFailure(selectedFailure));
320
+ return;
321
+ }
322
+ if (code !== 0) {
323
+ finish(frozenFailure('NONZERO_EXIT'));
324
+ return;
325
+ }
326
+ finish(decodeRunnerOutput(Buffer.concat(stdoutChunks, stdoutBytes)));
327
+ });
328
+
329
+ child.stdin.end(serialized, 'utf8');
330
+ });
331
+ }
332
+
333
+ /** Execute a caller-pinned protocol runner without shell or ambient config. */
334
+ export async function runPinnedProtocolRunner(
335
+ value: unknown,
336
+ rawOptions?: unknown,
337
+ ): Promise<RefereeRunnerExecutionResult> {
338
+ const invocation = parseRefereeRunnerInvocation(value);
339
+ const options = parseOptions(rawOptions);
340
+ return executeParsed(invocation, options);
341
+ }
342
+
343
+ /** Execute and evaluate one self-test, mapping every runner failure to no-claim. */
344
+ export async function runReferee(
345
+ value: unknown,
346
+ rawOptions?: unknown,
347
+ ): Promise<Readonly<RefereeResultV1>> {
348
+ // Snapshot every caller-controlled field before the first asynchronous edge.
349
+ const invocation = parseRefereeRunnerInvocation(value);
350
+ const options = parseOptions(rawOptions);
351
+ const execution = await executeParsed(invocation, options);
352
+ if (!execution.ok) {
353
+ return createIndeterminateRefereeResult({
354
+ runner_pin: invocation.runner_pin,
355
+ request: invocation.request,
356
+ reason_code: `runner_${execution.code.toLowerCase()}`,
357
+ });
358
+ }
359
+ return evaluateReferee({
360
+ version: REFEREE_EVALUATION_VERSION,
361
+ runner_pin: invocation.runner_pin,
362
+ request: invocation.request,
363
+ output: execution.output,
364
+ });
365
+ }