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 +12 -0
- package/broker.mjs +37 -3
- package/cli.mjs +4 -1
- package/installer.mjs +15 -5
- package/package.json +12 -2
- package/skill/SKILL.md +13 -1
- package/skill/skill.json +1 -1
- 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 +284 -0
- package/validator-runtime.mjs +498 -0
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
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|
|
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.
|
|
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
|
@@ -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
|
+
}
|