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
package/README.md
CHANGED
|
@@ -24,3 +24,52 @@ 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 沙箱,不授予任意磁盘或网络访问。
|
|
39
|
+
|
|
40
|
+
## Brain planning verification
|
|
41
|
+
|
|
42
|
+
IDE execution reports remain `reported` until a trusted runner verifies them. To prepare an execution plan from the server's complete report response:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
cli-validator brain prepare /absolute/trusted-workspace report-response.json > prepared-runner.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The trusted operator supplies the existing external, mode-0600 runner approval and signer configuration. The approval binds the exact `planSha256` printed by preparation. The runner input contains `reportResponse`, `approvalPath`, and `signerConfigPath`.
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
cli-validator brain run /absolute/trusted-workspace brain-runner-input.json > brain-validation.json
|
|
52
|
+
cli-aimlock brain validate /absolute/ide-workspace brain-validation.json
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Run this on a dedicated isolated runner, never on the production application host. Brain verification requires a privileged supervisor and executes the frozen checks as UID/GID 65534. The signing key, approved Node executable and runner controls remain protected and root owned. Frozen control files must be regular mode-0600 files; control directories must not be writable by the check process. Provide writable build-output directories separately when a check needs them. A Linux container supervisor needs SETUID, SETGID and KILL capabilities for identity separation and process-group cleanup. Do not mount a host Docker socket or production secrets.
|
|
56
|
+
|
|
57
|
+
The fixed adapter executes every frozen check with `shell: false` and an explicit PATH. Its source and complete check manifest are included in the signed file manifest. The supervisor bounds the whole run to five minutes and one MiB of output. A failed, timed-out, modified or incomplete run cannot become `verified`.
|
|
58
|
+
|
|
59
|
+
The API accepts `planId`, `reportDigest`, `executionPlan`, the runner's real `subject`, and `receipts`. It reconstructs and compares the member, plan, report, complete checks, files, adapter, frozen contract and policy before validating the signature. The server uses only the configured `CLITAX_VALIDATOR_RECEIPT_PUBLIC_KEY`, an Ed25519 SPKI DER public key encoded as base64. Test fixture keys must never be added to production trust.
|
|
60
|
+
|
|
61
|
+
## Isolated integration verification
|
|
62
|
+
|
|
63
|
+
The normal server test suite includes protocol, substitution, signature and unprivileged-runner rejection checks. The separate integration test runs real commands in a disposable root-to-unprivileged container:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
docker run --rm --network none --read-only --cap-drop ALL \
|
|
67
|
+
--cap-add SETUID --cap-add SETGID --cap-add KILL \
|
|
68
|
+
--security-opt no-new-privileges --pids-limit 64 --memory 256m --cpus 1 \
|
|
69
|
+
--tmpfs /tmp:rw,nosuid,size=64m \
|
|
70
|
+
--mount type=bind,source=/absolute/source,target=/code,readonly \
|
|
71
|
+
--workdir /code node:24-alpine \
|
|
72
|
+
node --test scripts/brain-validator-isolated-integration.mjs
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use a source fixture containing only the needed first-party modules, not a directory containing environment files or credentials. The fixture generates a temporary signing key inside the disposable container and verifies that the unprivileged checks cannot read it.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const BRAIN_RUNNER_ADAPTER_SOURCE: string
|
|
2
|
+
export const BRAIN_RUNNER_ADAPTER_PATH: string
|
|
3
|
+
export function brainRunnerContext(userId: string, plan: unknown, report: unknown,
|
|
4
|
+
frozenAt: string, reportDigest: string, reportedAt: string): Record<string, unknown>
|
|
5
|
+
export function assertBrainRunnerSubmission(context: unknown, executionPlan: unknown,
|
|
6
|
+
subject: unknown): Record<string, unknown>
|
|
7
|
+
export function buildBrainRunnerPlan(context: unknown, engine: unknown): Record<string, unknown>
|
|
8
|
+
export function canonicalBrainRunnerJson(value: unknown): string
|
|
9
|
+
export function brainRunnerBytesSha256(bytes: string | Buffer): string
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { basename, isAbsolute } from 'node:path'
|
|
3
|
+
import { validatorArtifactSubject, validatorReceiptSubject } from './validator-runtime.mjs'
|
|
4
|
+
|
|
5
|
+
export const BRAIN_RUNNER_CONTEXT_SCHEMA = 'brain.runner-context/1.0'
|
|
6
|
+
export const BRAIN_RUNNER_CONTRACT_SCHEMA = 'brain.frozen-runner/1.0'
|
|
7
|
+
export const BRAIN_RUNNER_ADAPTER_PATH = '.aimlock/brain-validation/check-runner.mjs'
|
|
8
|
+
export const BRAIN_RUNNER_MAX_TIMEOUT_MS = 300_000
|
|
9
|
+
export const BRAIN_RUNNER_MAX_OUTPUT_BYTES = 1_048_576
|
|
10
|
+
const BRAIN_RUNNER_OVERHEAD_MS = 5_000
|
|
11
|
+
const SHA256 = /^[0-9a-f]{64}$/
|
|
12
|
+
|
|
13
|
+
export const BRAIN_RUNNER_ADAPTER_SOURCE = String.raw`import { readFile } from 'node:fs/promises'
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { resolve } from 'node:path'
|
|
16
|
+
|
|
17
|
+
if (typeof process.getuid !== 'function' || process.getuid() !== 0) throw new Error('Trusted Brain checks require an isolated root runner with a separate unprivileged UID')
|
|
18
|
+
const path = process.argv[2]
|
|
19
|
+
if (process.argv.length !== 3 || typeof path !== 'string') throw new Error('A frozen checks manifest is required')
|
|
20
|
+
const manifest = JSON.parse(await readFile(resolve(path), 'utf8'))
|
|
21
|
+
if (manifest.schemaVersion !== 'brain.trusted-checks/1.0' || !Array.isArray(manifest.checks)
|
|
22
|
+
|| manifest.checks.length === 0 || manifest.checks.length > 16) throw new Error('Invalid frozen checks manifest')
|
|
23
|
+
if (typeof process.env.PATH !== 'string') throw new Error('An explicit executable search path is required')
|
|
24
|
+
|
|
25
|
+
function executeCheck(check) {
|
|
26
|
+
if (typeof check.id !== 'string' || typeof check.executable !== 'string'
|
|
27
|
+
|| !Array.isArray(check.args) || check.args.some((arg) => typeof arg !== 'string')
|
|
28
|
+
|| !Number.isSafeInteger(check.timeoutMs) || check.timeoutMs < 1) throw new Error('Invalid frozen check')
|
|
29
|
+
return new Promise((resolveCheck, reject) => {
|
|
30
|
+
const child = spawn(check.executable, check.args, {
|
|
31
|
+
cwd: process.cwd(), shell: false, detached: false, uid: 65534, gid: 65534,
|
|
32
|
+
env: { PATH: process.env.PATH }, stdio: ['ignore', 'inherit', 'inherit'],
|
|
33
|
+
})
|
|
34
|
+
let timedOut = false
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
timedOut = true
|
|
37
|
+
process.kill(-process.pid, 'SIGKILL')
|
|
38
|
+
}, check.timeoutMs)
|
|
39
|
+
child.once('error', (error) => { clearTimeout(timer); reject(error) })
|
|
40
|
+
child.once('close', (exitCode, signal) => {
|
|
41
|
+
clearTimeout(timer)
|
|
42
|
+
resolveCheck({ checkId: check.id, exitCode, signal, timedOut })
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const results = []
|
|
48
|
+
for (const check of manifest.checks) results.push(await executeCheck(check))
|
|
49
|
+
const complete = new Set(results.map((result) => result.checkId)).size === manifest.checks.length
|
|
50
|
+
const passed = complete && results.every((result) => result.exitCode === 0 && result.signal === null && !result.timedOut)
|
|
51
|
+
process.stdout.write(JSON.stringify({ schemaVersion: 'brain.trusted-check-results/1.0', results, passed }) + '\n')
|
|
52
|
+
process.exitCode = passed ? 0 : 1
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
export function canonicalBrainRunnerJson(value) {
|
|
56
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value)
|
|
57
|
+
if (typeof value === 'number' && Number.isFinite(value)) return JSON.stringify(value)
|
|
58
|
+
if (Array.isArray(value)) return '[' + value.map(canonicalBrainRunnerJson).join(',') + ']'
|
|
59
|
+
if (!value || typeof value !== 'object') throw new Error('Brain runner value must be JSON')
|
|
60
|
+
return '{' + Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canonicalBrainRunnerJson(value[key])).join(',') + '}'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function brainRunnerBytesSha256(bytes) {
|
|
64
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function brainRunnerContext(userId, plan, report, frozenAt, reportDigest, reportedAt) {
|
|
68
|
+
const files = [...report.files].sort((left, right) => left.path < right.path ? -1 : 1)
|
|
69
|
+
if (files.some((file) => file.path.startsWith('.aimlock/brain-validation/'))) throw new Error('Brain runner control paths are reserved')
|
|
70
|
+
return {
|
|
71
|
+
schemaVersion: BRAIN_RUNNER_CONTEXT_SCHEMA, memberId: brainRunnerBytesSha256(userId).slice(0, 32),
|
|
72
|
+
chainId: 'chn-' + report.reportId, validationRunId: 'brain-' + report.reportId,
|
|
73
|
+
planId: plan.planId, reportId: report.reportId, planDigest: validatorReceiptSubject(plan), reportDigest,
|
|
74
|
+
checks: plan.checks, files, frozenAt, reportedAt,
|
|
75
|
+
scopeContractSha256: validatorReceiptSubject(plan.contract), snapshotSha256: validatorReceiptSubject(plan.targets),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function brainRunnerManifest(context) {
|
|
80
|
+
return {
|
|
81
|
+
schemaVersion: 'brain.trusted-checks/1.0', planId: context.planId, reportId: context.reportId,
|
|
82
|
+
planDigest: context.planDigest, reportDigest: context.reportDigest, checks: context.checks,
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function brainRunnerControlFiles(context) {
|
|
87
|
+
return [
|
|
88
|
+
{ path: BRAIN_RUNNER_ADAPTER_PATH, content: BRAIN_RUNNER_ADAPTER_SOURCE },
|
|
89
|
+
{ path: '.aimlock/brain-validation/' + context.reportId + '/checks.json', content: canonicalBrainRunnerJson(brainRunnerManifest(context)) },
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildBrainRunnerPlan(context, engine) {
|
|
94
|
+
if (context.schemaVersion !== BRAIN_RUNNER_CONTEXT_SCHEMA) throw new Error('A server frozen Brain runner context is required')
|
|
95
|
+
if (typeof engine.executable !== 'string' || !isAbsolute(engine.executable) || basename(engine.executable) !== 'node'
|
|
96
|
+
|| !SHA256.test(engine.executableSha256) || typeof engine.path !== 'string' || engine.path.length === 0) {
|
|
97
|
+
throw new Error('An approved Node executable, hash, and explicit PATH are required')
|
|
98
|
+
}
|
|
99
|
+
const controls = brainRunnerControlFiles(context)
|
|
100
|
+
const manifestPath = controls[1].path
|
|
101
|
+
const files = [...context.files, ...controls.map((file) => ({ path: file.path, sha256: brainRunnerBytesSha256(file.content) }))]
|
|
102
|
+
.sort((left, right) => left.path < right.path ? -1 : 1)
|
|
103
|
+
const tests = [{ testId: 'brain-all-checks', path: manifestPath }]
|
|
104
|
+
return {
|
|
105
|
+
schemaVersion: 'validator.execution-plan/1.0', frozen: true, planId: context.planId,
|
|
106
|
+
validationRunId: context.validationRunId, memberId: context.memberId, chainId: context.chainId,
|
|
107
|
+
artifactSha256: validatorArtifactSubject(files), files, tests,
|
|
108
|
+
policy: { executable: engine.executable, executableSha256: engine.executableSha256,
|
|
109
|
+
args: [BRAIN_RUNNER_ADAPTER_PATH, manifestPath], environment: { PATH: engine.path },
|
|
110
|
+
timeoutMs: Math.min(BRAIN_RUNNER_MAX_TIMEOUT_MS,
|
|
111
|
+
context.checks.reduce((total, check) => total + check.timeoutMs, BRAIN_RUNNER_OVERHEAD_MS)),
|
|
112
|
+
maxOutputBytes: BRAIN_RUNNER_MAX_OUTPUT_BYTES, requiredExitCode: 0 },
|
|
113
|
+
goldenBaseline: { schemaVersion: 'validator.golden-baseline/1.0', baselineId: 'brain-' + context.planId,
|
|
114
|
+
source: { kind: 'approved-record', locator: 'brain-plan:' + context.planId, digestSha256: context.planDigest },
|
|
115
|
+
version: '1.0', frozen: true, frozenAt: context.frozenAt, frozenBy: 'brain-planning-server',
|
|
116
|
+
testsSha256: validatorReceiptSubject(tests) },
|
|
117
|
+
contracts: { brain: { schemaVersion: BRAIN_RUNNER_CONTRACT_SCHEMA,
|
|
118
|
+
planId: context.planId, planSha256: context.planDigest, reportId: context.reportId, reportSha256: context.reportDigest,
|
|
119
|
+
checksSha256: validatorReceiptSubject(context.checks), scopeContractSha256: context.scopeContractSha256,
|
|
120
|
+
snapshotSha256: context.snapshotSha256, adapterSha256: brainRunnerBytesSha256(BRAIN_RUNNER_ADAPTER_SOURCE) } },
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function assertBrainRunnerSubmission(context, executionPlan, subject) {
|
|
125
|
+
if (!executionPlan || typeof executionPlan !== 'object' || !executionPlan.policy || typeof executionPlan.policy !== 'object') {
|
|
126
|
+
throw new Error('A frozen runner execution plan is required')
|
|
127
|
+
}
|
|
128
|
+
const policy = executionPlan.policy
|
|
129
|
+
const expected = buildBrainRunnerPlan(context, {
|
|
130
|
+
executable: policy.executable, executableSha256: policy.executableSha256, path: policy.environment?.PATH,
|
|
131
|
+
})
|
|
132
|
+
if (canonicalBrainRunnerJson(expected) !== canonicalBrainRunnerJson(executionPlan)) {
|
|
133
|
+
throw new Error('Runner plan does not execute the complete frozen Brain checks')
|
|
134
|
+
}
|
|
135
|
+
if (!subject || typeof subject !== 'object' || typeof subject.executedAt !== 'string'
|
|
136
|
+
|| !Number.isFinite(Date.parse(subject.executedAt)) || Date.parse(subject.executedAt) < Date.parse(context.reportedAt)) {
|
|
137
|
+
throw new Error('Runner execution must follow the frozen Brain report')
|
|
138
|
+
}
|
|
139
|
+
const expectedSubject = {
|
|
140
|
+
schemaVersion: 'validator.validation-subject/1.0', artifactSha256: expected.artifactSha256,
|
|
141
|
+
memberId: expected.memberId, chainId: expected.chainId, executedAt: subject.executedAt, files: expected.files,
|
|
142
|
+
validationRunId: expected.validationRunId, planId: expected.planId, tests: expected.tests,
|
|
143
|
+
policy: { command: [policy.executable, ...policy.args].map((part) => JSON.stringify(part)).join(' '),
|
|
144
|
+
requiredExitCode: 0, executionPlanSha256: brainRunnerBytesSha256(canonicalBrainRunnerJson(expected)) },
|
|
145
|
+
goldenBaseline: expected.goldenBaseline, contracts: expected.contracts,
|
|
146
|
+
}
|
|
147
|
+
if (canonicalBrainRunnerJson(expectedSubject) !== canonicalBrainRunnerJson(subject)) {
|
|
148
|
+
throw new Error('Signed runner subject does not match the member, plan, report, artifact, and checks')
|
|
149
|
+
}
|
|
150
|
+
return expectedSubject
|
|
151
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { constants } from 'node:fs'
|
|
2
|
+
import { lstat, mkdir, open, readFile, realpath } from 'node:fs/promises'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { readRegularFile } from './validator-runner-plan.mjs'
|
|
5
|
+
import { runApprovedValidatorPlan } from './validator-local-runner.mjs'
|
|
6
|
+
import {
|
|
7
|
+
assertBrainRunnerSubmission, brainRunnerBytesSha256, brainRunnerControlFiles,
|
|
8
|
+
buildBrainRunnerPlan, canonicalBrainRunnerJson,
|
|
9
|
+
} from './brain-validator-contract.mjs'
|
|
10
|
+
|
|
11
|
+
async function checkedProjectPath(root, relativePath, createParents) {
|
|
12
|
+
if (typeof relativePath !== 'string' || relativePath.startsWith('/') || relativePath.includes('\\')
|
|
13
|
+
|| relativePath.split('/').some((part) => !part || part === '.' || part === '..')) throw new Error('Invalid runner project path')
|
|
14
|
+
let directory = root
|
|
15
|
+
const parts = relativePath.split('/')
|
|
16
|
+
for (const part of parts.slice(0, -1)) {
|
|
17
|
+
directory = resolve(directory, part)
|
|
18
|
+
if (createParents) {
|
|
19
|
+
try { await mkdir(directory, { mode: 0o700 }) }
|
|
20
|
+
catch (error) { if (!error || typeof error !== 'object' || error.code !== 'EEXIST') throw error }
|
|
21
|
+
}
|
|
22
|
+
const status = await lstat(directory)
|
|
23
|
+
if (!status.isDirectory() || status.isSymbolicLink()) throw new Error('Runner paths cannot traverse symlinks')
|
|
24
|
+
}
|
|
25
|
+
return resolve(directory, parts[parts.length - 1])
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function writeFrozenControl(root, relativePath, content) {
|
|
29
|
+
const path = await checkedProjectPath(root, relativePath, true)
|
|
30
|
+
let handle
|
|
31
|
+
try { handle = await open(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600) }
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (!error || typeof error !== 'object' || error.code !== 'EEXIST') throw error
|
|
34
|
+
const existing = await readRegularFile(path, 1_048_576, true)
|
|
35
|
+
if (existing.sha256 !== brainRunnerBytesSha256(content)) throw new Error('A frozen runner control file already has different content')
|
|
36
|
+
return path
|
|
37
|
+
}
|
|
38
|
+
try { await handle.writeFile(content) } finally { await handle.close() }
|
|
39
|
+
return path
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function prepareBrainValidation(repositoryRoot, reportResponse, environment = process.env) {
|
|
43
|
+
if (reportResponse.status !== 'reported' || !reportResponse.report || reportResponse.report.checksPassed !== true
|
|
44
|
+
|| !reportResponse.report.validationContext) throw new Error('A complete successful Brain report is required')
|
|
45
|
+
if (typeof environment.PATH !== 'string' || !environment.PATH) throw new Error('An explicit PATH is required')
|
|
46
|
+
const rootStatus = await lstat(repositoryRoot)
|
|
47
|
+
if (!rootStatus.isDirectory() || rootStatus.isSymbolicLink()) throw new Error('Runner workspace must be a real directory')
|
|
48
|
+
const root = await realpath(repositoryRoot)
|
|
49
|
+
const context = reportResponse.report.validationContext
|
|
50
|
+
if (context.planId !== reportResponse.planId || context.reportDigest !== reportResponse.reportDigest) {
|
|
51
|
+
throw new Error('Brain report identity does not match its runner context')
|
|
52
|
+
}
|
|
53
|
+
for (const file of context.files) {
|
|
54
|
+
const actual = await readRegularFile(await checkedProjectPath(root, file.path, false), 16_777_216, false)
|
|
55
|
+
if (actual.sha256 !== file.sha256) throw new Error('Reported project file changed before validation: ' + file.path)
|
|
56
|
+
}
|
|
57
|
+
for (const file of brainRunnerControlFiles(context)) await writeFrozenControl(root, file.path, file.content)
|
|
58
|
+
const executable = await realpath(process.execPath)
|
|
59
|
+
const executionPlan = buildBrainRunnerPlan(context, {
|
|
60
|
+
executable, executableSha256: brainRunnerBytesSha256(await readFile(executable)), path: environment.PATH,
|
|
61
|
+
})
|
|
62
|
+
const planPath = '.aimlock/brain-validation/' + context.reportId + '/execution-plan.json'
|
|
63
|
+
const planContent = canonicalBrainRunnerJson(executionPlan)
|
|
64
|
+
await writeFrozenControl(root, planPath, planContent)
|
|
65
|
+
return { repositoryRoot: root, planPath, executionPlan, planSha256: brainRunnerBytesSha256(planContent), context }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runBrainApprovedValidation(repositoryRoot, input, environment = process.env) {
|
|
69
|
+
if (typeof input.approvalPath !== 'string' || typeof input.signerConfigPath !== 'string') {
|
|
70
|
+
throw new Error('An external approved runner authority and signer configuration are required')
|
|
71
|
+
}
|
|
72
|
+
const prepared = await prepareBrainValidation(repositoryRoot, input.reportResponse, environment)
|
|
73
|
+
const execution = await runApprovedValidatorPlan({
|
|
74
|
+
repositoryRoot: prepared.repositoryRoot, planPath: prepared.planPath,
|
|
75
|
+
approvalPath: input.approvalPath, signerConfigPath: input.signerConfigPath,
|
|
76
|
+
})
|
|
77
|
+
if (execution.status !== 'succeeded' || !execution.receipt) throw new Error('All Brain checks require a successful trusted runner receipt')
|
|
78
|
+
const subject = assertBrainRunnerSubmission(prepared.context, prepared.executionPlan, execution.subject)
|
|
79
|
+
return { planId: prepared.context.planId, reportDigest: prepared.context.reportDigest,
|
|
80
|
+
executionPlan: prepared.executionPlan, subject, receipts: [execution.receipt] }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function runBrainValidatorCli(args) {
|
|
84
|
+
if (args.length !== 3 || !['prepare', 'run'].includes(args[0])) {
|
|
85
|
+
throw new Error('Usage: cli-validator brain <prepare|run> <repositoryRoot> <inputJsonFile>')
|
|
86
|
+
}
|
|
87
|
+
const inputFile = await readRegularFile(resolve(args[2]), 256_000, false)
|
|
88
|
+
const input = JSON.parse(inputFile.bytes.toString('utf8'))
|
|
89
|
+
const output = args[0] === 'prepare' ? await prepareBrainValidation(resolve(args[1]), input)
|
|
90
|
+
: await runBrainApprovedValidation(resolve(args[1]), input)
|
|
91
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n')
|
|
92
|
+
}
|
package/cli.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { runBrainValidatorCli } from './brain-validator-plan.mjs'
|
|
2
3
|
import { dirname } from 'node:path'
|
|
3
4
|
import { fileURLToPath } from 'node:url'
|
|
4
5
|
import { dispatchOfficialSkillCli, runIntakeHandshake } from './installer.mjs'
|
|
6
|
+
import { runValidatorLocalCli } from './validator-local-cli.mjs'
|
|
5
7
|
|
|
6
8
|
const INTAKE_QUESTIONS = [
|
|
7
9
|
{ id: 'goal', prompt: 'What is being validated? Describe the deliverable and expected behavior.', required: true, example: '电商运营仪表盘计算工具:输入访客数/订单数/GMV/广告费,输出转化率/客单价/ROAS。' },
|
|
@@ -10,8 +12,11 @@ const INTAKE_QUESTIONS = [
|
|
|
10
12
|
{ id: 'targetFiles', prompt: 'Files to validate (or "auto" to scan all).', required: false, example: 'auto' },
|
|
11
13
|
]
|
|
12
14
|
|
|
13
|
-
await
|
|
15
|
+
if (process.argv[2] === 'brain') await runBrainValidatorCli(process.argv.slice(3))
|
|
16
|
+
else if (process.argv[2] === 'local') await runValidatorLocalCli(process.argv.slice(3))
|
|
17
|
+
else await dispatchOfficialSkillCli({
|
|
14
18
|
packageRoot: dirname(fileURLToPath(import.meta.url)),
|
|
19
|
+
extraUsageLines: [' cli-validator brain prepare <repositoryRoot> <reportResponse.json>', ' cli-validator brain run <repositoryRoot> <runnerInput.json>', ' cli-validator local capabilities', ' cli-validator local run-approved-plan < runner-input.json'],
|
|
15
20
|
runCommand: (context) => runIntakeHandshake(context, {
|
|
16
21
|
questions: INTAKE_QUESTIONS,
|
|
17
22
|
outputFile: 'VALIDATOR-REQUIREMENTS.json',
|
package/package.json
CHANGED
|
@@ -3,13 +3,29 @@
|
|
|
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
|
+
"./brain-contract": "./brain-validator-contract.mjs",
|
|
11
|
+
"./brain-runner": "./brain-validator-plan.mjs"
|
|
12
|
+
},
|
|
6
13
|
"files": [
|
|
7
14
|
"cli.mjs",
|
|
8
15
|
"installer.mjs",
|
|
9
16
|
"broker.mjs",
|
|
10
17
|
"README.md",
|
|
11
18
|
"skill/SKILL.md",
|
|
12
|
-
"skill/skill.json"
|
|
19
|
+
"skill/skill.json",
|
|
20
|
+
"validator-runner-plan.mjs",
|
|
21
|
+
"validator-local-runner.mjs",
|
|
22
|
+
"validator-local-cli.mjs",
|
|
23
|
+
"validator-runtime.mjs",
|
|
24
|
+
"validator-compliance.mjs",
|
|
25
|
+
"brain-validator-contract.mjs",
|
|
26
|
+
"brain-validator-contract.d.mts",
|
|
27
|
+
"brain-validator-plan.mjs",
|
|
28
|
+
"validator-brain-contract.mjs"
|
|
13
29
|
],
|
|
14
30
|
"license": "UNLICENSED",
|
|
15
31
|
"name": "cli-validator",
|
|
@@ -18,5 +34,5 @@
|
|
|
18
34
|
"url": "https://github.com/88208555/Validator-clitax.git"
|
|
19
35
|
},
|
|
20
36
|
"type": "module",
|
|
21
|
-
"version": "7.0.
|
|
37
|
+
"version": "7.0.35"
|
|
22
38
|
}
|
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.35
|
|
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,47 @@
|
|
|
1
|
+
const SHA_PATTERN = '^[0-9a-f]{64}$'
|
|
2
|
+
const UUID_PATTERN = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
|
3
|
+
const SHA = new RegExp(SHA_PATTERN)
|
|
4
|
+
const UUID = new RegExp(UUID_PATTERN)
|
|
5
|
+
const FIELDS = ['schemaVersion', 'planId', 'planSha256', 'reportId', 'reportSha256',
|
|
6
|
+
'checksSha256', 'scopeContractSha256', 'snapshotSha256', 'adapterSha256']
|
|
7
|
+
|
|
8
|
+
export const brainRunnerContractSchema = {
|
|
9
|
+
type: 'object', additionalProperties: false, required: FIELDS,
|
|
10
|
+
properties: {
|
|
11
|
+
schemaVersion: { const: 'brain.frozen-runner/1.0' },
|
|
12
|
+
planId: { type: 'string', pattern: UUID_PATTERN }, reportId: { type: 'string', pattern: UUID_PATTERN },
|
|
13
|
+
...Object.fromEntries(FIELDS.filter((field) => field.endsWith('Sha256'))
|
|
14
|
+
.map((field) => [field, { type: 'string', pattern: SHA_PATTERN }])),
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validBrainRunnerContract(value, subject) {
|
|
19
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
20
|
+
|| Object.keys(value).sort().join('|') !== [...FIELDS].sort().join('|')
|
|
21
|
+
|| value.schemaVersion !== 'brain.frozen-runner/1.0'
|
|
22
|
+
|| !UUID.test(value.planId) || !UUID.test(value.reportId)
|
|
23
|
+
|| value.planId !== subject.planId || subject.chainId !== 'chn-' + value.reportId
|
|
24
|
+
|| subject.validationRunId !== 'brain-' + value.reportId) return false
|
|
25
|
+
return FIELDS.filter((field) => field.endsWith('Sha256')).every((field) => typeof value[field] === 'string' && SHA.test(value[field]))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validateBridgeContracts(value, entityRef, hardened, utilities) {
|
|
29
|
+
const { isObj, text, idRegex, shaRegex, finding } = utilities
|
|
30
|
+
const findings = []
|
|
31
|
+
if (hardened && (!isObj(value.contracts) || (!isObj(value.contracts.aimlock)
|
|
32
|
+
&& !validBrainRunnerContract(value.contracts.brain, value)))) findings.push(finding("P0", "VALIDATION-SUBJECT-SNAPSHOT", `${entityRef}.contracts`, "Hardened validation requires an Aimlock snapshot or server frozen Brain contract binding"));
|
|
33
|
+
if (value.contracts?.brain !== undefined && !validBrainRunnerContract(value.contracts.brain, value)) findings.push(finding("P0", "VALIDATION-SUBJECT-BRAIN", `${entityRef}.contracts.brain`, "Brain runner contract is invalid or bound to another execution"));
|
|
34
|
+
if (value.contracts !== undefined && (!isObj(value.contracts)
|
|
35
|
+
|| (value.contracts.aimlock !== undefined && (!isObj(value.contracts.aimlock)
|
|
36
|
+
|| !idRegex.test(text(value.contracts.aimlock.goalId))
|
|
37
|
+
|| !shaRegex.test(text(value.contracts.aimlock.scopeContractSha256))
|
|
38
|
+
|| !shaRegex.test(text(value.contracts.aimlock.snapshotSha256))))
|
|
39
|
+
|| (value.contracts.blueprint !== undefined && (!isObj(value.contracts.blueprint)
|
|
40
|
+
|| !idRegex.test(text(value.contracts.blueprint.blueprintId))
|
|
41
|
+
|| !shaRegex.test(text(value.contracts.blueprint.acceptanceReportSha256))))
|
|
42
|
+
|| (value.contracts.archguard !== undefined && (!isObj(value.contracts.archguard)
|
|
43
|
+
|| !shaRegex.test(text(value.contracts.archguard.contractSha256))
|
|
44
|
+
|| !shaRegex.test(text(value.contracts.archguard.ledgerSha256))
|
|
45
|
+
|| !["green", "yellow", "red"].includes(value.contracts.archguard.driftStatus))))) findings.push(finding("P0", "VALIDATION-SUBJECT-CONTRACTS", `${entityRef}.contracts`, "Aimlock, Blueprint, and ArchGuard bridge contracts require stable ids, SHA-256 digests, and a valid drift status"));
|
|
46
|
+
return findings
|
|
47
|
+
}
|
|
@@ -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
|
+
}
|