cli-validator 7.0.32 → 7.0.34

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,284 @@
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
+ export async function verifyPlanFiles(authority) {
225
+ let bytes = 0
226
+ for (const file of authority.plan.files) {
227
+ const actual = await projectFile(authority.root, file.path, false)
228
+ bytes += actual.byteLength
229
+ if (bytes > RUNNER_LIMITS.maxManifestBytes || actual.sha256 !== file.sha256) {
230
+ throw new Error(`Artifact changed or exceeds its bound: ${file.path}`)
231
+ }
232
+ }
233
+ const executable = await hashRegularFile(authority.plan.policy.executable, RUNNER_LIMITS.maxExecutableBytes)
234
+ if (executable.sha256 !== authority.plan.policy.executableSha256) throw new Error('Executable SHA-256 mismatch')
235
+ const plan = await projectFile(authority.root, authority.planPath, true)
236
+ if (plan.sha256 !== authority.planSha256) throw new Error('Frozen plan changed')
237
+ const approval = await validateApproval(authority.root, authority.approval.path, authority.planSha256)
238
+ if (approval.sha256 !== authority.approval.sha256) throw new Error('External approval changed')
239
+ if (authority.signer !== null) {
240
+ const signer = await externalFile(authority.root, authority.signer.path, RUNNER_LIMITS.maxControlBytes)
241
+ if (signer.sha256 !== authority.signer.sha256) throw new Error('External signer configuration changed')
242
+ }
243
+ }
244
+
245
+ export async function loadApprovedExecutionPlan(input) {
246
+ if (process.platform === 'win32' || typeof process.getuid !== 'function') {
247
+ throw new Error('This bounded subprocess runner requires a POSIX host')
248
+ }
249
+ exactObject(input, ['repositoryRoot', 'planPath', 'approvalPath', 'signerConfigPath'], 'Runner input')
250
+ if (typeof input.repositoryRoot !== 'string' || !isAbsolute(input.repositoryRoot)) {
251
+ throw new Error('repositoryRoot must be absolute')
252
+ }
253
+ const rootStatus = await lstat(input.repositoryRoot)
254
+ if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) throw new Error('Workspace root must be a real directory')
255
+ const root = await realpath(input.repositoryRoot)
256
+ const planFile = await projectFile(root, input.planPath, true)
257
+ if (planFile.bytes.length > RUNNER_LIMITS.maxControlBytes) throw new Error('Frozen plan is too large')
258
+ const plan = validateFrozenPlan(JSON.parse(planFile.bytes.toString('utf8')))
259
+ const approval = await validateApproval(root, input.approvalPath, planFile.sha256)
260
+ const signer = await signerAuthority(root, input.signerConfigPath, plan.policy)
261
+ const authority = { root, plan, planPath: input.planPath, planSha256: planFile.sha256, approval, signer }
262
+ await verifyPlanFiles(authority)
263
+ const subject = validationSubjectForPlan(plan, planFile.sha256, new Date().toISOString())
264
+ const validation = await validateProtocol({ schemaVersion: 'validator.skill.request/1.0',
265
+ requestId: 'runner-subject-validation', operation: 'verdict',
266
+ input: { expectedSubject: subject, evidence: [], findings: [] } })
267
+ if (validation.status !== 'succeeded') throw new Error('Frozen validation subject fails the Validator protocol')
268
+ return authority
269
+ }
270
+
271
+ export async function loadApprovedSigner(authority) {
272
+ if (authority.signer === null) return null
273
+ const { config } = authority.signer
274
+ const file = await externalFile(authority.root, config.privateKeyPath, 16_384)
275
+ try {
276
+ const key = createPrivateKey(file.bytes)
277
+ if (key.asymmetricKeyType !== 'ed25519') throw new Error('Runner signer requires Ed25519')
278
+ const publicDer = createPublicKey(key).export({ format: 'der', type: 'spki' })
279
+ if (bytesSha256(publicDer) !== config.keyId) throw new Error('Runner signer public key fingerprint mismatch')
280
+ return { key, keyId: config.keyId, receiptTtlMs: config.receiptTtlMs }
281
+ } finally {
282
+ file.bytes.fill(0)
283
+ }
284
+ }