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.
package/README.md CHANGED
@@ -24,3 +24,15 @@ Source: https://github.com/88208555/Validator-clitax.git
24
24
  Brain Client 服务端在同一次 runtime 请求的事务中绑定真实响应、生成并持久化权威评分与评语,再返回已提交回执。broker 只验证 `feedbackReceiptId`、`feedbackInvocationId` 和权威摘要,不发起第二次评价写入,也不生成分数或评语。`not-reported`、验证不完整、P0/P1 findings、`blocked` 或 `failed` 都不得生成好评;缺凭证、缺回执、摘要不匹配、响应非法或 HTTP 失败都会显式失败。
25
25
 
26
26
  本地 CLI 不提供手工评分或评语提交命令,人类不能选择技能分数或填写技能评价。日常聊天不属于评价协议。
27
+
28
+ ## 本地受限执行器
29
+
30
+ 先运行 `cli-validator local capabilities` 读取输入合同与限额,再以 JSON stdin 调用 `cli-validator local run-approved-plan`。输入必须完整包含 `repositoryRoot`(绝对目录)、`planPath`(工作区相对路径)、`approvalPath`(外部绝对路径)与 `signerConfigPath`(外部绝对路径或显式 null)。
31
+
32
+ 冻结计划采用 `validator.execution-plan/1.0`:frozen=true,planId/validationRunId/memberId/chainId,排序且唯一的 files[{path,sha256}] 与 artifactSha256,tests[{testId,path}],GoldenBaseline 与 Aimlock 合同。policy 明确 executable(绝对真实文件)及 executableSha256、args、environment 字符串字典、timeoutMs、maxOutputBytes、requiredExitCode=0。每个测试文件必须列入 manifest 并作为真实命令参数传入;组合测试可使用受审计的入口脚本,不能把未执行文件假报为测试。执行前后校验产物、计划、可执行文件与外部授权。
33
+
34
+ 外部授权为 `validator.runner-approval/1.0`,包含 repositoryRoot、planSha256(冻结计划文件原始字节 SHA-256)、approvedBy、approvedAt、expiresAt,最长 24 小时。文件必须归执行账户所有、0600、非符号链接且位于被测工作区之外。它记录外部批准;智能体不得伪造批准或用测试 fixture 授权真实工作。
35
+
36
+ 未配置 signer 时只返回 local TestEvidence,独立终审仍为 incomplete。可选 `validator.runner-signer/1.0` 配置包含外置 privateKeyPath、keyId(Ed25519 SPKI DER SHA-256)、receiptTtlMs(最长 10 分钟);配置和私钥同样必须为外部 0600 文件。签名绑定 subject、退出结果与日志指纹,消费方使用已配置公钥核验。密钥配置不是进程隔离:同 UID 子进程可能读取同账户文件,生产可信服务仍须独立 UID/容器及权限隔离;本工具不自动部署隔离、不创建可信密钥,也不改变验证器的信任配置。
37
+
38
+ 本地运行仅支持 POSIX;无 shell、显式子进程环境、受限输出与时限。信号终止保留 exitCode=null,不制造整数退出码或成功 receipt。超时、输出超限、非零退出或运行中完整性变化均失败;原始执行记录与日志摘要可审计。此进程执行器不是 OS 沙箱,不授予任意磁盘或网络访问。
package/broker.mjs CHANGED
@@ -25,6 +25,19 @@ const EVALUATION_SCHEMA = 'skill-automatic-evaluation/1.0'
25
25
  const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
26
26
  const REQUEST_SCHEMA_PATTERN = /^([A-Za-z0-9.-]+\.skill)\.request\/([0-9]+\.[0-9]+)$/
27
27
  const TRANSPORT_ERROR_CODE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/
28
+ const NETWORK_TRANSPORT_ERROR = 'NETWORK_TRANSPORT'
29
+ const SKILL_INVOCATION_ERROR = 'SKILL_INVOCATION_FAILED'
30
+
31
+ class OfficialSkillInvocationError extends Error {
32
+ constructor(context, operation, transportCode) {
33
+ super(`${context.displayName} ${operation} invocation failed: network transport ${transportCode}`)
34
+ this.name = 'OfficialSkillInvocationError'
35
+ this.code = NETWORK_TRANSPORT_ERROR
36
+ this.operation = operation
37
+ this.retryable = false
38
+ this.transportCode = transportCode
39
+ }
40
+ }
28
41
 
29
42
  function asObject(value, label) {
30
43
  if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -237,6 +250,29 @@ export function transportFailureCode(error) {
237
250
  return 'UNKNOWN_TRANSPORT_ERROR'
238
251
  }
239
252
 
253
+ export function officialSkillFailureResponse(error) {
254
+ if (error instanceof OfficialSkillInvocationError) {
255
+ return {
256
+ ok: false,
257
+ error: {
258
+ code: error.code,
259
+ message: error.message,
260
+ operation: error.operation,
261
+ retryable: error.retryable,
262
+ transportCode: error.transportCode,
263
+ },
264
+ }
265
+ }
266
+ return {
267
+ ok: false,
268
+ error: {
269
+ code: SKILL_INVOCATION_ERROR,
270
+ message: error instanceof Error ? error.message : 'Skill invocation failed',
271
+ retryable: false,
272
+ },
273
+ }
274
+ }
275
+
240
276
  export async function invokeOfficialSkill(context, operation, input, dependencies) {
241
277
  const environment = asObject(dependencies.environment, 'broker environment')
242
278
  if (typeof dependencies.request !== 'function') {
@@ -253,9 +289,7 @@ export async function invokeOfficialSkill(context, operation, input, dependencie
253
289
  signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
254
290
  })
255
291
  } catch (error) {
256
- throw new Error(
257
- `${context.displayName} ${operation} invocation failed: network transport ${transportFailureCode(error)}`,
258
- )
292
+ throw new OfficialSkillInvocationError(context, operation, transportFailureCode(error))
259
293
  }
260
294
  const payload = await responsePayload(response, `${context.displayName} ${operation} response`)
261
295
  if (!response.ok || payload.ok !== true) {
package/cli.mjs CHANGED
@@ -2,6 +2,7 @@
2
2
  import { dirname } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { dispatchOfficialSkillCli, runIntakeHandshake } from './installer.mjs'
5
+ import { runValidatorLocalCli } from './validator-local-cli.mjs'
5
6
 
6
7
  const INTAKE_QUESTIONS = [
7
8
  { id: 'goal', prompt: 'What is being validated? Describe the deliverable and expected behavior.', required: true, example: '电商运营仪表盘计算工具:输入访客数/订单数/GMV/广告费,输出转化率/客单价/ROAS。' },
@@ -10,8 +11,10 @@ const INTAKE_QUESTIONS = [
10
11
  { id: 'targetFiles', prompt: 'Files to validate (or "auto" to scan all).', required: false, example: 'auto' },
11
12
  ]
12
13
 
13
- await dispatchOfficialSkillCli({
14
+ if (process.argv[2] === 'local') await runValidatorLocalCli(process.argv.slice(3))
15
+ else await dispatchOfficialSkillCli({
14
16
  packageRoot: dirname(fileURLToPath(import.meta.url)),
17
+ extraUsageLines: [' cli-validator local capabilities', ' cli-validator local run-approved-plan < runner-input.json'],
15
18
  runCommand: (context) => runIntakeHandshake(context, {
16
19
  questions: INTAKE_QUESTIONS,
17
20
  outputFile: 'VALIDATOR-REQUIREMENTS.json',
package/installer.mjs CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  brokerCommandInput,
14
14
  invokeCommandInput,
15
15
  invokeOfficialSkill,
16
+ officialSkillFailureResponse,
16
17
  } from './broker.mjs'
17
18
 
18
19
  export {
@@ -25,6 +26,7 @@ export {
25
26
  callOfficialSkill,
26
27
  invokeCommandInput,
27
28
  invokeOfficialSkill,
29
+ officialSkillFailureResponse,
28
30
  } from './broker.mjs'
29
31
 
30
32
  const INSTALL_META = 'install-meta.json'
@@ -184,11 +186,19 @@ async function readBrokerSource(input) {
184
186
  }
185
187
 
186
188
  async function runBrokerInvocation(context, commandInput) {
187
- const invocation = await invokeOfficialSkill(
188
- context, commandInput.operation, commandInput.input, brokerDependencies(),
189
- )
190
- console.log(JSON.stringify(invocation))
191
- return invocation
189
+ try {
190
+ const invocation = await invokeOfficialSkill(
191
+ context, commandInput.operation, commandInput.input, brokerDependencies(),
192
+ )
193
+ console.log(JSON.stringify(invocation))
194
+ return invocation
195
+ } catch (error) {
196
+ const response = officialSkillFailureResponse(error)
197
+ console.log(JSON.stringify({ response }))
198
+ console.error(response.error.message)
199
+ process.exitCode = 1
200
+ return null
201
+ }
192
202
  }
193
203
 
194
204
  export async function runIntakeHandshake(context, spec) {
package/package.json CHANGED
@@ -3,13 +3,23 @@
3
3
  "cli-validator": "./cli.mjs"
4
4
  },
5
5
  "description": "Validator skill installer for CLI.Tax: delivery quality gate with three defense lines and golden baseline testing.",
6
+ "exports": {
7
+ "./local-runner": "./validator-local-runner.mjs",
8
+ "./runner-plan": "./validator-runner-plan.mjs",
9
+ "./runtime": "./validator-runtime.mjs"
10
+ },
6
11
  "files": [
7
12
  "cli.mjs",
8
13
  "installer.mjs",
9
14
  "broker.mjs",
10
15
  "README.md",
11
16
  "skill/SKILL.md",
12
- "skill/skill.json"
17
+ "skill/skill.json",
18
+ "validator-runner-plan.mjs",
19
+ "validator-local-runner.mjs",
20
+ "validator-local-cli.mjs",
21
+ "validator-runtime.mjs",
22
+ "validator-compliance.mjs"
13
23
  ],
14
24
  "license": "UNLICENSED",
15
25
  "name": "cli-validator",
@@ -18,5 +28,5 @@
18
28
  "url": "https://github.com/88208555/Validator-clitax.git"
19
29
  },
20
30
  "type": "module",
21
- "version": "7.0.32"
31
+ "version": "7.0.34"
22
32
  }
package/skill/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: '交付前质量门禁:三道防线(静态/动态/对抗)递
5
5
 
6
6
  # Validator
7
7
 
8
- Package version: v7.0.32
8
+ Package version: v7.0.34
9
9
 
10
10
  Validator 是技能链最后一站,只消费冻结目标和真实执行证据;模型解释没有裁判权。
11
11
 
@@ -107,3 +107,15 @@ GoldenBaseline 只有 `frozen: true` 才有效。来源只允许 `repository-com
107
107
  - 本地 CLI 不提供手工评分或评语提交命令,人类不得选择技能分数或填写技能评价;日常聊天不属于评价协议。
108
108
 
109
109
  调用示例:`npx cli-validator@latest invoke <operation> '<JSON对象>'`。IDE 集成可向 `npx cli-validator@latest broker` 的 stdin 发送 `{"operation":"capabilities","input":{}}`。
110
+
111
+ ## 本地受限执行器
112
+
113
+ 先运行 `cli-validator local capabilities` 读取输入合同与限额,再以 JSON stdin 调用 `cli-validator local run-approved-plan`。输入必须完整包含 `repositoryRoot`(绝对目录)、`planPath`(工作区相对路径)、`approvalPath`(外部绝对路径)与 `signerConfigPath`(外部绝对路径或显式 null)。
114
+
115
+ 冻结计划采用 `validator.execution-plan/1.0`:frozen=true,planId/validationRunId/memberId/chainId,排序且唯一的 files[{path,sha256}] 与 artifactSha256,tests[{testId,path}],GoldenBaseline 与 Aimlock 合同。policy 明确 executable(绝对真实文件)及 executableSha256、args、environment 字符串字典、timeoutMs、maxOutputBytes、requiredExitCode=0。每个测试文件必须列入 manifest 并作为真实命令参数传入;组合测试可使用受审计的入口脚本,不能把未执行文件假报为测试。执行前后校验产物、计划、可执行文件与外部授权。
116
+
117
+ 外部授权为 `validator.runner-approval/1.0`,包含 repositoryRoot、planSha256(冻结计划文件原始字节 SHA-256)、approvedBy、approvedAt、expiresAt,最长 24 小时。文件必须归执行账户所有、0600、非符号链接且位于被测工作区之外。它记录外部批准;智能体不得伪造批准或用测试 fixture 授权真实工作。
118
+
119
+ 未配置 signer 时只返回 local TestEvidence,独立终审仍为 incomplete。可选 `validator.runner-signer/1.0` 配置包含外置 privateKeyPath、keyId(Ed25519 SPKI DER SHA-256)、receiptTtlMs(最长 10 分钟);配置和私钥同样必须为外部 0600 文件。签名绑定 subject、退出结果与日志指纹,消费方使用已配置公钥核验。密钥配置不是进程隔离:同 UID 子进程可能读取同账户文件,生产可信服务仍须独立 UID/容器及权限隔离;本工具不自动部署隔离、不创建可信密钥,也不改变验证器的信任配置。
120
+
121
+ 本地运行仅支持 POSIX;无 shell、显式子进程环境、受限输出与时限。信号终止保留 exitCode=null,不制造整数退出码或成功 receipt。超时、输出超限、非零退出或运行中完整性变化均失败;原始执行记录与日志摘要可审计。此进程执行器不是 OS 沙箱,不授予任意磁盘或网络访问。
package/skill/skill.json CHANGED
@@ -5,6 +5,6 @@
5
5
  "method": "POST",
6
6
  "name": "validator",
7
7
  "type": "Skill",
8
- "version": "v7.0.32",
8
+ "version": "v7.0.34",
9
9
  "schemaVersion": "validator.skill.request/1.0"
10
10
  }
@@ -0,0 +1,158 @@
1
+ const PUBLIC_BIND_ADDRESSES = new Set(['0.0.0.0', '::', '[::]', '0:0:0:0:0:0:0:0'])
2
+ const BIND_OPTION = /^(?:host|hostname|bind|bindhost|bindaddress|listenhost|listenaddress)$/i
3
+ const BIND_METHODS = new Set(['listen', 'bind'])
4
+ const SCRIPT_EXTENSION = /\.(?:[cm]?[jt]sx?)$/i
5
+
6
+ function decodeHostLiteral(value) {
7
+ return value.replace(/\\(?:u\{([0-9a-f]+)\}|u([0-9a-f]{4})|x([0-9a-f]{2})|(.))/gi,
8
+ (match, point, unicode, byte, character) => {
9
+ const digits = point ?? unicode ?? byte
10
+ if (digits === undefined) return character
11
+ const code = parseInt(digits, 16)
12
+ return code <= 0x10ffff ? String.fromCodePoint(code) : match
13
+ })
14
+ }
15
+
16
+ function quotedToken(source, start) {
17
+ const quote = source[start]
18
+ let end = start + 1
19
+ while (end < source.length) {
20
+ if (source[end] === '\\') { end += 2; continue }
21
+ if (source[end] === quote) {
22
+ return { kind: 'literal', value: decodeHostLiteral(source.slice(start + 1, end)), start, end: end + 1 }
23
+ }
24
+ end += 1
25
+ }
26
+ return { kind: 'invalid', value: source.slice(start), start, end: source.length }
27
+ }
28
+
29
+ function templateTokens(source, start) {
30
+ const tokens = []
31
+ let index = start + 1
32
+ while (index < source.length) {
33
+ if (source[index] === '\\') { index += 2; continue }
34
+ if (source[index] === '`') {
35
+ if (tokens.length === 0) tokens.push(quotedToken(source, start))
36
+ return { tokens, end: index + 1 }
37
+ }
38
+ if (source[index] === '$' && source[index + 1] === '{') {
39
+ const expression = codeTokens(source, index + 2, true)
40
+ tokens.push(...expression.tokens)
41
+ index = expression.end
42
+ continue
43
+ }
44
+ index += 1
45
+ }
46
+ return { tokens, end: source.length }
47
+ }
48
+
49
+ function codeTokens(source, start = 0, templateExpression = false) {
50
+ const tokens = []
51
+ let depth = 0
52
+ for (let index = start; index < source.length;) {
53
+ const tail = source.slice(index)
54
+ const ignored = tail.match(/^(?:\s+|\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$))/)
55
+ if (ignored) { index += ignored[0].length; continue }
56
+ if (source[index] === '`') {
57
+ const template = templateTokens(source, index)
58
+ tokens.push(...template.tokens)
59
+ index = template.end
60
+ continue
61
+ }
62
+ if (['"', "'"].includes(source[index])) {
63
+ const token = quotedToken(source, index)
64
+ tokens.push(token)
65
+ index = token.end
66
+ continue
67
+ }
68
+ if (source[index] === '}' && templateExpression && depth === 0) return { tokens, end: index + 1 }
69
+ if (source[index] === '{') depth += 1
70
+ if (source[index] === '}') depth -= 1
71
+ const word = tail.match(/^[A-Za-z_$][\w$]*/)
72
+ const value = word === null ? source[index] : word[0]
73
+ tokens.push({ kind: word === null ? 'symbol' : 'word', value, start: index, end: index + value.length })
74
+ index += value.length
75
+ }
76
+ return { tokens, end: source.length }
77
+ }
78
+
79
+ function bindHost(token, aliases) {
80
+ if (token === undefined) return false
81
+ return token.kind === 'literal' ? PUBLIC_BIND_ADDRESSES.has(token.value)
82
+ : token.kind === 'word' && aliases.has(token.value)
83
+ }
84
+
85
+ function publicAliases(tokens) {
86
+ const aliases = new Set()
87
+ let changed = true
88
+ while (changed) {
89
+ changed = false
90
+ for (let index = 0; index < tokens.length - 2; index += 1) {
91
+ const token = tokens[index]
92
+ if (token.kind === 'word' && tokens[index + 1].value === '='
93
+ && bindHost(tokens[index + 2], aliases) && !aliases.has(token.value)) {
94
+ aliases.add(token.value)
95
+ changed = true
96
+ }
97
+ }
98
+ }
99
+ return aliases
100
+ }
101
+
102
+ function callArguments(tokens, opening) {
103
+ const args = [[]]
104
+ let depth = 0
105
+ for (let index = opening + 1; index < tokens.length; index += 1) {
106
+ const token = tokens[index]
107
+ if (token.value === ')' && depth === 0) return args
108
+ if (token.value === ',' && depth === 0) { args.push([]); continue }
109
+ args[args.length - 1].push(token)
110
+ if (['(', '[', '{'].includes(token.value)) depth += 1
111
+ if ([')', ']', '}'].includes(token.value)) depth -= 1
112
+ }
113
+ return []
114
+ }
115
+
116
+ function scriptBindSites(source) {
117
+ const { tokens } = codeTokens(source)
118
+ const aliases = publicAliases(tokens)
119
+ const sites = []
120
+ for (let index = 0; index < tokens.length; index += 1) {
121
+ const token = tokens[index]
122
+ const normalized = token.value.replaceAll('_', '').replaceAll('-', '')
123
+ if (BIND_OPTION.test(normalized) && [':', '='].includes(tokens[index + 1]?.value)
124
+ && bindHost(tokens[index + 2], aliases)) {
125
+ sites.push({ offset: token.start, context: 'bind-option', address: tokens[index + 2].value })
126
+ }
127
+ if (!BIND_METHODS.has(token.value)) continue
128
+ let opening = index + 1
129
+ if (tokens[index - 1]?.value === '[' && tokens[opening]?.value === ']') opening += 1
130
+ if (tokens[opening]?.value === '?' && tokens[opening + 1]?.value === '.') opening += 2
131
+ if (tokens[opening]?.value !== '(') continue
132
+ const args = callArguments(tokens, opening)
133
+ const hosts = token.value === 'bind' ? args.slice(0, 2).flat() : args[1]
134
+ if (hosts === undefined) continue
135
+ const publicHost = hosts.find((candidate) => bindHost(candidate, aliases))
136
+ if (publicHost) sites.push({ offset: token.start, context: `${token.value}-argument`, address: publicHost.value })
137
+ }
138
+ return sites
139
+ }
140
+
141
+ function configurationBindSites(source) {
142
+ const sites = []
143
+ const pattern = /(?:\b(?:host|hostname|bind(?:[_-]?(?:host|address))?|listen(?:[_-]?(?:host|address))?)\b["']?\s*(?:[:=]\s*|\s+)|--(?:host|bind|listen)(?:=|\s+))["']?(0\.0\.0\.0|\[::\]|::)(?=["'\s:;,]|$)/gi
144
+ for (const match of source.matchAll(pattern)) {
145
+ const lineStart = source.lastIndexOf('\n', match.index) + 1
146
+ if (/^\s*(?:#|\/\/)/.test(source.slice(lineStart, match.index))) continue
147
+ sites.push({ offset: match.index, context: 'bind-configuration', address: match[1] })
148
+ }
149
+ return sites
150
+ }
151
+
152
+ /** Inspect bind sinks and host configuration; address lists and outbound targets are not listeners. */
153
+ export function publicBindSites(path, source) {
154
+ const sites = SCRIPT_EXTENSION.test(path) ? scriptBindSites(source) : configurationBindSites(source)
155
+ return sites.map((site) => ({
156
+ ...site, line: source.slice(0, site.offset).split('\n').length,
157
+ }))
158
+ }
@@ -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
+ }