diff --git a/packages/verify-cli/CLAUDE.md b/packages/verify-cli/CLAUDE.md index c8d4377..c199ccf 100644 --- a/packages/verify-cli/CLAUDE.md +++ b/packages/verify-cli/CLAUDE.md @@ -15,7 +15,7 @@ ## 重要な不変条件 1. **`shared` の検証ロジックを再実装しない**: バグや暗号アルゴリズムの修正は shared 側で行う。CLI 側で差分があると 「Web で OK / CLI で NG」 のような不整合事故が起きる -2. **終了コード**: 0 = 成功、1 = 失敗 / エラー。これが CI で利用されるので変えない +2. **終了コード**: 0 = 成功、1 = 失敗 / エラー。これが CI で利用されるので変えない。**立て方は `process.exitCode` で、`process.exit()` は使わない** (#283): stdout がパイプのとき Node の書き込みは非同期なので、`process.exit()` は未 flush の出力を捨ててプロセスを落とす。60 proof の ZIP を遅い読み手 (`| tee` / `| less`) へ流すと **ちょうど 65536 バイト (パイプバッファ 1 個分) で行の途中から切れ、`=== Summary: N/M proofs passed ===` ごと消える**ことを実測済み。TTY 実行とファイル redirect では再現しないので、気付かずに再導入しやすい (`exitCode.test.ts` が `process.exit(` の再導入を落とす)。副作用として `--analyzer` の外部モジュールがハンドルを残すと自然終了できなくなるので、README に明記してある 3. **ZIP 内の proof は全件検証する**: exam/class はタブ毎に独立した `_proof.json` を N 個出力するので、`shared` の `extractAllProofsFromZip` で全件を取り出し、**1 件でも fail なら exit 1**。最初の 1 件だけ見ると未検証タブが exit 0 で通る (proof 判定は構造 `isProofFile` で、ファイル名順や `screenshots/manifest.json` に依存しない) 4. **stdout は人間向け、stderr はエラーログ**: パイプして grep される可能性を考慮 5. **proof / ZIP 由来の文字列は `output.ts` の `safe()` を通してから stdout へ出す** (#266): 生値のままだと改行と ANSI エスケープで**任意の行を偽造できる** (ZIP エントリ名から Summary に緑の `✓ <正規ファイル名>` を生やせることを再現済み)。exit code は守られるので壊れるのは grep する採点運用と端末表示。`safe()` が保証するのは「未信頼値が 1 行に収まり行頭を乗っ取れない」ところまで — 行内に `Hash Chain: PASS` という**文字列**が残るのは防げないので、**採点は行頭を固定して** grep する。整形を `cli.ts` の `console.log` に直接書かない (テストを当てられなくなる。`formatProofHeader` / `formatMultiSummary` のように `output.ts` へ寄せる) diff --git a/packages/verify-cli/README.md b/packages/verify-cli/README.md index 1a7b339..a1351a0 100644 --- a/packages/verify-cli/README.md +++ b/packages/verify-cli/README.md @@ -73,7 +73,8 @@ TypedCode は「判定するツール」ではなく「多様な分析手法を - `--analyzer ` に、ADR-0009 の Analyzer 契約を `default` / `analyzer` / `analyzers` で export する ES モジュールのパスを渡します (反復可) - 既定では同梱の分析器に**追加**されます。`--no-default-analyzers` を付けると外部のみになります -- 分析結果は **advisory** で、**exit code には一切影響しません** +- 分析結果は **advisory** で、**exit code には一切影響しません**。渡される検証結果は凍結済みで、書き換えようとすると分析器側で例外になります (他の分析器は止まりません) +- 分析器は**ハンドルを残さないでください** (タイマー・ソケット・開いたままのファイル)。CLI は終了コードを立てたあとイベントループが空になるのを待って終了するため、残ったハンドルはプロセスの終了を妨げます > ⚠️ `--analyzer` は指定したモジュールを動的 import します = **任意コード実行**。信頼できるモジュールのみを渡してください。 diff --git a/packages/verify-cli/src/__tests__/analyzerIsolation.test.ts b/packages/verify-cli/src/__tests__/analyzerIsolation.test.ts new file mode 100644 index 0000000..7ce0f48 --- /dev/null +++ b/packages/verify-cli/src/__tests__/analyzerIsolation.test.ts @@ -0,0 +1,191 @@ +/** + * 外部 analyzer が検証結果に触れないことの固定 (#283 c3 / 旧 #238)。 + * + * ADR-0009 / ADR-0023 の不変条件は「分析層は advisory であって判定ではない」。ところが + * `AnalysisInput.verification` は検証結果オブジェクトそのものなので、以前は `--analyzer` で + * 渡した分析器が `input.verification.valid = true` と書くだけで **改ざん proof が exit 0** になり、 + * 三層保証の integrity や表示上の統計まで汚染できた。 + * + * 守り方は二重で、どちらが破れても事故にならないようにしてある: + * 1. 検証結果を deep freeze してから分析層へ渡す (書き込みは TypeError で弾かれる) + * 2. 判定に効く値は分析層より**前**にすべて確定させる (順序で保証する) + * + * ここでは「悪意ある分析器を通しても結論が変わらない」ことだけを見る。分析器の読み込み契約は + * `analyzers.test.ts`、advisory と判定の分離そのものは shared の `assurance.test.ts` が持つ。 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { TypingProof, computeHash, type Analyzer, type FingerprintComponents } from '@typedcode/shared'; +import { verifyProof, type ProofFile } from '../verify.js'; + +/** + * PoSW 用 Web Worker のスタブ (webCliParity.test.ts と同じ役割)。Node 環境に Worker は無い。 + * 検証はすべて `mode: 'fast'` (PoSW 再計算なし) で回すので、返す値は固定で構わない。 + */ +class PoswWorkerStub { + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + + postMessage(message: Record): void { + queueMicrotask(() => { + if (!this.onmessage) return; + const data = + message.type === 'compute-posw' + ? { + type: 'posw-result', + requestId: message.requestId, + iterations: message.iterations, + nonce: 'ab'.repeat(16), + intermediateHash: 'stub-intermediate-hash', + computeTimeMs: 1, + } + : { type: 'verify-result', requestId: message.requestId, valid: true }; + this.onmessage({ data } as MessageEvent); + }); + } + + terminate(): void { + // no-op + } +} + +vi.stubGlobal('Worker', PoswWorkerStub); + +const components = (): FingerprintComponents => + ({ + userAgent: 'Mozilla/5.0 (Analyzer Isolation Test)', + language: 'en', + languages: ['en'], + platform: 'TestOS', + hardwareConcurrency: 4, + deviceMemory: 8, + screen: { + width: 1440, + height: 900, + availWidth: 1440, + availHeight: 860, + colorDepth: 24, + pixelDepth: 24, + devicePixelRatio: 2, + }, + timezone: 'UTC', + timezoneOffset: 0, + canvas: 'mock-canvas', + webgl: { vendor: 'Mock', renderer: 'Mock' }, + fonts: ['Arial'], + cookieEnabled: true, + doNotTrack: 'unspecified', + maxTouchPoints: 0, + }) as FingerprintComponents; + +/** 打鍵して proof を 1 本作る。 */ +async function buildProof(text: string): Promise { + const fp = components(); + const fingerprintHash = await computeHash(JSON.stringify(fp, null, 0)); + const proof = new TypingProof(); + await proof.initialize(fingerprintHash, fp); + let content = ''; + for (const ch of text) { + await proof.recordEvent({ + type: 'contentChange', + inputType: 'insertText', + data: ch, + rangeOffset: content.length, + rangeLength: 0, + }); + content += ch; + } + const exported = await proof.exportProof(content); + return { ...exported, content, language: 'text' } as ProofFile; +} + +/** チェーンを壊した proof (検証は必ず落ちる)。 */ +async function buildTamperedProof(): Promise { + const proof = await buildProof('hello'); + const events = proof.proof.events; + const target = events[events.length - 1]!; + // 記録済みイベントの中身だけ差し替える → hash 再計算と合わなくなる。 + return { + ...proof, + proof: { + ...proof.proof, + events: [...events.slice(0, -1), { ...target, data: 'X' }], + }, + } as ProofFile; +} + +/** + * 検証結果を書き換えにかかる分析器。判定に効く boolean は**反転**させる — 固定値を書くと + * 「もともとその値だった」ケースで退行を見逃すため (改ざん proof なら false→true、 + * 健全な proof なら true→false になり、どちらでも結論の汚染が観測できる)。 + * 凍結されていれば代入は TypeError で throw し、orchestrator が握り潰す。 + */ +const hostileAnalyzer: Analyzer = { + id: 'hostile-fixture', + version: '1.0.0', + analyze(input) { + const verification = input.verification as unknown as Record; + for (const key of ['valid', 'chainValid', 'metadataValid', 'isPureTyping', 'poswSkipped']) { + try { + verification[key] = !verification[key]; + } catch { + // 凍結済み。ここを通るのが期待どおりの姿。 + } + } + try { + verification.errorMessage = 'injected by analyzer'; + } catch { + // 同上。 + } + return []; + }, +}; + +describe('外部 analyzer の隔離 (#283 c3)', () => { + it('改ざん proof は、検証結果を書き換えにくる分析器を通しても invalid のまま', async () => { + const tampered = await buildTamperedProof(); + + const baseline = await verifyProof(tampered, { mode: 'fast' }); + expect(baseline.valid).toBe(false); + expect(baseline.chainValid).toBe(false); + + const withHostile = await verifyProof(tampered, { mode: 'fast', analyzers: [hostileAnalyzer] }); + + // exit code を決める値 (cli.ts は summary.every(s => s.valid) で 0/1 を出す)。 + expect(withHostile.valid).toBe(false); + expect(withHostile.chainValid).toBe(false); + // 三層保証の integrity も advisory に引きずられない。 + expect(withHostile.assurance.integrity).toBe('failed'); + // 失敗の理由も消せない (採点者に「なぜ落ちたか」が届かなくなるため)。 + expect(withHostile.errorMessage).toBe(baseline.errorMessage); + }); + + it('健全な proof でも、分析器の書き込みで表示上の統計が変わらない', async () => { + const healthy = await buildProof('hello world'); + + const baseline = await verifyProof(healthy, { mode: 'fast' }); + const withHostile = await verifyProof(healthy, { mode: 'fast', analyzers: [hostileAnalyzer] }); + + expect(baseline.valid).toBe(true); + expect(withHostile.valid).toBe(true); + expect(withHostile.errorMessage).toBe(baseline.errorMessage); + expect(withHostile.pasteEvents).toBe(baseline.pasteEvents); + expect(withHostile.dropEvents).toBe(baseline.dropEvents); + expect(withHostile.isPureTyping).toBe(baseline.isPureTyping); + expect(withHostile.assurance).toEqual(baseline.assurance); + }); + + it('行儀の悪い分析器が throw しても、他の分析器のシグナルは失われない', async () => { + const healthy = await buildProof('hi'); + + const witness: Analyzer = { + id: 'witness-fixture', + version: '1.0.0', + analyze: () => [{ id: 'witness', severity: 'info', summary: 'ran' }], + }; + + const result = await verifyProof(healthy, { mode: 'fast', analyzers: [hostileAnalyzer, witness] }); + + expect(result.analysis.signals.some((s) => s.id === 'witness')).toBe(true); + }); +}); diff --git a/packages/verify-cli/src/__tests__/exitCode.test.ts b/packages/verify-cli/src/__tests__/exitCode.test.ts new file mode 100644 index 0000000..b85ed69 --- /dev/null +++ b/packages/verify-cli/src/__tests__/exitCode.test.ts @@ -0,0 +1,36 @@ +/** + * `cli.ts` が `process.exit()` を使わないことの固定 (#283 c1 / 旧 #238)。 + * + * stdout がパイプのとき Node の書き込みは非同期なので、`process.exit()` は**未 flush の + * 出力を捨てて**プロセスを落とす。クラス単位の ZIP (数十 proof) を + * `typedcode-verify class.zip | tee report.txt` のように受けると、最後の + * `=== Summary: N/M proofs passed ===` から欠ける — 採点運用がまさに壊れる形で欠ける。 + * + * 直し方は `process.exitCode` を立てて `main()` を素直に return させること + * (イベントループが空になった時点で Node が flush してから終了する)。 + * 挙動そのものはプロセスを跨ぐので e2e の領分だが、**再導入を止めるのはここ** — + * `process.exit()` は 1 箇所足すだけで同じ穴が開き、しかも普段の TTY 実行では再現しない。 + * + * 終了コードの意味 (0 = 成功 / 1 = 失敗・エラー) は verify-cli/CLAUDE.md の不変条件 2。 + */ + +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const CLI_SOURCE = readFileSync(fileURLToPath(new URL('../cli.ts', import.meta.url)), 'utf-8'); + +describe('CLI の終了経路 (#283 c1)', () => { + it('process.exit() を呼ばない (パイプ時に stdout を切り捨てるため)', () => { + const calls = CLI_SOURCE.match(/process\.exit\s*\(/g) ?? []; + expect(calls).toEqual([]); + }); + + it('終了コードは process.exitCode で立てる', () => { + expect(CLI_SOURCE).toMatch(/process\.exitCode\s*=/); + }); + + it('検証結果の集計がそのまま終了コードになる (成功 0 / 失敗 1)', () => { + expect(CLI_SOURCE).toMatch(/process\.exitCode = summary\.every\(\(s\) => s\.valid\) \? 0 : 1;/); + }); +}); diff --git a/packages/verify-cli/src/__tests__/output.test.ts b/packages/verify-cli/src/__tests__/output.test.ts index f1d7b20..97a66fc 100644 --- a/packages/verify-cli/src/__tests__/output.test.ts +++ b/packages/verify-cli/src/__tests__/output.test.ts @@ -8,7 +8,14 @@ import { describe, expect, it } from 'vitest'; import type { AssuranceResult, ScreenshotVerificationSummary } from '@typedcode/shared'; -import { formatMultiSummary, formatProofHeader, formatResult, safe, type VerificationOutput } from '../output.js'; +import { + formatMultiSummary, + formatProofHeader, + formatResult, + printUsage, + safe, + type VerificationOutput, +} from '../output.js'; import type { CLIExamResult } from '../verify.js'; /** 色付けは TTY 依存 (module load 時に決まる) なので、比較前に ANSI を落とす。 */ @@ -428,3 +435,34 @@ describe('formatResult — proof 由来の文字列による偽セクション expect(reflection[0]).toContain('(sanitized)'); }); }); + +describe('printUsage — --mode の説明が実装と一致する (#283 c2)', () => { + /** printUsage は console.log へ書くので、1 回分を掴む。 */ + function usageText(): string { + const lines: string[] = []; + const original = console.log; + console.log = (...args: unknown[]) => { + lines.push(args.map(String).join(' ')); + }; + try { + printUsage(); + } finally { + console.log = original; + } + return plain(lines.join('\n')); + } + + it('audit を「未実装・現状 full と同等」と説明する', () => { + // 実装は verify の poswModeFor が audit → 'full' に落としており、spec §6.1 も + // 「部分的 PoSW 検証 (未実装、現状 full と同等)」。help だけが + // `fast + deterministic PoSW sampling` = fast 相当と読める文言だった。 + // 採点者が「audit なら軽くて十分」と誤読すると、実際にはフルコストを払う。 + expect(usageText()).toMatch(/audit\s+- unimplemented; currently equivalent to full/); + }); + + it('audit が fast 相当・サンプリング実施だと読める文言を含まない', () => { + const text = usageText(); + expect(text).not.toMatch(/audit\s+- fast/); + expect(text).not.toMatch(/deterministic PoSW sampling/); + }); +}); diff --git a/packages/verify-cli/src/cli.ts b/packages/verify-cli/src/cli.ts index d333e6a..01628ef 100644 --- a/packages/verify-cli/src/cli.ts +++ b/packages/verify-cli/src/cli.ts @@ -39,7 +39,8 @@ async function main(): Promise { if (args.length === 0 || args.includes('--help') || args.includes('-h')) { printUsage(); - process.exit(args.length === 0 ? 1 : 0); + process.exitCode = args.length === 0 ? 1 : 0; + return; } // 未知フラグ・タイポ・値欠落は黙殺せず usage error (#148)。 @@ -48,7 +49,8 @@ async function main(): Promise { if (flagError !== null) { printError(flagError); printUsage(); - process.exit(1); + process.exitCode = 1; + return; } const mode = parseModeFlag(args); @@ -56,7 +58,8 @@ async function main(): Promise { if (positional.length === 0) { printError('No proof file given.'); printUsage(); - process.exit(1); + process.exitCode = 1; + return; } const filePath = resolve(positional[0]!); const ext = extname(filePath).toLowerCase(); @@ -84,14 +87,16 @@ async function main(): Promise { if (analyzerPaths.length > 0 || noDefaultAnalyzers) { if (noDefaultAnalyzers && analyzerPaths.length === 0) { printError('--no-default-analyzers requires at least one --analyzer .'); - process.exit(1); + process.exitCode = 1; + return; } let external: Analyzer[]; try { external = await loadExternalAnalyzers(analyzerPaths); } catch (err) { printError(err instanceof Error ? err.message : String(err)); - process.exit(1); + process.exitCode = 1; + return; } analyzers = noDefaultAnalyzers ? external : [...defaultAnalyzers, ...external]; const names = analyzers.map((a) => `${a.id}@${a.version}`).join(', '); @@ -106,7 +111,8 @@ async function main(): Promise { submittedAtMs = Date.parse(submittedAtRaw); if (Number.isNaN(submittedAtMs)) { printError(`Invalid --submitted-at value: ${submittedAtRaw}. Use an ISO 8601 timestamp.`); - process.exit(1); + process.exitCode = 1; + return; } // #218: --submitted-at は time-box (advisory) の判定にしか使われず、time-box は package の // manifest にしか無い。単独で渡しても黙って捨てられるので、その旨を出す (advisory なので @@ -140,7 +146,8 @@ async function main(): Promise { } else { spinner.stop(); printError(`Unsupported file type: ${ext}. Use .json or .zip`); - process.exit(1); + process.exitCode = 1; + return; } // 問題パッケージ (.tcexam) の読込・パース (任意) @@ -151,7 +158,8 @@ async function main(): Promise { if (!parsed) { spinner.stop(); printError(`Invalid exam package (.tcexam): ${examPackagePath}`); - process.exit(1); + process.exitCode = 1; + return; } examPackageManifest = parsed; } @@ -223,10 +231,10 @@ async function main(): Promise { console.log(formatMultiSummary(summary)); } - process.exit(summary.every((s) => s.valid) ? 0 : 1); + process.exitCode = summary.every((s) => s.valid) ? 0 : 1; } catch (error) { printError(error instanceof Error ? error.message : String(error)); - process.exit(1); + process.exitCode = 1; } } diff --git a/packages/verify-cli/src/output.ts b/packages/verify-cli/src/output.ts index fd1a5c7..f48e3de 100644 --- a/packages/verify-cli/src/output.ts +++ b/packages/verify-cli/src/output.ts @@ -516,7 +516,7 @@ ${c('cyan', 'Arguments:')} ${c('cyan', 'Options:')} --mode Verification mode (default: full) fast - Skip PoSW recompute (tamper resistance only) - audit - fast + deterministic PoSW sampling (placeholder) + audit - unimplemented; currently equivalent to full (spec 6.1) full - Full PoSW verification --exam-package Exam mode (ADR-0006): sealed problem package (.tcexam) to fully verify the binding (signature, package hash, decrypted content). diff --git a/packages/verify-cli/src/verify.ts b/packages/verify-cli/src/verify.ts index 80ada40..e36b620 100644 --- a/packages/verify-cli/src/verify.ts +++ b/packages/verify-cli/src/verify.ts @@ -133,6 +133,24 @@ export interface VerifyProofOptions { signedCheckpointKeyRegistry?: readonly CheckpointPublicKey[]; } +/** + * オブジェクトを再帰的に凍結する (#238 c3)。 + * + * 用途は `FullVerificationResult` の凍結ひとつ。**event 数に比例するデータには使わないこと** + * (proof 本体は分析層が読む必要があり、コピーも凍結もコストが event 数に比例する)。 + * ES module は strict mode なので、凍結後の代入は黙って無視されるのではなく TypeError で throw する。 + * 分析器が throw しても orchestrator が握り潰す契約 (shared の `Analyzer` の JSDoc) なので、 + * 行儀の悪い分析器 1 つが他の分析器を巻き添えにすることはない。 + */ +function deepFreeze(value: T): T { + if (value === null || typeof value !== 'object' || Object.isFrozen(value)) return value; + Object.freeze(value); + for (const key of Object.getOwnPropertyNames(value)) { + deepFreeze((value as Record)[key]); + } + return value; +} + export async function verifyProof(proof: ProofFile, options: VerifyProofOptions = {}): Promise { const mode: VerificationMode = options.mode ?? 'full'; const startTime = performance.now(); @@ -171,10 +189,11 @@ export async function verifyProof(proof: ProofFile, options: VerifyProofOptions progressBar.complete(); - // 分析層 (ADR-0009): 検証と直交する post-hoc 分析。既定の分析器は方向性を示す - // プレースホルダのみ。advisory であって判定ではない (verifyProofFile の valid とは別軸)。 - // options.analyzers が渡れば (採点者/研究者の外部アナライザ) それを使う。未指定なら shared 既定。 - const analysis = await runAnalysis({ proof, verification: result }, options.analyzers); + // #238 (c3): 検証結果を凍結してから分析層へ渡す。`AnalysisInput.verification` は + // このオブジェクトそのものなので、凍結しないと外部 analyzer が `valid` 等を書き換えられる。 + // 小さなサマリ (signedCheckpoints.details は署名 cp 数ぶんで event 数には比例しない) なので + // 深く凍結してよい。以降このオブジェクトは誰も書き換えない。 + deepFreeze(result); // 試験モード (ADR-0006): root 束縛は proof 自己完結 (verifyProofFile が rootValid で検証済み)。 // package が渡されたときのみ署名/復号/内容まで完全検証する (binding は上で計算済み)。 @@ -207,8 +226,6 @@ export async function verifyProof(proof: ProofFile, options: VerifyProofOptions const firstPoswEvent = events.find((e) => e.posw); const poswIterations = firstPoswEvent?.posw?.iterations; - const duration = (performance.now() - startTime) / 1000; - // package が渡されたとき、束縛失敗は全体を fail にする (proof 自己整合とは別軸の真正性)。 const examValid = exam?.binding ? exam.binding.valid : true; @@ -216,7 +233,27 @@ export async function verifyProof(proof: ProofFile, options: VerifyProofOptions // 全体 fail = exit 1 に合流させる。欠損/chainOnly は warning (web と同じ) で exit 非干渉。 const screenshotsValid = (options.screenshotSummary?.tampered ?? 0) === 0; + // proof 由来の自己申告値。生値のままだと Language: 行から改行や ANSI を stdout へ + // 流し込める (#248 / #266)。allowlist に落として渡す。 + const language = normalizeProofLanguage(proof.language); + const processSummary = summarizeProcess(events); + + // 判定に効く値 (exit code を決める valid、および三層保証の integrity / authenticity の入力) は + // **ここまでですべて確定している**。分析層より後に読むのは `analysis` だけ、という並びを保つこと。 + // #238 (c3): 以前は runAnalysis がここより前に走っており、`AnalysisInput.verification` 経由で + // 外部 analyzer が `result.valid` を書き換えると改ざん proof が exit 0 になった。 + // ADR-0009 / ADR-0023 の「advisory は判定に漏れない」を、凍結 (上) と順序 (ここ) の二重で担保する。 + const valid = result.valid && examValid && screenshotsValid; + + // 分析層 (ADR-0009): 検証と直交する post-hoc 分析。既定の分析器は方向性を示す + // プレースホルダのみ。advisory であって判定ではない (verifyProofFile の valid とは別軸)。 + // options.analyzers が渡れば (採点者/研究者の外部アナライザ) それを使う。未指定なら shared 既定。 + const analysis = await runAnalysis({ proof, verification: result }, options.analyzers); + + const duration = (performance.now() - startTime) / 1000; + // 三層保証語彙 (ADR-0020): 実証拠のみから導出 (自己申告 mode は使わない)。 + // analysis は `process` 層 (notableSignals / reviewPriority) にしか流れない (shared/assurance.ts)。 const assurance = deriveAssurance({ metadataValid: result.metadataValid, chainValid: result.chainValid, @@ -248,7 +285,7 @@ export async function verifyProof(proof: ProofFile, options: VerifyProofOptions }); return { - valid: result.valid && examValid && screenshotsValid, + valid, metadataValid: result.metadataValid, chainValid: result.chainValid, isPureTyping: result.isPureTyping, @@ -259,16 +296,14 @@ export async function verifyProof(proof: ProofFile, options: VerifyProofOptions poswIterations, errorAt: result.errorAt, errorMessage: result.errorMessage, - // proof 由来の自己申告値。生値のままだと Language: 行から改行や ANSI を stdout へ - // 流し込める (#248 / #266)。allowlist に落として渡す。 - language: normalizeProofLanguage(proof.language), + language, mode, poswSkipped: result.poswSkipped ?? false, signedCheckpoints: result.signedCheckpoints, rootAnchored: result.rootAnchored ?? false, analysis, assurance, - processSummary: summarizeProcess(events), + processSummary, exam, screenshots: options.screenshotSummary, };