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.
- package/README.md +49 -0
- package/brain-validator-contract.d.mts +9 -0
- package/brain-validator-contract.mjs +151 -0
- package/brain-validator-plan.mjs +92 -0
- package/cli.mjs +6 -1
- package/package.json +18 -2
- package/skill/SKILL.md +13 -1
- package/skill/skill.json +1 -1
- package/validator-brain-contract.mjs +47 -0
- package/validator-compliance.mjs +158 -0
- package/validator-local-cli.mjs +49 -0
- package/validator-local-runner.mjs +170 -0
- package/validator-runner-plan.mjs +306 -0
- package/validator-runtime.mjs +488 -0
|
@@ -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
|
+
}
|