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 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 dispatchOfficialSkillCli({
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.33"
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.33
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
@@ -5,6 +5,6 @@
5
5
  "method": "POST",
6
6
  "name": "validator",
7
7
  "type": "Skill",
8
- "version": "v7.0.33",
8
+ "version": "v7.0.35",
9
9
  "schemaVersion": "validator.skill.request/1.0"
10
10
  }
@@ -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
+ }