cli-validator 7.0.33 → 7.0.35

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.
@@ -0,0 +1,49 @@
1
+ import { stdin, stdout } from 'node:process'
2
+ import { runApprovedValidatorPlan } from './validator-local-runner.mjs'
3
+ import { RUNNER_LIMITS, RUNNER_PLAN_SCHEMA, RUNNER_APPROVAL_SCHEMA, RUNNER_SIGNER_SCHEMA } from './validator-runner-plan.mjs'
4
+
5
+ const CAPABILITIES = Object.freeze({
6
+ schemaVersion: 'validator.local-runner/1.0',
7
+ operations: ['capabilities', 'run-approved-plan'],
8
+ operationSchemas: {
9
+ capabilities: { type: 'object', additionalProperties: false, properties: {} },
10
+ 'run-approved-plan': {
11
+ type: 'object', additionalProperties: false,
12
+ required: ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'],
13
+ properties: {
14
+ repositoryRoot: { type: 'string', minLength: 1 }, planPath: { type: 'string', minLength: 1 },
15
+ approvalPath: { type: 'string', minLength: 1 }, signerConfigPath: { type: ['string', 'null'] },
16
+ },
17
+ },
18
+ },
19
+ planSchema: RUNNER_PLAN_SCHEMA, approvalSchema: RUNNER_APPROVAL_SCHEMA, signerSchema: RUNNER_SIGNER_SCHEMA,
20
+ limits: RUNNER_LIMITS,
21
+ boundary: 'POSIX subprocess runner; external preapproval required. Signed receipts require separately configured trust and isolation.',
22
+ })
23
+
24
+ async function readInput() {
25
+ const chunks = []
26
+ let bytes = 0
27
+ for await (const chunk of stdin) {
28
+ bytes += Buffer.byteLength(chunk)
29
+ if (bytes > RUNNER_LIMITS.maxControlBytes) throw new Error('Validator runner input exceeds its limit')
30
+ chunks.push(Buffer.from(chunk))
31
+ }
32
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
33
+ }
34
+
35
+ export async function runValidatorLocalCli(args) {
36
+ try {
37
+ if (args.length !== 1 || !CAPABILITIES.operations.includes(args[0])) {
38
+ throw new Error('Usage: cli-validator local capabilities | local run-approved-plan < runner-input.json')
39
+ }
40
+ const output = args[0] === 'capabilities' ? CAPABILITIES : await runApprovedValidatorPlan(await readInput())
41
+ stdout.write(JSON.stringify(output) + '\n')
42
+ if (output.status === 'failed') process.exitCode = 1
43
+ } catch (error) {
44
+ const failure = { status: 'failed', error: error instanceof Error ? error.message : String(error) }
45
+ if (error instanceof Error && error.execution) failure.execution = error.execution
46
+ stdout.write(JSON.stringify(failure) + '\n')
47
+ process.exitCode = 1
48
+ }
49
+ }
@@ -0,0 +1,170 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createHash, randomUUID, sign } from 'node:crypto'
3
+ import { performance } from 'node:perf_hooks'
4
+ import {
5
+ bytesSha256, loadApprovedExecutionPlan, loadApprovedSigner, validationSubjectForPlan, verifyPlanFiles,
6
+ } from './validator-runner-plan.mjs'
7
+ import { validatorReceiptPayload, validatorReceiptSubject } from './validator-runtime.mjs'
8
+
9
+ const TERMINATION_GRACE_MS = 250
10
+ const REAP_DEADLINE_MS = 1_000
11
+ const EXECUTION_SCHEMA = 'validator.runner-execution/1.0'
12
+ const EVIDENCE_SCHEMA = 'cli.tax.test-evidence/1.0'
13
+ const RECEIPT_SCHEMA = 'validator.execution-receipt/1.0'
14
+
15
+ function signalProcessGroup(child, signal) {
16
+ if (!Number.isInteger(child.pid)) throw new Error('Runner child has no process identifier')
17
+ try {
18
+ process.kill(-child.pid, signal)
19
+ return { signal, status: 'delivered' }
20
+ } catch (error) {
21
+ if (!(error instanceof Error) || !('code' in error) || error.code !== 'ESRCH') throw error
22
+ return { signal, status: 'already-exited' }
23
+ }
24
+ }
25
+
26
+ function executeBoundedCommand(authority) {
27
+ const { policy } = authority.plan
28
+ return new Promise((resolve, reject) => {
29
+ const started = performance.now()
30
+ const child = spawn(policy.executable, policy.args, {
31
+ cwd: authority.root, env: { ...policy.environment },
32
+ shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
33
+ })
34
+ const termination = []
35
+ const stdout = []
36
+ const stderr = []
37
+ const stdoutHash = createHash('sha256')
38
+ const stderrHash = createHash('sha256')
39
+ let capturedBytes = 0
40
+ let droppedBytes = 0
41
+ let timedOut = false
42
+ let outputLimitExceeded = false
43
+ let terminating = false
44
+ let escalation = null
45
+ let failure = null
46
+ const terminate = () => {
47
+ if (terminating) return
48
+ terminating = true
49
+ try {
50
+ termination.push(signalProcessGroup(child, 'SIGTERM'))
51
+ escalation = setTimeout(() => {
52
+ try { termination.push(signalProcessGroup(child, 'SIGKILL')) } catch (error) { failure = error }
53
+ }, TERMINATION_GRACE_MS)
54
+ } catch (error) {
55
+ failure = error
56
+ }
57
+ }
58
+ const timer = setTimeout(() => { timedOut = true; terminate() }, policy.timeoutMs)
59
+ const hardLimit = setTimeout(() => {
60
+ clearTimeout(timer)
61
+ if (escalation !== null) clearTimeout(escalation)
62
+ try { if (Number.isInteger(child.pid)) termination.push(signalProcessGroup(child, 'SIGKILL')) } catch (error) { failure = error }
63
+ child.stdout.destroy()
64
+ child.stderr.destroy()
65
+ child.unref()
66
+ reject(new Error('Validator process could not be reaped within its hard deadline', { cause: failure }))
67
+ }, policy.timeoutMs + TERMINATION_GRACE_MS + REAP_DEADLINE_MS)
68
+ const consume = (target, hash, chunk) => {
69
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
70
+ hash.update(bytes)
71
+ const remaining = policy.maxOutputBytes - capturedBytes
72
+ const retained = Math.min(bytes.length, remaining)
73
+ if (retained > 0) target.push(Buffer.from(bytes.subarray(0, retained)))
74
+ capturedBytes += retained
75
+ droppedBytes += bytes.length - retained
76
+ if (droppedBytes > 0) { outputLimitExceeded = true; terminate() }
77
+ }
78
+ child.stdout.on('data', (chunk) => consume(stdout, stdoutHash, chunk))
79
+ child.stderr.on('data', (chunk) => consume(stderr, stderrHash, chunk))
80
+ child.once('error', (error) => { failure = error })
81
+ child.once('close', (exitCode, signal) => {
82
+ clearTimeout(timer)
83
+ clearTimeout(hardLimit)
84
+ if (escalation !== null) clearTimeout(escalation)
85
+ try { if (Number.isInteger(child.pid)) termination.push(signalProcessGroup(child, 'SIGKILL')) } catch (error) { failure = error }
86
+ if (failure !== null) return reject(new Error('Validator subprocess lifecycle failed', { cause: failure }))
87
+ resolve({
88
+ exitCode, signal, timedOut, outputLimitExceeded,
89
+ durationMs: Math.max(0, Math.round(performance.now() - started)),
90
+ stdout: Buffer.concat(stdout).toString('utf8'), stderr: Buffer.concat(stderr).toString('utf8'),
91
+ stdoutSha256: stdoutHash.digest('hex'), stderrSha256: stderrHash.digest('hex'),
92
+ capturedBytes, droppedBytes, termination,
93
+ })
94
+ })
95
+ })
96
+ }
97
+
98
+ function executionSummary(result) {
99
+ if (result.timedOut) return 'Approved validation command exceeded its deadline'
100
+ if (result.outputLimitExceeded) return 'Approved validation command exceeded its output limit'
101
+ if (result.signal !== null) return `Approved validation command ended on signal ${result.signal}`
102
+ return `Approved validation command exited with code ${result.exitCode}`
103
+ }
104
+
105
+ export function validatorExecutionLogsDigest(result) {
106
+ return bytesSha256(JSON.stringify({
107
+ stdout: result.stdout, stderr: result.stderr, stdoutSha256: result.stdoutSha256, stderrSha256: result.stderrSha256,
108
+ capturedBytes: result.capturedBytes, droppedBytes: result.droppedBytes,
109
+ }))
110
+ }
111
+
112
+ function receiptSummary(result) {
113
+ return JSON.stringify({
114
+ message: executionSummary(result), logsSha256: validatorExecutionLogsDigest(result),
115
+ stdoutSha256: result.stdoutSha256, stderrSha256: result.stderrSha256,
116
+ capturedBytes: result.capturedBytes, droppedBytes: result.droppedBytes,
117
+ })
118
+ }
119
+
120
+ function localEvidence(subject, result, summary) {
121
+ if (!Number.isInteger(result.exitCode)) return null
122
+ return {
123
+ schemaVersion: EVIDENCE_SCHEMA, evidenceId: `${subject.validationRunId}:process`,
124
+ kind: 'test', runner: 'local', command: subject.policy.command,
125
+ exitCode: result.exitCode, durationMs: result.durationMs, summary,
126
+ artifactSha256: subject.artifactSha256, subject, subjectDigest: validatorReceiptSubject(subject),
127
+ }
128
+ }
129
+
130
+ function signedEvidence(subject, result, summary, signer, passed) {
131
+ const issuedAt = new Date()
132
+ const unsigned = {
133
+ schemaVersion: RECEIPT_SCHEMA, keyId: signer.keyId, nonce: `receipt-${randomUUID()}`,
134
+ subjectDigest: validatorReceiptSubject(subject), issuedAt: issuedAt.toISOString(),
135
+ expiresAt: new Date(issuedAt.getTime() + signer.receiptTtlMs).toISOString(),
136
+ result: { runner: 'trusted-runner', passed, exitCode: result.exitCode,
137
+ durationMs: result.durationMs, summary },
138
+ }
139
+ const receipt = { ...unsigned,
140
+ signature: sign(null, Buffer.from(validatorReceiptPayload(unsigned)), signer.key).toString('base64url') }
141
+ return { ...localEvidence(subject, result, summary), runner: 'trusted-runner', receipt }
142
+ }
143
+
144
+ export async function runApprovedValidatorPlan(input) {
145
+ const authority = await loadApprovedExecutionPlan(input)
146
+ const subject = validationSubjectForPlan(authority.plan, authority.planSha256, new Date().toISOString())
147
+ const result = await executeBoundedCommand(authority)
148
+ try {
149
+ await verifyPlanFiles(authority)
150
+ } catch (error) {
151
+ const failure = new Error('Validator integrity changed during execution', { cause: error })
152
+ failure.execution = { schemaVersion: EXECUTION_SCHEMA, status: 'failed', subject, process: result, evidence: [] }
153
+ throw failure
154
+ }
155
+ const passed = result.exitCode === authority.plan.policy.requiredExitCode
156
+ && result.signal === null && !result.timedOut && !result.outputLimitExceeded
157
+ const summary = receiptSummary(result)
158
+ const signer = Number.isInteger(result.exitCode) ? await loadApprovedSigner(authority) : null
159
+ const evidence = signer === null ? localEvidence(subject, result, summary)
160
+ : signedEvidence(subject, result, summary, signer, passed)
161
+ return {
162
+ schemaVersion: EXECUTION_SCHEMA, status: passed ? 'succeeded' : 'failed',
163
+ planSha256: authority.planSha256,
164
+ approvalSha256: authority.approval.sha256, approvedBy: authority.approval.approval.approvedBy,
165
+ subject, process: result, evidence: evidence === null ? [] : [evidence],
166
+ receipt: evidence !== null && signer !== null ? evidence.receipt : null,
167
+ signingBoundary: signer === null ? 'local-evidence-only'
168
+ : 'configured-signature-only; independent-uid-or-container-isolation-required-for-production-trust',
169
+ }
170
+ }
@@ -0,0 +1,306 @@
1
+ import { constants } from 'node:fs'
2
+ import { lstat, open, realpath } from 'node:fs/promises'
3
+ import { createHash, createPrivateKey, createPublicKey } from 'node:crypto'
4
+ import { isAbsolute, relative, resolve, sep } from 'node:path'
5
+ import {
6
+ run as validateProtocol, validatorArtifactSubject,
7
+ } from './validator-runtime.mjs'
8
+
9
+ export const RUNNER_PLAN_SCHEMA = 'validator.execution-plan/1.0'
10
+ export const RUNNER_APPROVAL_SCHEMA = 'validator.runner-approval/1.0'
11
+ export const RUNNER_SIGNER_SCHEMA = 'validator.runner-signer/1.0'
12
+ export const RUNNER_LIMITS = Object.freeze({
13
+ maxFiles: 512, maxFileBytes: 16 * 1024 * 1024, maxManifestBytes: 64 * 1024 * 1024,
14
+ maxControlBytes: 1024 * 1024, maxExecutableBytes: 256 * 1024 * 1024, maxTimeoutMs: 300_000, maxOutputBytes: 1024 * 1024,
15
+ maxApprovalLifetimeMs: 24 * 60 * 60 * 1000, maxReceiptLifetimeMs: 600_000,
16
+ })
17
+ const SHA = /^[0-9a-f]{64}$/
18
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
19
+ const ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/
20
+
21
+ export function bytesSha256(value) {
22
+ return createHash('sha256').update(value).digest('hex')
23
+ }
24
+
25
+ function exactObject(value, fields, label) {
26
+ if (!value || typeof value !== 'object' || Array.isArray(value)
27
+ || Object.keys(value).sort().join('|') !== [...fields].sort().join('|')) {
28
+ throw new Error(`${label} has invalid fields`)
29
+ }
30
+ return value
31
+ }
32
+
33
+ function positiveInteger(value, maximum, label) {
34
+ if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) {
35
+ throw new Error(`${label} must be a positive bounded integer`)
36
+ }
37
+ }
38
+
39
+ function relativeFile(value) {
40
+ if (typeof value !== 'string' || !value || value !== value.normalize('NFC')
41
+ || isAbsolute(value) || /^[A-Za-z]:/.test(value) || value.includes('\\')
42
+ || /[\u0000-\u001f\u007f]/.test(value)
43
+ || value.split('/').some((part) => !part || part === '.' || part === '..')) {
44
+ throw new Error('Manifest paths must be normalized relative files')
45
+ }
46
+ return value
47
+ }
48
+
49
+ function inside(root, target) {
50
+ const path = relative(root, target)
51
+ return path === '' || (!isAbsolute(path) && path !== '..' && !path.startsWith(`..${sep}`))
52
+ }
53
+
54
+ export async function readRegularFile(path, maximum, privateFile) {
55
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
56
+ try {
57
+ const before = await handle.stat()
58
+ if (!before.isFile() || before.size > maximum) throw new Error('File is not regular or exceeds its limit')
59
+ if (privateFile && (before.uid !== process.getuid() || (before.mode & 0o777) !== 0o600
60
+ || before.nlink !== 1)) throw new Error('Runner control files must be owned by the runner and mode 0600')
61
+ const bytes = await handle.readFile()
62
+ const after = await handle.stat()
63
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs
64
+ || bytes.length !== after.size) throw new Error('File changed while being read')
65
+ return { bytes, byteLength: bytes.length, sha256: bytesSha256(bytes), device: before.dev, inode: before.ino }
66
+ } finally {
67
+ await handle.close()
68
+ }
69
+ }
70
+
71
+ async function hashRegularFile(path, maximum) {
72
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
73
+ try {
74
+ const before = await handle.stat()
75
+ if (!before.isFile() || before.size > maximum) throw new Error('Hash target is not a bounded regular file')
76
+ const hash = createHash('sha256')
77
+ const buffer = Buffer.alloc(64 * 1024)
78
+ let byteLength = 0
79
+ while (true) {
80
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null)
81
+ if (bytesRead === 0) break
82
+ byteLength += bytesRead
83
+ if (byteLength > maximum) throw new Error('Hash target grew beyond its limit')
84
+ hash.update(buffer.subarray(0, bytesRead))
85
+ }
86
+ const after = await handle.stat()
87
+ if (before.size !== after.size || before.mtimeMs !== after.mtimeMs
88
+ || byteLength !== after.size) throw new Error('Hash target changed while being read')
89
+ return { byteLength, sha256: hash.digest('hex'), device: before.dev, inode: before.ino }
90
+ } finally {
91
+ await handle.close()
92
+ }
93
+ }
94
+
95
+ async function externalFile(root, path, maximum) {
96
+ if (typeof path !== 'string' || !isAbsolute(path)) throw new Error('External runner control path must be absolute')
97
+ const status = await lstat(path)
98
+ if (status.isSymbolicLink()) throw new Error('External runner control cannot be a symlink')
99
+ const canonical = await realpath(path)
100
+ if (inside(root, canonical)) throw new Error('Runner controls and keys must be outside the tested workspace')
101
+ return { path: canonical, ...(await readRegularFile(canonical, maximum, true)) }
102
+ }
103
+
104
+ async function projectFile(root, path, contents) {
105
+ const name = relativeFile(path)
106
+ let target = root
107
+ for (const [index, part] of name.split('/').entries()) {
108
+ target = resolve(target, part)
109
+ const status = await lstat(target)
110
+ if (status.isSymbolicLink() || (index < name.split('/').length - 1 && !status.isDirectory())) {
111
+ throw new Error('Workspace files cannot traverse symlinks or non-directory parents')
112
+ }
113
+ }
114
+ if (!inside(root, await realpath(target))) throw new Error('Workspace file escapes its root')
115
+ const data = contents ? await readRegularFile(target, RUNNER_LIMITS.maxFileBytes, false)
116
+ : await hashRegularFile(target, RUNNER_LIMITS.maxFileBytes)
117
+ return { path: target, ...data }
118
+ }
119
+
120
+ function validateExecutionPolicy(policy, tests, files) {
121
+ exactObject(policy, ['executable', 'executableSha256', 'args', 'environment',
122
+ 'timeoutMs', 'maxOutputBytes', 'requiredExitCode'], 'Execution policy')
123
+ if (typeof policy.executable !== 'string' || !isAbsolute(policy.executable)
124
+ || !SHA.test(policy.executableSha256)) throw new Error('Executable requires an absolute path and SHA-256')
125
+ if (!Array.isArray(policy.args) || policy.args.length > 128
126
+ || policy.args.some((value) => typeof value !== 'string' || value.includes('\0') || value.length > 4096)) {
127
+ throw new Error('Executable args must be a bounded string array')
128
+ }
129
+ if (!policy.environment || typeof policy.environment !== 'object' || Array.isArray(policy.environment)
130
+ || Object.keys(policy.environment).length > 64
131
+ || Object.entries(policy.environment).some(([key, value]) => !ENVIRONMENT_KEY.test(key)
132
+ || typeof value !== 'string' || value.includes('\0') || value.length > 16_384)) {
133
+ throw new Error('Child environment must be explicit and bounded')
134
+ }
135
+ positiveInteger(policy.timeoutMs, RUNNER_LIMITS.maxTimeoutMs, 'timeoutMs')
136
+ positiveInteger(policy.maxOutputBytes, RUNNER_LIMITS.maxOutputBytes, 'maxOutputBytes')
137
+ if (policy.requiredExitCode !== 0) throw new Error('Validation requires exit code zero')
138
+ if (!Array.isArray(tests) || !tests.length || tests.length > RUNNER_LIMITS.maxFiles) {
139
+ throw new Error('Frozen tests must be non-empty and bounded')
140
+ }
141
+ for (const item of tests) {
142
+ exactObject(item, ['testId', 'path'], 'Frozen test')
143
+ if (!IDENTIFIER.test(item.testId) || !files.some((file) => file.path === item.path)
144
+ || !policy.args.includes(relativeFile(item.path))) {
145
+ throw new Error('Each test must be in the manifest and passed explicitly as a command argument')
146
+ }
147
+ }
148
+ }
149
+
150
+ function validateFrozenPlan(plan) {
151
+ exactObject(plan, ['schemaVersion', 'frozen', 'planId', 'validationRunId', 'memberId', 'chainId',
152
+ 'artifactSha256', 'files', 'tests', 'policy', 'goldenBaseline', 'contracts'], 'Frozen execution plan')
153
+ if (plan.schemaVersion !== RUNNER_PLAN_SCHEMA || plan.frozen !== true
154
+ || !SHA.test(plan.artifactSha256) || !Array.isArray(plan.files)
155
+ || !plan.files.length || plan.files.length > RUNNER_LIMITS.maxFiles) {
156
+ throw new Error('Frozen execution plan is invalid')
157
+ }
158
+ for (const [index, file] of plan.files.entries()) {
159
+ exactObject(file, ['path', 'sha256'], 'Manifest entry')
160
+ relativeFile(file.path)
161
+ if (!SHA.test(file.sha256) || (index > 0 && plan.files[index - 1].path >= file.path)) {
162
+ throw new Error('Manifest paths must be unique, sorted and SHA-bound')
163
+ }
164
+ }
165
+ if (validatorArtifactSubject(plan.files) !== plan.artifactSha256) throw new Error('Artifact manifest digest mismatch')
166
+ validateExecutionPolicy(plan.policy, plan.tests, plan.files)
167
+ if (plan.contracts?.archguard?.driftStatus === 'red') throw new Error('ArchGuard red blocks execution')
168
+ return plan
169
+ }
170
+
171
+ export function validationSubjectForPlan(plan, planSha256, executedAt) {
172
+ return {
173
+ schemaVersion: 'validator.validation-subject/1.0',
174
+ artifactSha256: plan.artifactSha256, memberId: plan.memberId, chainId: plan.chainId,
175
+ executedAt, files: plan.files, validationRunId: plan.validationRunId, planId: plan.planId,
176
+ tests: plan.tests,
177
+ policy: { command: [plan.policy.executable, ...plan.policy.args].map((value) => JSON.stringify(value)).join(' '),
178
+ requiredExitCode: plan.policy.requiredExitCode, executionPlanSha256: planSha256 },
179
+ goldenBaseline: plan.goldenBaseline, contracts: plan.contracts,
180
+ }
181
+ }
182
+
183
+ async function validateApproval(root, approvalPath, planSha256) {
184
+ const file = await externalFile(root, approvalPath, RUNNER_LIMITS.maxControlBytes)
185
+ const approval = exactObject(JSON.parse(file.bytes.toString('utf8')),
186
+ ['schemaVersion', 'repositoryRoot', 'planSha256', 'approvedBy', 'approvedAt', 'expiresAt'],
187
+ 'Runner approval')
188
+ const approvedAt = Date.parse(approval.approvedAt)
189
+ const expiresAt = Date.parse(approval.expiresAt)
190
+ if (approval.schemaVersion !== RUNNER_APPROVAL_SCHEMA || approval.repositoryRoot !== root
191
+ || approval.planSha256 !== planSha256 || !IDENTIFIER.test(approval.approvedBy)
192
+ || !Number.isFinite(approvedAt) || !Number.isFinite(expiresAt)
193
+ || approvedAt > Date.now() || expiresAt <= Date.now() || expiresAt <= approvedAt
194
+ || expiresAt - approvedAt > RUNNER_LIMITS.maxApprovalLifetimeMs) {
195
+ throw new Error('External approval is expired or does not match the frozen plan')
196
+ }
197
+ return { ...file, approval }
198
+ }
199
+
200
+ async function signerAuthority(root, signerConfigPath, policy) {
201
+ if (signerConfigPath === null) return null
202
+ const file = await externalFile(root, signerConfigPath, RUNNER_LIMITS.maxControlBytes)
203
+ const config = exactObject(JSON.parse(file.bytes.toString('utf8')),
204
+ ['schemaVersion', 'privateKeyPath', 'keyId', 'receiptTtlMs'], 'Runner signer')
205
+ if (config.schemaVersion !== RUNNER_SIGNER_SCHEMA || !SHA.test(config.keyId)) {
206
+ throw new Error('External runner signer configuration is invalid')
207
+ }
208
+ positiveInteger(config.receiptTtlMs, RUNNER_LIMITS.maxReceiptLifetimeMs, 'receiptTtlMs')
209
+ const privatePath = await realpath(config.privateKeyPath)
210
+ if (!isAbsolute(config.privateKeyPath) || inside(root, privatePath)
211
+ || JSON.stringify([policy.args, policy.environment]).includes(config.privateKeyPath)
212
+ || JSON.stringify([policy.args, policy.environment]).includes(privatePath)
213
+ || JSON.stringify([policy.args, policy.environment]).includes(file.path)) {
214
+ throw new Error('Signer paths cannot be inside the workspace or passed to the child')
215
+ }
216
+ const keyStatus = await lstat(config.privateKeyPath)
217
+ if (keyStatus.isSymbolicLink() || !keyStatus.isFile() || keyStatus.uid !== process.getuid()
218
+ || (keyStatus.mode & 0o777) !== 0o600 || keyStatus.nlink !== 1) {
219
+ throw new Error('Runner private key must be a regular owned mode-0600 external file')
220
+ }
221
+ return { ...file, config: { ...config, privateKeyPath: privatePath } }
222
+ }
223
+
224
+ async function verifyBrainControlAuthority(authority) {
225
+ if (authority.plan.contracts.brain === undefined) return
226
+ if (process.getuid() !== 0) throw new Error('Brain validation requires an isolated privileged runner')
227
+ const executable = await lstat(authority.plan.policy.executable)
228
+ if (executable.uid !== 0 || (executable.mode & 0o022) !== 0) throw new Error('Brain runner executable must be root owned and protected')
229
+ for (const name of authority.plan.policy.args) {
230
+ const parts = relativeFile(name).split('/')
231
+ let target = authority.root
232
+ for (const [index, part] of ['', ...parts].entries()) {
233
+ if (part) target = resolve(target, part)
234
+ const status = await lstat(target)
235
+ if (status.uid !== 0 || status.isSymbolicLink() || (status.mode & 0o022) !== 0) {
236
+ throw new Error('Brain runner control paths must be root owned and protected')
237
+ }
238
+ if (index === parts.length && (!status.isFile() || status.nlink !== 1 || (status.mode & 0o777) !== 0o600)) {
239
+ throw new Error('Brain runner controls require root owned mode-0600 regular files')
240
+ }
241
+ }
242
+ }
243
+ }
244
+
245
+ export async function verifyPlanFiles(authority) {
246
+ await verifyBrainControlAuthority(authority)
247
+ let bytes = 0
248
+ for (const file of authority.plan.files) {
249
+ const actual = await projectFile(authority.root, file.path, false)
250
+ bytes += actual.byteLength
251
+ if (bytes > RUNNER_LIMITS.maxManifestBytes || actual.sha256 !== file.sha256) {
252
+ throw new Error(`Artifact changed or exceeds its bound: ${file.path}`)
253
+ }
254
+ }
255
+ const executable = await hashRegularFile(authority.plan.policy.executable, RUNNER_LIMITS.maxExecutableBytes)
256
+ if (executable.sha256 !== authority.plan.policy.executableSha256) throw new Error('Executable SHA-256 mismatch')
257
+ const plan = await projectFile(authority.root, authority.planPath, true)
258
+ if (plan.sha256 !== authority.planSha256) throw new Error('Frozen plan changed')
259
+ const approval = await validateApproval(authority.root, authority.approval.path, authority.planSha256)
260
+ if (approval.sha256 !== authority.approval.sha256) throw new Error('External approval changed')
261
+ if (authority.signer !== null) {
262
+ const signer = await externalFile(authority.root, authority.signer.path, RUNNER_LIMITS.maxControlBytes)
263
+ if (signer.sha256 !== authority.signer.sha256) throw new Error('External signer configuration changed')
264
+ }
265
+ }
266
+
267
+ export async function loadApprovedExecutionPlan(input) {
268
+ if (process.platform === 'win32' || typeof process.getuid !== 'function') {
269
+ throw new Error('This bounded subprocess runner requires a POSIX host')
270
+ }
271
+ exactObject(input, ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'], 'Runner input')
272
+ if (typeof input.repositoryRoot !== 'string' || !isAbsolute(input.repositoryRoot)) {
273
+ throw new Error('repositoryRoot must be absolute')
274
+ }
275
+ const rootStatus = await lstat(input.repositoryRoot)
276
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) throw new Error('Workspace root must be a real directory')
277
+ const root = await realpath(input.repositoryRoot)
278
+ const planFile = await projectFile(root, input.planPath, true)
279
+ if (planFile.bytes.length > RUNNER_LIMITS.maxControlBytes) throw new Error('Frozen plan is too large')
280
+ const plan = validateFrozenPlan(JSON.parse(planFile.bytes.toString('utf8')))
281
+ const approval = await validateApproval(root, input.approvalPath, planFile.sha256)
282
+ const signer = await signerAuthority(root, input.signerConfigPath, plan.policy)
283
+ const authority = { root, plan, planPath: input.planPath, planSha256: planFile.sha256, approval, signer }
284
+ await verifyPlanFiles(authority)
285
+ const subject = validationSubjectForPlan(plan, planFile.sha256, new Date().toISOString())
286
+ const validation = await validateProtocol({ schemaVersion: 'validator.skill.request/1.0',
287
+ requestId: 'runner-subject-validation', operation: 'verdict',
288
+ input: { expectedSubject: subject, evidence: [], findings: [] } })
289
+ if (validation.status !== 'succeeded') throw new Error('Frozen validation subject fails the Validator protocol')
290
+ return authority
291
+ }
292
+
293
+ export async function loadApprovedSigner(authority) {
294
+ if (authority.signer === null) return null
295
+ const { config } = authority.signer
296
+ const file = await externalFile(authority.root, config.privateKeyPath, 16_384)
297
+ try {
298
+ const key = createPrivateKey(file.bytes)
299
+ if (key.asymmetricKeyType !== 'ed25519') throw new Error('Runner signer requires Ed25519')
300
+ const publicDer = createPublicKey(key).export({ format: 'der', type: 'spki' })
301
+ if (bytesSha256(publicDer) !== config.keyId) throw new Error('Runner signer public key fingerprint mismatch')
302
+ return { key, keyId: config.keyId, receiptTtlMs: config.receiptTtlMs }
303
+ } finally {
304
+ file.bytes.fill(0)
305
+ }
306
+ }