euthyna 0.1.1 → 0.3.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/.agents/skills/euthyna/SKILL.md +256 -0
- package/.agents/skills/euthyna/references/bug-classes.md +130 -0
- package/.agents/skills/euthyna/references/change-audit.md +219 -0
- package/.agents/skills/euthyna/references/dependency-audit.md +116 -0
- package/.agents/skills/euthyna/references/fact-contract.md +129 -0
- package/.agents/skills/euthyna/references/fact-producers.md +274 -0
- package/.agents/skills/euthyna/references/meta-mechanisms.md +173 -0
- package/.agents/skills/euthyna/references/verification-gates.md +293 -0
- package/.agents/skills/euthyna-en/SKILL.md +285 -0
- package/.agents/skills/euthyna-en/references/bug-classes.md +130 -0
- package/.agents/skills/euthyna-en/references/change-audit.md +212 -0
- package/.agents/skills/euthyna-en/references/dependency-audit.md +116 -0
- package/.agents/skills/euthyna-en/references/fact-contract.md +129 -0
- package/.agents/skills/euthyna-en/references/fact-producers.md +258 -0
- package/.agents/skills/euthyna-en/references/meta-mechanisms.md +174 -0
- package/.agents/skills/euthyna-en/references/verification-gates.md +291 -0
- package/.claude/commands/euthyna.md +18 -0
- package/.opencode/commands/euthyna.md +8 -0
- package/LICENSE +202 -202
- package/README.md +55 -8
- package/__init__.py +91 -0
- package/bin/euthyna.js +13 -3
- package/cordis.patch.yml +4 -0
- package/package.json +28 -3
- package/plugin/index.js +89 -0
- package/plugin.yaml +8 -0
- package/src/cli.js +189 -41
- package/src/contract.js +20 -13
- package/src/facts/coverage.js +69 -23
- package/src/facts/deps.js +81 -24
- package/src/facts/history.js +89 -25
- package/src/gate.js +171 -43
- package/src/lang.js +55 -0
package/src/gate.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { execFile } from 'node:child_process';
|
|
26
26
|
import { promisify } from 'node:util';
|
|
27
27
|
import { safeTextLines } from './contract.js';
|
|
28
|
+
import { T, DEFAULT_LANG } from './lang.js';
|
|
28
29
|
|
|
29
30
|
const execFileAsync = promisify(execFile);
|
|
30
31
|
|
|
@@ -40,6 +41,30 @@ export const GATE_NAMES = Object.freeze({
|
|
|
40
41
|
6: '环境'
|
|
41
42
|
});
|
|
42
43
|
|
|
44
|
+
/** The gate statuses as written in a report, in either language. */
|
|
45
|
+
const GATE_STATUSES = Object.freeze({
|
|
46
|
+
PASS: 'PASS',
|
|
47
|
+
FAIL: 'FAIL',
|
|
48
|
+
PASS_ZH: '通过',
|
|
49
|
+
NOT_EVALUATED_ZH: '未评估',
|
|
50
|
+
NOT_EVALUATED_EN: 'NOT EVALUATED'
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/** True when a parsed status means "the gate passed". */
|
|
54
|
+
export function isPassStatus(status) {
|
|
55
|
+
return status === GATE_STATUSES.PASS || status === GATE_STATUSES.PASS_ZH;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when a parsed status means "the gate failed". */
|
|
59
|
+
export function isFailStatus(status) {
|
|
60
|
+
return status === GATE_STATUSES.FAIL;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when a parsed status means "the gate could not be evaluated". */
|
|
64
|
+
export function isNotEvaluatedStatus(status) {
|
|
65
|
+
return status === GATE_STATUSES.NOT_EVALUATED_ZH || /^not evaluated$/i.test(status);
|
|
66
|
+
}
|
|
67
|
+
|
|
43
68
|
/**
|
|
44
69
|
* A finding header: `BUG #N <VERDICT> — <claim>`. The em dash is the skill's
|
|
45
70
|
* form, a hyphen is tolerated, and the claim may be empty (that is itself a
|
|
@@ -50,16 +75,23 @@ const HEADER = /^BUG\s+#?(\d+)\s+(TRUE POSITIVE|FALSE POSITIVE|INCONCLUSIVE)\s*(
|
|
|
50
75
|
/** A line that starts like a finding but does not parse - reported, not dropped. */
|
|
51
76
|
const BUGLIKE = /^BUG\b/i;
|
|
52
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Field markers, accepted in either language. The parser is bilingual on
|
|
80
|
+
* purpose and independent of the output language: a report is data, and a
|
|
81
|
+
* Chinese-written report must validate under an English session and vice
|
|
82
|
+
* versa. The English keywords are case-insensitive; the Chinese ones are
|
|
83
|
+
* unaffected by the flag.
|
|
84
|
+
*/
|
|
53
85
|
const FIELDS = {
|
|
54
|
-
evidence:
|
|
55
|
-
reproduce:
|
|
56
|
-
impact:
|
|
57
|
-
exploitability:
|
|
58
|
-
gate:
|
|
86
|
+
evidence: /^(?:证据|Evidence)\s*[::]\s*(.+)$/i,
|
|
87
|
+
reproduce: /^(?:复现|Reproduce)\s*[::]\s*(.+)$/i,
|
|
88
|
+
impact: /^(?:影响|Impact)\s*[::]\s*(.+)$/i,
|
|
89
|
+
exploitability: /^(?:可利用性|Exploitability)\s*[::]\s*(.+)$/i,
|
|
90
|
+
gate: /^(?:门禁|Gate)\s*(\d+)\s*(?:[((]([^))]*)[))])?\s*(PASS|通过|FAIL|未评估|NOT EVALUATED|Not evaluated)\s*[::]?\s*(.*)$/i
|
|
59
91
|
};
|
|
60
92
|
|
|
61
|
-
/** `门禁全部通过。` may share its line with the evidence that follows it. */
|
|
62
|
-
const ALL_PASS =
|
|
93
|
+
/** `门禁全部通过。` / `All gates passed.` may share its line with the evidence that follows it. */
|
|
94
|
+
const ALL_PASS = /^(?:门禁全部通过|All gates passed)[。.]?\s*/i;
|
|
63
95
|
|
|
64
96
|
/**
|
|
65
97
|
* Extract {file, line, commit} from an evidence string. Both `src/a.js:123`
|
|
@@ -178,43 +210,113 @@ export function parseGateReport(text) {
|
|
|
178
210
|
* `violations` names every way it fails, `downgraded` is true when any fail.
|
|
179
211
|
* Unparseable BUG-like lines are returned separately as downgrade candidates.
|
|
180
212
|
*/
|
|
181
|
-
export function validateFindings(findings) {
|
|
213
|
+
export function validateFindings(findings, { lang = DEFAULT_LANG } = {}) {
|
|
214
|
+
const t = T(lang);
|
|
182
215
|
return findings.map((finding) => {
|
|
183
216
|
const violations = [];
|
|
184
|
-
const fails = finding.gates.filter((g) => g.status
|
|
185
|
-
const notEval = finding.gates.filter((g) => g.status
|
|
186
|
-
const passes = finding.gates.filter((g) => g.status
|
|
217
|
+
const fails = finding.gates.filter((g) => isFailStatus(g.status));
|
|
218
|
+
const notEval = finding.gates.filter((g) => isNotEvaluatedStatus(g.status));
|
|
219
|
+
const passes = finding.gates.filter((g) => isPassStatus(g.status));
|
|
187
220
|
|
|
188
221
|
for (const gate of finding.gates) {
|
|
189
222
|
if (!(gate.number in GATE_NAMES)) {
|
|
190
|
-
violations.push(
|
|
223
|
+
violations.push(
|
|
224
|
+
t(`门禁编号 ${gate.number} 不在 1..6 内`, `gate number ${gate.number} is not in 1..6`)
|
|
225
|
+
);
|
|
191
226
|
}
|
|
192
|
-
if ((gate.status
|
|
193
|
-
violations.push(
|
|
227
|
+
if ((isFailStatus(gate.status) || isNotEvaluatedStatus(gate.status)) && !gate.reason.trim()) {
|
|
228
|
+
violations.push(
|
|
229
|
+
t(`门禁 ${gate.number} ${gate.status} 缺少理由`, `gate ${gate.number} ${gate.status} lacks a reason`)
|
|
230
|
+
);
|
|
194
231
|
}
|
|
195
232
|
}
|
|
196
233
|
|
|
197
|
-
if (!finding.claim.trim())
|
|
234
|
+
if (!finding.claim.trim()) {
|
|
235
|
+
violations.push(
|
|
236
|
+
t('缺少结论描述(BUG 标题后的说明)', 'missing claim description (the note after the BUG header)')
|
|
237
|
+
);
|
|
238
|
+
}
|
|
198
239
|
|
|
199
240
|
if (finding.verdict === 'TRUE POSITIVE') {
|
|
200
241
|
if (!finding.evidence) {
|
|
201
|
-
violations.push(
|
|
242
|
+
violations.push(
|
|
243
|
+
t(
|
|
244
|
+
'缺少证据(TRUE POSITIVE 必须带 证据:path:L123)',
|
|
245
|
+
'missing evidence (a TRUE POSITIVE must carry Evidence: path:L123)'
|
|
246
|
+
)
|
|
247
|
+
);
|
|
202
248
|
} else if (finding.evidence.line === null) {
|
|
203
|
-
violations.push(
|
|
249
|
+
violations.push(
|
|
250
|
+
t(
|
|
251
|
+
'证据缺少行号(需要 path:L123 形式,禁止只写文件名)',
|
|
252
|
+
'evidence lacks a line number (path:L123 form required; a bare file name is not allowed)'
|
|
253
|
+
)
|
|
254
|
+
);
|
|
204
255
|
}
|
|
205
256
|
if (!finding.reproduce) {
|
|
206
|
-
violations.push(
|
|
257
|
+
violations.push(
|
|
258
|
+
t(
|
|
259
|
+
'缺少复现命令(TRUE POSITIVE 必须带 复现:<命令> 或 PoC)',
|
|
260
|
+
'missing reproduce command (a TRUE POSITIVE must carry Reproduce: <command> or a PoC)'
|
|
261
|
+
)
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (!finding.impact) {
|
|
265
|
+
violations.push(
|
|
266
|
+
t('缺少影响说明(门禁 3 的证据)', 'missing impact statement (the evidence for gate 3)')
|
|
267
|
+
);
|
|
207
268
|
}
|
|
208
|
-
if (!finding.impact) violations.push('缺少影响说明(门禁 3 的证据)');
|
|
209
269
|
const allPassed = finding.allPass || passes.length === 6;
|
|
210
|
-
if (!allPassed)
|
|
211
|
-
|
|
212
|
-
|
|
270
|
+
if (!allPassed) {
|
|
271
|
+
violations.push(
|
|
272
|
+
t(
|
|
273
|
+
'门禁未全部通过(需要 门禁全部通过。 或六条 PASS)',
|
|
274
|
+
'not all gates pass (requires "All gates passed." or six PASS lines)'
|
|
275
|
+
)
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (fails.length) {
|
|
279
|
+
violations.push(
|
|
280
|
+
t(
|
|
281
|
+
`TRUE POSITIVE 含 FAIL 门禁(${fails.map((g) => g.number).join(', ')})——应裁定为 FALSE POSITIVE`,
|
|
282
|
+
`TRUE POSITIVE contains FAIL gates (${fails.map((g) => g.number).join(', ')}) — should be ruled FALSE POSITIVE`
|
|
283
|
+
)
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (notEval.length) {
|
|
287
|
+
violations.push(
|
|
288
|
+
t(
|
|
289
|
+
`TRUE POSITIVE 含未评估门禁(${notEval.map((g) => g.number).join(', ')})——应裁定为 INCONCLUSIVE`,
|
|
290
|
+
`TRUE POSITIVE contains not-evaluated gates (${notEval.map((g) => g.number).join(', ')}) — should be ruled INCONCLUSIVE`
|
|
291
|
+
)
|
|
292
|
+
);
|
|
293
|
+
}
|
|
213
294
|
} else if (finding.verdict === 'FALSE POSITIVE') {
|
|
214
|
-
if (fails.length === 0)
|
|
295
|
+
if (fails.length === 0) {
|
|
296
|
+
violations.push(
|
|
297
|
+
t(
|
|
298
|
+
'FALSE POSITIVE 必须至少有一条 门禁 N FAIL:<具体证据>',
|
|
299
|
+
'a FALSE POSITIVE must have at least one Gate N FAIL: <specific evidence>'
|
|
300
|
+
)
|
|
301
|
+
);
|
|
302
|
+
}
|
|
215
303
|
} else {
|
|
216
|
-
if (notEval.length === 0)
|
|
217
|
-
|
|
304
|
+
if (notEval.length === 0) {
|
|
305
|
+
violations.push(
|
|
306
|
+
t(
|
|
307
|
+
'INCONCLUSIVE 必须至少有一条 门禁 N 未评估:<为什么>',
|
|
308
|
+
'an INCONCLUSIVE must have at least one Gate N NOT EVALUATED: <why>'
|
|
309
|
+
)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (fails.length) {
|
|
313
|
+
violations.push(
|
|
314
|
+
t(
|
|
315
|
+
'INCONCLUSIVE 含 FAIL 门禁——应裁定为 FALSE POSITIVE',
|
|
316
|
+
'INCONCLUSIVE contains FAIL gates — should be ruled FALSE POSITIVE'
|
|
317
|
+
)
|
|
318
|
+
);
|
|
319
|
+
}
|
|
218
320
|
}
|
|
219
321
|
|
|
220
322
|
return { finding, violations, downgraded: violations.length > 0 };
|
|
@@ -321,27 +423,48 @@ export async function verifyFinding(
|
|
|
321
423
|
* Render the gate result for a terminal reader. Report-derived text passes the
|
|
322
424
|
* render boundary here exactly once, like the fact report channel.
|
|
323
425
|
*/
|
|
324
|
-
export function renderGateReport(result, { write: rawWrite = console.log } = {}) {
|
|
426
|
+
export function renderGateReport(result, { write: rawWrite = console.log, lang = DEFAULT_LANG } = {}) {
|
|
427
|
+
const t = T(lang);
|
|
325
428
|
const write = (line) => rawWrite(safeTextLines(line));
|
|
326
429
|
const { file, findings, unparseable, downgraded, verify } = result;
|
|
327
430
|
|
|
328
431
|
write('');
|
|
329
|
-
write(
|
|
330
|
-
|
|
432
|
+
write(
|
|
433
|
+
t(
|
|
434
|
+
'euthyna gate — 6 门禁契约校验(不测量,只核对报告的自我声明)',
|
|
435
|
+
'euthyna gate — six-gate contract check (no measurement; only the report\'s own claims are checked)'
|
|
436
|
+
)
|
|
437
|
+
);
|
|
438
|
+
write(`${t('报告: ', 'Report: ')}${file}`);
|
|
331
439
|
if (verify) {
|
|
332
|
-
write(
|
|
333
|
-
|
|
334
|
-
|
|
440
|
+
write(
|
|
441
|
+
t(
|
|
442
|
+
'⚠ --verify 以当前用户权限执行报告中的复现命令(按 argv 执行,不经过 shell)。',
|
|
443
|
+
'⚠ --verify runs the report\'s reproduce commands with your privileges (as argv, no shell).'
|
|
444
|
+
)
|
|
445
|
+
);
|
|
446
|
+
write(
|
|
447
|
+
t(
|
|
448
|
+
' 默认只执行 git 命令;解释器命令(node/npm/python)需要显式 --allow-exec。',
|
|
449
|
+
' Only git commands run by default; interpreter commands (node/npm/python) need explicit --allow-exec.'
|
|
450
|
+
)
|
|
451
|
+
);
|
|
452
|
+
write(
|
|
453
|
+
t(
|
|
454
|
+
' 它不是一个安全沙箱:只对你自己信任的报告使用。',
|
|
455
|
+
' It is not a sandbox: use it only on reports you trust.'
|
|
456
|
+
)
|
|
457
|
+
);
|
|
335
458
|
}
|
|
336
459
|
write('');
|
|
337
460
|
|
|
338
461
|
for (const entry of findings) {
|
|
339
462
|
const f = entry.finding;
|
|
340
|
-
const head = ` BUG #${f.number} ${f.verdict} — ${f.claim || '(无结论描述)'}`;
|
|
463
|
+
const head = ` BUG #${f.number} ${f.verdict} — ${f.claim || t('(无结论描述)', '(no claim)')}`;
|
|
341
464
|
if (entry.downgraded) {
|
|
342
|
-
write(`${head} — ✗
|
|
465
|
+
write(`${head} — ✗ ${t('降级为「观察」', 'downgraded to "observation"')}`);
|
|
343
466
|
} else {
|
|
344
|
-
write(`${head} — ✓
|
|
467
|
+
write(`${head} — ✓ ${t('通过', 'passed')}`);
|
|
345
468
|
}
|
|
346
469
|
if (entry.verification && f.verdict === 'TRUE POSITIVE') {
|
|
347
470
|
const v = entry.verification;
|
|
@@ -352,30 +475,35 @@ export function renderGateReport(result, { write: rawWrite = console.log } = {})
|
|
|
352
475
|
: v.status === 'failed'
|
|
353
476
|
? `(exit ${v.exitCode ?? '?'}${v.detail ? `: ${v.detail}` : ''})`
|
|
354
477
|
: v.status === 'refused'
|
|
355
|
-
? `(拒绝运行 ${v.tool} —— 不在白名单)`
|
|
478
|
+
? t(`(拒绝运行 ${v.tool} —— 不在白名单)`, `(refused to run ${v.tool} — not on the allowlist)`)
|
|
356
479
|
: v.status === 'needs-consent'
|
|
357
|
-
? `(未执行 ${v.tool} 解释器命令 —— 需要 --allow-exec)`
|
|
480
|
+
? t(`(未执行 ${v.tool} 解释器命令 —— 需要 --allow-exec)`, `(interpreter command ${v.tool} not run — needs --allow-exec)`)
|
|
358
481
|
: v.status === 'unparseable'
|
|
359
|
-
? '(命令无法拆分为 argv)'
|
|
360
|
-
: '(无复现命令)';
|
|
361
|
-
write(
|
|
482
|
+
? t('(命令无法拆分为 argv)', '(command could not be split into argv)')
|
|
483
|
+
: t('(无复现命令)', '(no reproduce command)');
|
|
484
|
+
write(`${t(' 复现核验 ', ' Reproduce check ')}${mark} ${how}`);
|
|
362
485
|
}
|
|
363
486
|
for (const violation of entry.violations) {
|
|
364
|
-
write(
|
|
487
|
+
write(`${t(' 缺: ', ' missing: ')}${violation}`);
|
|
365
488
|
}
|
|
366
489
|
}
|
|
367
490
|
|
|
368
491
|
if (unparseable.length) {
|
|
369
492
|
write('');
|
|
370
|
-
write('⚠ 无法解析的 BUG 行(计入降级):');
|
|
493
|
+
write(t('⚠ 无法解析的 BUG 行(计入降级):', '⚠ Unparseable BUG lines (counted as downgrades):'));
|
|
371
494
|
for (const line of unparseable) write(` • ${line}`);
|
|
372
495
|
}
|
|
373
496
|
|
|
374
497
|
write('');
|
|
375
498
|
if (downgraded === 0) {
|
|
376
|
-
write('全部 finding 通过门禁契约。');
|
|
499
|
+
write(t('全部 finding 通过门禁契约。', 'All findings pass the gate contract.'));
|
|
377
500
|
} else {
|
|
378
|
-
write(
|
|
501
|
+
write(
|
|
502
|
+
t(
|
|
503
|
+
`⚠ ${downgraded} 个 finding 被降级为「观察」——缺证据、缺复现或门禁不一致,不得当作已确证的结论。`,
|
|
504
|
+
`⚠ ${downgraded} finding${downgraded === 1 ? '' : 's'} downgraded to "observation" — missing evidence, missing reproduction, or inconsistent gates; must not be read as established findings.`
|
|
505
|
+
)
|
|
506
|
+
);
|
|
379
507
|
}
|
|
380
508
|
write('');
|
|
381
509
|
}
|
package/src/lang.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language selection for the CLI (i18n).
|
|
3
|
+
*
|
|
4
|
+
* Zero-dependency on purpose, like everything else in this project: no i18n
|
|
5
|
+
* framework is pulled in. The CLI currently ships two languages, chosen by an
|
|
6
|
+
* explicit switch rather than by locale sniffing, because a locale-derived
|
|
7
|
+
* answer can surprise ("I asked for English and got Chinese") and is hard to
|
|
8
|
+
* reproduce in CI.
|
|
9
|
+
*
|
|
10
|
+
* Precedence: an explicit `--lang` flag beats the EUTHYNA_LANG environment
|
|
11
|
+
* variable, which beats the default (Chinese). The default stays Chinese so a
|
|
12
|
+
* user who never asks for English sees exactly the output they saw before.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const LANGS = Object.freeze(['zh', 'en']);
|
|
16
|
+
export const DEFAULT_LANG = 'zh';
|
|
17
|
+
|
|
18
|
+
/** True for a value the CLI accepts as a language. */
|
|
19
|
+
export function isLang(value) {
|
|
20
|
+
return value === 'zh' || value === 'en';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the effective language.
|
|
25
|
+
*
|
|
26
|
+
* Precedence: an explicit flag wins, then the environment variable, then the
|
|
27
|
+
* default. cli.js validates the flag value before calling this, so a non-zh/en
|
|
28
|
+
* flag never reaches here.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} [options]
|
|
31
|
+
* @param {string|boolean} [options.flag] the value of --lang (true when the
|
|
32
|
+
* flag was passed without a value)
|
|
33
|
+
* @param {object} [options.env] defaults to process.env
|
|
34
|
+
* @returns {'zh'|'en'}
|
|
35
|
+
*/
|
|
36
|
+
export function resolveLang({ flag, env = process.env } = {}) {
|
|
37
|
+
if (flag === 'en' || flag === 'zh') return flag;
|
|
38
|
+
if (env.EUTHYNA_LANG === 'en') return 'en';
|
|
39
|
+
return DEFAULT_LANG;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The translation helper. Call it once per scope with the active language:
|
|
44
|
+
*
|
|
45
|
+
* const t = T(lang);
|
|
46
|
+
* write(t('已确证', 'Established'));
|
|
47
|
+
*
|
|
48
|
+
* The Chinese text is always the first argument and stays the canonical form;
|
|
49
|
+
* the English is the second. Each call site keeps both strings next to the
|
|
50
|
+
* interpolation that builds them, so a translation cannot drift from the
|
|
51
|
+
* message it translates.
|
|
52
|
+
*/
|
|
53
|
+
export function T(lang) {
|
|
54
|
+
return (zh, en) => (lang === 'en' ? en : zh);
|
|
55
|
+
}
|