euthyna 0.1.0 → 0.2.0

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/src/gate.js ADDED
@@ -0,0 +1,381 @@
1
+ /**
2
+ * The six-gate contract, enforced on adjudication reports.
3
+ *
4
+ * The skill's gates live in Markdown, which is discipline a model must choose
5
+ * to follow. This module is the part that does not depend on the choice: it
6
+ * reads an adjudication report and checks, mechanically, that every finding
7
+ * carries the evidence its verdict claims to have, and that the verdict is
8
+ * consistent with the gates it reports.
9
+ *
10
+ * TRUE POSITIVE requires evidence `path:L123`, a reproduce command, an
11
+ * impact statement, and every one of the six gates passing.
12
+ * FALSE POSITIVE requires at least one gate to FAIL with a reason.
13
+ * INCONCLUSIVE requires at least one gate to be not evaluated, and none
14
+ * to FAIL.
15
+ *
16
+ * A finding that fails its required shape is downgraded to an observation -
17
+ * the same rule the skill states in prose, now enforced by a process exit
18
+ * code instead of an agent's memory.
19
+ *
20
+ * `--verify` goes one step further: it re-runs each reproduce command (argv
21
+ * split, no shell; only an allowlist of tools) so a claim that "this command
22
+ * reproduces it" is actually checked rather than repeated.
23
+ */
24
+
25
+ import { execFile } from 'node:child_process';
26
+ import { promisify } from 'node:util';
27
+ import { safeTextLines } from './contract.js';
28
+
29
+ const execFileAsync = promisify(execFile);
30
+
31
+ export const VERDICTS = ['TRUE POSITIVE', 'FALSE POSITIVE', 'INCONCLUSIVE'];
32
+
33
+ /** The six gates, by the numbers the report format uses. */
34
+ export const GATE_NAMES = Object.freeze({
35
+ 1: '流程',
36
+ 2: '可达性',
37
+ 3: '真实影响',
38
+ 4: 'PoC 验证',
39
+ 5: '数学边界',
40
+ 6: '环境'
41
+ });
42
+
43
+ /**
44
+ * A finding header: `BUG #N <VERDICT> — <claim>`. The em dash is the skill's
45
+ * form, a hyphen is tolerated, and the claim may be empty (that is itself a
46
+ * violation, but it is still parsed so the report says so).
47
+ */
48
+ const HEADER = /^BUG\s+#?(\d+)\s+(TRUE POSITIVE|FALSE POSITIVE|INCONCLUSIVE)\s*(?:[—\-]\s*)?(.*)$/;
49
+
50
+ /** A line that starts like a finding but does not parse - reported, not dropped. */
51
+ const BUGLIKE = /^BUG\b/i;
52
+
53
+ const FIELDS = {
54
+ evidence: /^证据\s*[::]\s*(.+)$/,
55
+ reproduce: /^复现\s*[::]\s*(.+)$/,
56
+ impact: /^影响\s*[::]\s*(.+)$/,
57
+ exploitability: /^可利用性\s*[::]\s*(.+)$/,
58
+ gate: /^门禁\s*(\d+)\s*(?:[((]([^))]*)[))])?\s*(PASS|通过|FAIL|未评估)\s*[::]?\s*(.*)$/
59
+ };
60
+
61
+ /** `门禁全部通过。` may share its line with the evidence that follows it. */
62
+ const ALL_PASS = /^门禁全部通过[。.]?/;
63
+
64
+ /**
65
+ * Extract {file, line, commit} from an evidence string. Both `src/a.js:123`
66
+ * and the skill's documented `src/a.js:L123` form are accepted — the skill
67
+ * states the evidence form as `path:L123`, so a validator that only accepted
68
+ * the bare numeric form would downgrade every report written to spec.
69
+ */
70
+ export function parseEvidence(text) {
71
+ const value = String(text).trim();
72
+ const withLine = /^(.*?):L?(\d+)\s*(?:\(([^)]*)\))?$/.exec(value);
73
+ if (withLine) {
74
+ return { file: withLine[1], line: Number(withLine[2]), commit: withLine[3] ?? null, raw: value };
75
+ }
76
+ return { file: value, line: null, commit: null, raw: value };
77
+ }
78
+
79
+ /**
80
+ * Parse an adjudication report (the skill's 裁定格式) into findings.
81
+ * Lines before the first BUG header, and free-form body lines that match no
82
+ * field, are ignored: the discipline lives in the fields, not the prose.
83
+ *
84
+ * @returns {{ findings: object[], unparseable: string[] }}
85
+ */
86
+ export function parseGateReport(text) {
87
+ const findings = [];
88
+ const unparseable = [];
89
+ let current = null;
90
+
91
+ for (const rawLine of String(text).split(/\r?\n/)) {
92
+ const trimmed = rawLine.trim();
93
+ if (!trimmed) continue;
94
+
95
+ if (BUGLIKE.test(trimmed)) {
96
+ const header = HEADER.exec(trimmed);
97
+ if (!header) {
98
+ unparseable.push(trimmed.slice(0, 160));
99
+ continue;
100
+ }
101
+ current = {
102
+ number: Number(header[1]),
103
+ verdict: header[2],
104
+ claim: header[3] ?? '',
105
+ evidence: null,
106
+ reproduce: null,
107
+ impact: null,
108
+ exploitability: null,
109
+ allPass: false,
110
+ gates: [],
111
+ lines: []
112
+ };
113
+ findings.push(current);
114
+ continue;
115
+ }
116
+
117
+ if (!current) continue;
118
+
119
+ // One body line can carry several markers (`门禁全部通过。证据:…`), so the
120
+ // fields are consumed as prefixes in a loop. A gate line is terminal: the
121
+ // rest of it is that gate's reason, not another field.
122
+ let rest = trimmed;
123
+ while (rest) {
124
+ const gate = FIELDS.gate.exec(rest);
125
+ if (gate) {
126
+ current.gates.push({
127
+ number: Number(gate[1]),
128
+ name: gate[2] ?? '',
129
+ status: gate[3],
130
+ reason: gate[4] ?? ''
131
+ });
132
+ break;
133
+ }
134
+ const allPass = ALL_PASS.exec(rest);
135
+ if (allPass && !current.allPass) {
136
+ current.allPass = true;
137
+ rest = rest.slice(allPass[0].length);
138
+ continue;
139
+ }
140
+ if (current.evidence === null) {
141
+ const evidence = FIELDS.evidence.exec(rest);
142
+ if (evidence) {
143
+ current.evidence = parseEvidence(evidence[1]);
144
+ break;
145
+ }
146
+ }
147
+ if (current.reproduce === null) {
148
+ const reproduce = FIELDS.reproduce.exec(rest);
149
+ if (reproduce) {
150
+ current.reproduce = reproduce[1].trim();
151
+ break;
152
+ }
153
+ }
154
+ if (current.impact === null) {
155
+ const impact = FIELDS.impact.exec(rest);
156
+ if (impact) {
157
+ current.impact = impact[1].trim();
158
+ break;
159
+ }
160
+ }
161
+ if (current.exploitability === null) {
162
+ const exploitability = FIELDS.exploitability.exec(rest);
163
+ if (exploitability) {
164
+ current.exploitability = exploitability[1].trim();
165
+ break;
166
+ }
167
+ }
168
+ current.lines.push(trimmed);
169
+ break;
170
+ }
171
+ }
172
+
173
+ return { findings, unparseable };
174
+ }
175
+
176
+ /**
177
+ * Validate findings against the gate contract. Returns one result per finding:
178
+ * `violations` names every way it fails, `downgraded` is true when any fail.
179
+ * Unparseable BUG-like lines are returned separately as downgrade candidates.
180
+ */
181
+ export function validateFindings(findings) {
182
+ return findings.map((finding) => {
183
+ const violations = [];
184
+ const fails = finding.gates.filter((g) => g.status === 'FAIL');
185
+ const notEval = finding.gates.filter((g) => g.status === '未评估');
186
+ const passes = finding.gates.filter((g) => g.status === 'PASS' || g.status === '通过');
187
+
188
+ for (const gate of finding.gates) {
189
+ if (!(gate.number in GATE_NAMES)) {
190
+ violations.push(`门禁编号 ${gate.number} 不在 1..6 内`);
191
+ }
192
+ if ((gate.status === 'FAIL' || gate.status === '未评估') && !gate.reason.trim()) {
193
+ violations.push(`门禁 ${gate.number} ${gate.status} 缺少理由`);
194
+ }
195
+ }
196
+
197
+ if (!finding.claim.trim()) violations.push('缺少结论描述(BUG 标题后的说明)');
198
+
199
+ if (finding.verdict === 'TRUE POSITIVE') {
200
+ if (!finding.evidence) {
201
+ violations.push('缺少证据(TRUE POSITIVE 必须带 证据:path:L123)');
202
+ } else if (finding.evidence.line === null) {
203
+ violations.push('证据缺少行号(需要 path:L123 形式,禁止只写文件名)');
204
+ }
205
+ if (!finding.reproduce) {
206
+ violations.push('缺少复现命令(TRUE POSITIVE 必须带 复现:<命令> 或 PoC)');
207
+ }
208
+ if (!finding.impact) violations.push('缺少影响说明(门禁 3 的证据)');
209
+ const allPassed = finding.allPass || passes.length === 6;
210
+ if (!allPassed) violations.push('门禁未全部通过(需要 门禁全部通过。 或六条 PASS)');
211
+ if (fails.length) violations.push(`TRUE POSITIVE 含 FAIL 门禁(${fails.map((g) => g.number).join(', ')})——应裁定为 FALSE POSITIVE`);
212
+ if (notEval.length) violations.push(`TRUE POSITIVE 含未评估门禁(${notEval.map((g) => g.number).join(', ')})——应裁定为 INCONCLUSIVE`);
213
+ } else if (finding.verdict === 'FALSE POSITIVE') {
214
+ if (fails.length === 0) violations.push('FALSE POSITIVE 必须至少有一条 门禁 N FAIL:<具体证据>');
215
+ } else {
216
+ if (notEval.length === 0) violations.push('INCONCLUSIVE 必须至少有一条 门禁 N 未评估:<为什么>');
217
+ if (fails.length) violations.push('INCONCLUSIVE 含 FAIL 门禁——应裁定为 FALSE POSITIVE');
218
+ }
219
+
220
+ return { finding, violations, downgraded: violations.length > 0 };
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Minimal POSIX-ish word splitter, used so a reproduce command is executed as
226
+ * argv with no shell between the report and the process. Handles single and
227
+ * double quotes and backslash escapes; throws on an unterminated quote.
228
+ */
229
+ export function splitCommand(line) {
230
+ const argv = [];
231
+ let current = '';
232
+ let mode = null;
233
+ let i = 0;
234
+ while (i < line.length) {
235
+ const ch = line[i];
236
+ if (mode === 'single') {
237
+ if (ch === "'") mode = null;
238
+ else current += ch;
239
+ i++;
240
+ continue;
241
+ }
242
+ if (mode === 'double') {
243
+ if (ch === '"') mode = null;
244
+ else if (ch === '\\' && ['"', '\\', '$', '`'].includes(line[i + 1])) {
245
+ current += line[i + 1];
246
+ i += 2;
247
+ continue;
248
+ } else current += ch;
249
+ i++;
250
+ continue;
251
+ }
252
+ if (ch === "'") { mode = 'single'; i++; continue; }
253
+ if (ch === '"') { mode = 'double'; i++; continue; }
254
+ if (ch === '\\') { current += line[i + 1] ?? ''; i += 2; continue; }
255
+ if (ch === ' ' || ch === '\t') { if (current) { argv.push(current); current = ''; } i++; continue; }
256
+ current += ch;
257
+ i++;
258
+ }
259
+ if (mode) throw new Error('未闭合的引号');
260
+ if (current) argv.push(current);
261
+ return argv;
262
+ }
263
+
264
+ /**
265
+ * Tools a reproduce command may invoke under --verify, split by what they can
266
+ * do. A report is written by the agent under audit, so its reproduce commands
267
+ * are untrusted input that `--verify` would execute with the user's
268
+ * privileges; the allowlist is a blast-radius limit, not a sandbox.
269
+ *
270
+ * `git` is a reproduce tool: it reads repository history, which is what the
271
+ * facts are about. Interpreters (node, npm, python) can run arbitrary code by
272
+ * construction — `node -e`, `python -c`, an npm script — so they are NOT
273
+ * reachable by default: running one requires `--allow-exec`, which is the
274
+ * caller saying "I trust this report". Narrowing the list further would not
275
+ * close the hole (git itself can reach a pager or `-c core.…`); the honest
276
+ * boundary is the explicit consent, not the list.
277
+ */
278
+ export const SAFE_VERIFY_TOOLS = new Set(['git']);
279
+ export const INTERPRETER_VERIFY_TOOLS = new Set(['node', 'npm', 'python', 'python3']);
280
+ export const ALLOWED_VERIFY = new Set([...SAFE_VERIFY_TOOLS, ...INTERPRETER_VERIFY_TOOLS]);
281
+
282
+ /**
283
+ * Re-run one finding's reproduce command.
284
+ *
285
+ * @param {{allowInterpreters?: boolean}} [options] set from --allow-exec
286
+ * @returns {{status: 'verified'|'failed'|'refused'|'needs-consent'|'no-command'|'unparseable', tool?, exitCode?, detail?}}
287
+ */
288
+ export async function verifyFinding(
289
+ finding,
290
+ { cwd = process.cwd(), timeoutMs = 30000, allowInterpreters = false } = {}
291
+ ) {
292
+ if (!finding.reproduce) return { status: 'no-command' };
293
+ let argv;
294
+ try {
295
+ argv = splitCommand(finding.reproduce);
296
+ } catch (error) {
297
+ return { status: 'unparseable', detail: error.message };
298
+ }
299
+ if (argv.length === 0) return { status: 'no-command' };
300
+ if (INTERPRETER_VERIFY_TOOLS.has(argv[0]) && !allowInterpreters) {
301
+ // Deliberately not executed: an interpreter command from an untrusted
302
+ // report is arbitrary code, and running it is the caller's decision.
303
+ return { status: 'needs-consent', tool: argv[0] };
304
+ }
305
+ if (!ALLOWED_VERIFY.has(argv[0])) return { status: 'refused', tool: argv[0] };
306
+ try {
307
+ await execFileAsync(argv[0], argv.slice(1), { cwd, timeout: timeoutMs, env: process.env });
308
+ return { status: 'verified', tool: argv[0] };
309
+ } catch (error) {
310
+ const stderr = (error.stderr || '').toString().split('\n')[0];
311
+ return {
312
+ status: 'failed',
313
+ tool: argv[0],
314
+ exitCode: typeof error.code === 'number' ? error.code : null,
315
+ detail: stderr || error.message || ''
316
+ };
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Render the gate result for a terminal reader. Report-derived text passes the
322
+ * render boundary here exactly once, like the fact report channel.
323
+ */
324
+ export function renderGateReport(result, { write: rawWrite = console.log } = {}) {
325
+ const write = (line) => rawWrite(safeTextLines(line));
326
+ const { file, findings, unparseable, downgraded, verify } = result;
327
+
328
+ write('');
329
+ write('euthyna gate — 6 门禁契约校验(不测量,只核对报告的自我声明)');
330
+ write(`报告: ${file}`);
331
+ if (verify) {
332
+ write('⚠ --verify 以当前用户权限执行报告中的复现命令(按 argv 执行,不经过 shell)。');
333
+ write(' 默认只执行 git 命令;解释器命令(node/npm/python)需要显式 --allow-exec。');
334
+ write(' 它不是一个安全沙箱:只对你自己信任的报告使用。');
335
+ }
336
+ write('');
337
+
338
+ for (const entry of findings) {
339
+ const f = entry.finding;
340
+ const head = ` BUG #${f.number} ${f.verdict} — ${f.claim || '(无结论描述)'}`;
341
+ if (entry.downgraded) {
342
+ write(`${head} — ✗ 降级为「观察」`);
343
+ } else {
344
+ write(`${head} — ✓ 通过`);
345
+ }
346
+ if (entry.verification && f.verdict === 'TRUE POSITIVE') {
347
+ const v = entry.verification;
348
+ const mark = v.status === 'verified' ? '✓' : v.status === 'failed' ? '✗' : '·';
349
+ const how =
350
+ v.status === 'verified'
351
+ ? `(${v.tool})`
352
+ : v.status === 'failed'
353
+ ? `(exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`
354
+ : v.status === 'refused'
355
+ ? `(拒绝运行 ${v.tool} —— 不在白名单)`
356
+ : v.status === 'needs-consent'
357
+ ? `(未执行 ${v.tool} 解释器命令 —— 需要 --allow-exec)`
358
+ : v.status === 'unparseable'
359
+ ? '(命令无法拆分为 argv)'
360
+ : '(无复现命令)';
361
+ write(` 复现核验 ${mark} ${how}`);
362
+ }
363
+ for (const violation of entry.violations) {
364
+ write(` 缺: ${violation}`);
365
+ }
366
+ }
367
+
368
+ if (unparseable.length) {
369
+ write('');
370
+ write('⚠ 无法解析的 BUG 行(计入降级):');
371
+ for (const line of unparseable) write(` • ${line}`);
372
+ }
373
+
374
+ write('');
375
+ if (downgraded === 0) {
376
+ write('全部 finding 通过门禁契约。');
377
+ } else {
378
+ write(`⚠ ${downgraded} 个 finding 被降级为「观察」——缺证据、缺复现或门禁不一致,不得当作已确证的结论。`);
379
+ }
380
+ write('');
381
+ }