diff --git a/packages/verify-cli/CLAUDE.md b/packages/verify-cli/CLAUDE.md index e04137d..b7c77c7 100644 --- a/packages/verify-cli/CLAUDE.md +++ b/packages/verify-cli/CLAUDE.md @@ -47,6 +47,7 @@ src/ - ZIP 入力のとき、`screenshots/manifest.json` の entry と画像バイト列を突合し、さらにチェーンの `screenshotCapture.imageHash` (改ざん不能な唯一の真正記録) との裏付けを検査する。判定は **shared の `summarizeScreenshotArtifacts` / `checkScreenshotImage`** (verify web と同一実装) — CLI 側で再実装しない - **改ざん (tampered) は exit 1** (web の error 軸 / integrity failed と同じ結論)。欠損・chainOnly (チェーンに記録があるのに manifest に無い = 剥ぎ取り疑い) は warning のみで exit 非干渉 +- チェーンが健全でスクショだけ改ざんのときは、出力ヘッダに改ざん枚数を出す (#217)。exam 束縛のみ失敗と同型で、chain の成功メッセージを `Error:` として誤表示しない (両方落ちたときは両方出す) - JSON 単体入力は画像が無いので未検査 — 出力に `Screenshots: not checked` を明示する (overclaim 防止) - サマリは ZIP 単位で一度だけ計算し全 proof に共有する (スクショはセッション単位で proof 横断)。`deriveAssurance` へは `screenshotsTampered` として渡る @@ -66,7 +67,7 @@ src/ - 検証 (`--- Checks ---`) と**直交する advisory** を `--- Analysis (advisory) ---` セクションに出す。判定ではない (**exit code には一切影響させない** — ここを破ると ADR-0009 の直交性が壊れる) - 各 signal は severity (`INFO`/`NOTICE`/`REVIEW`) + summary + **evidence (event index)** を出す。evidence は人間が当該イベントを検分するためのリンクで ADR-0009 上必須 - `--analysis-json ` (任意): 全 proof 分の `{filename, valid, analysis}` を JSON でファイル出力する。分析器の評価ハーネス / コホート集計の機械可読な入口 (Phase 8 W5)。advisory のみで exit code 非干渉 -- `--analysis-bundle ` (任意, ADR-0024 Tier A): 全 proof 分の **content-free な派生バンドル** `{filename, schema, integrityValid, processSummary, analysis, assurance}` を出力する。**events / ソース / fingerprint を含まない** (Tier A)。コホート基準 (ADR-0025) の入力フォーマット。組み立ては shared の `buildAnalysisBundle` に委譲 (CLI は result の content-free な派生物を渡すだけ)。advisory のみで exit code 非干渉 +- `--analysis-bundle ` (任意, ADR-0024 Tier A): 全 proof 分の **content-free な派生バンドル** `{filename, schema, integrityValid, processSummary, analysis, assurance}` を出力する。**events / ソース / fingerprint を含まない** (Tier A)。コホート基準 (ADR-0025) の入力フォーマット。組み立ては shared の `buildAnalysisBundle` に委譲 (CLI は result の content-free な派生物を渡すだけ)。advisory のみで exit code 非干渉。`integrityValid` は **gate 込みの総合 valid ではなく `assurance.integrity !== 'failed'`** (`toBundleIntegrityValid`, #219) — 契約は「整合性検証を通ったか」で、ADR-0031 の `'partial'` (検査を省略した) を失敗に潰さない - `--analyzer ` (任意・反復可) / `--no-default-analyzers` (ADR-0023 / プラットフォーム方針): 採点者/研究者の**外部 Analyzer** (ADR-0009 契約を default / `analyzer` / `analyzers` で export する ES モジュール) を**フォークせず**差し込む。既定では同梱分析器に**追加**、`--no-default-analyzers` で既定を外して外部のみ。読込は `src/analyzers.ts` の `loadExternalAnalyzers` (動的 import + 契約バリデーション + 重複 id 拒否) で、**分析ロジックは外部モジュール側**。`runAnalysis(input, analyzers)` に渡すだけ。advisory のみで exit code 非干渉。**注意**: 任意モジュールを動的 import する = 任意コード実行。信頼できるモジュールのみ - 分析ロジックは shared の `runAnalysis` に委譲。**CLI 側に分析器を書かない** (`--analyzer` も読込 I/O のみで中身は外部) diff --git a/packages/verify-cli/src/__tests__/bundleIntegrity.test.ts b/packages/verify-cli/src/__tests__/bundleIntegrity.test.ts new file mode 100644 index 0000000..d2bdd09 --- /dev/null +++ b/packages/verify-cli/src/__tests__/bundleIntegrity.test.ts @@ -0,0 +1,33 @@ +/** + * Tier A バンドル (ADR-0024) の `integrityValid` に何を渡すか (#219)。 + * + * バンドルの契約は「派生元 proof が**整合性検証**を通ったか」であって、CLI の総合 valid + * (gate 込み) ではない。かつ ADR-0031 の `'partial'` (検査を省略した) を「整合性が失敗した」に + * 潰してはいけない。この 2 点が守られていることを固定する。 + */ + +import { describe, expect, it } from 'vitest'; +import type { AssuranceResult } from '@typedcode/shared'; +import { toBundleIntegrityValid } from '../verify.js'; + +function assurance(integrity: AssuranceResult['integrity']): AssuranceResult { + return { + integrity, + temporal: 'anchored', + provenance: { pureTyping: true, notableSignals: 0, reviewPriority: 0 }, + }; +} + +describe('toBundleIntegrityValid (ADR-0024 / ADR-0031)', () => { + it('reports integrity as valid when the proof was fully proven', () => { + expect(toBundleIntegrityValid(assurance('proven'))).toBe(true); + }); + + it('keeps integrity valid when checks were skipped (partial), not treating them as tampering', () => { + expect(toBundleIntegrityValid(assurance('partial'))).toBe(true); + }); + + it('reports integrity as invalid only when the integrity checks actually failed', () => { + expect(toBundleIntegrityValid(assurance('failed'))).toBe(false); + }); +}); diff --git a/packages/verify-cli/src/__tests__/output.test.ts b/packages/verify-cli/src/__tests__/output.test.ts index 8c964e0..f62a6bd 100644 --- a/packages/verify-cli/src/__tests__/output.test.ts +++ b/packages/verify-cli/src/__tests__/output.test.ts @@ -7,8 +7,9 @@ */ import { describe, expect, it } from 'vitest'; -import type { AssuranceResult } from '@typedcode/shared'; +import type { AssuranceResult, ScreenshotVerificationSummary } from '@typedcode/shared'; import { formatResult, type VerificationOutput } from '../output.js'; +import type { CLIExamResult } from '../verify.js'; /** 色付けは TTY 依存 (module load 時に決まる) なので、比較前に ANSI を落とす。 */ function plain(text: string): string { @@ -43,6 +44,117 @@ function output(overrides: Partial = {}): VerificationOutput }; } +function screenshots(overrides: Partial = {}): ScreenshotVerificationSummary { + return { total: 8, verified: 7, missing: 0, tampered: 1, chainOnly: 0, ...overrides }; +} + +/** exam 束縛だけが落ちた proof (チェーンは健全)。 */ +function examBindingFailed(): CLIExamResult { + return { + present: true, + examId: 'exam-1', + problemId: 'p1', + variant: null, + packageProvided: true, + rootBindingValid: true, + binding: { + valid: false, + packageSignatureValid: true, + packageHashMatches: false, + rootMatches: true, + problemContentHashMatches: true, + timeBox: null, + reason: 'packageHash mismatch', + }, + }; +} + +/** チェーン検証が通ったときに shared が返す (成功) メッセージ。 */ +const CHAIN_SUCCESS_MESSAGE = 'All hashes verified successfully (including PoSW)'; + +/** FAILED ヘッダから Assurance セクションまで = 「なぜ落ちたか」を述べる領域。 */ +function failureHeader(text: string): string { + return text.slice(text.indexOf('Verification FAILED'), text.indexOf('--- Assurance')); +} + +describe('formatResult — 総合 FAILED の理由表示 (#217)', () => { + it('reports the chain failure reason when the chain itself is broken', () => { + const text = plain( + formatResult( + output({ + valid: false, + chainValid: false, + errorMessage: 'Hash mismatch at event 3', + errorAt: 3, + assurance: assurance({ integrity: 'failed' }), + }) + ) + ); + + expect(failureHeader(text)).toContain('Error: Hash mismatch at event 3'); + }); + + it('reports the exam binding reason when only the exam binding failed', () => { + const text = plain( + formatResult( + output({ + valid: false, + errorMessage: CHAIN_SUCCESS_MESSAGE, + exam: examBindingFailed(), + }) + ) + ); + + expect(failureHeader(text)).toContain('Exam binding failed: packageHash mismatch'); + expect(failureHeader(text)).not.toContain(CHAIN_SUCCESS_MESSAGE); + }); + + it('never presents the chain success message as the error when only screenshots were tampered', () => { + const text = plain( + formatResult( + output({ + valid: false, + errorMessage: CHAIN_SUCCESS_MESSAGE, + screenshots: screenshots({ tampered: 1 }), + }) + ) + ); + + expect(failureHeader(text)).not.toContain(CHAIN_SUCCESS_MESSAGE); + }); + + it('names the tampered screenshots as the failure reason when only screenshots were tampered', () => { + const text = plain( + formatResult( + output({ + valid: false, + errorMessage: CHAIN_SUCCESS_MESSAGE, + screenshots: screenshots({ tampered: 2, verified: 6 }), + }) + ) + ); + + expect(failureHeader(text)).toContain('Screenshots failed: 2/8 tampered'); + }); + + it('reports both reasons when the exam binding and the screenshots failed together', () => { + const text = plain( + formatResult( + output({ + valid: false, + errorMessage: CHAIN_SUCCESS_MESSAGE, + exam: examBindingFailed(), + screenshots: screenshots({ tampered: 1 }), + }) + ) + ); + + const header = failureHeader(text); + expect(header).toContain('Exam binding failed: packageHash mismatch'); + expect(header).toContain('Screenshots failed: 1/8 tampered'); + }); +}); + describe('formatResult — PoSW が再計算されなかったとき (fast モード)', () => { it('states next to the PASSED header that the PoSW was not recomputed', () => { const text = plain( diff --git a/packages/verify-cli/src/cli.ts b/packages/verify-cli/src/cli.ts index 3ed63cf..33b49ab 100644 --- a/packages/verify-cli/src/cli.ts +++ b/packages/verify-cli/src/cli.ts @@ -7,7 +7,7 @@ import { readFile, writeFile } from 'node:fs/promises'; import { resolve, extname } from 'node:path'; -import { verifyProof, type ProofFile } from './verify.js'; +import { verifyProof, toBundleIntegrityValid, type ProofFile } from './verify.js'; import { extractAllProofs, extractScreenshotArtifacts } from './zip.js'; import { loadExternalAnalyzers } from './analyzers.js'; import { formatResult, printError, printUsage } from './output.js'; @@ -188,7 +188,8 @@ async function main(): Promise { // Tier A バンドル (ADR-0024): content-free な派生ビュー。--analysis-bundle 指定時のみ集める。 if (analysisBundlePath !== undefined) { const bundle = buildAnalysisBundle({ - integrityValid: result.valid, + // #219: gate 込みの result.valid ではなく整合性そのものを渡す (理由は関数の doc)。 + integrityValid: toBundleIntegrityValid(result.assurance), processSummary: result.processSummary, analysis: result.analysis, assurance: result.assurance, diff --git a/packages/verify-cli/src/output.ts b/packages/verify-cli/src/output.ts index e07c4c4..514f0fa 100644 --- a/packages/verify-cli/src/output.ts +++ b/packages/verify-cli/src/output.ts @@ -219,8 +219,23 @@ export function formatResult(result: VerificationOutput): string { // \u300cError:\u300d\u3068\u3057\u3066\u51fa\u3059\u3068\u8aa4\u89e3\u3092\u62db\u304f\u3002\u305d\u306e\u5834\u5408\u306f exam \u675f\u7e1b\u306e\u7406\u7531\u3092\u51fa\u3059\u3002 const examBindingFailedOnly = result.metadataValid && result.chainValid && !!result.exam?.binding && !result.exam.binding.valid; - if (examBindingFailedOnly) { - lines.push(c('red', ` Exam binding failed: ${result.exam!.binding!.reason ?? 'see section below'}`)); + // #217: スクショ改ざんも同じ形の矛盾を起こす (チェーン健全 → errorMessage は成功文字列のまま、 + // 総合 valid だけが screenshotsValid で false になる)。改ざん枚数を失敗理由として出す。 + const tamperedShots = result.screenshots?.tampered ?? 0; + const screenshotsFailedOnly = result.metadataValid && result.chainValid && tamperedShots > 0; + if (examBindingFailedOnly || screenshotsFailedOnly) { + // 両方落ちることもある (exam proof の ZIP でスクショも改ざん) ので、片方に潰さず両方出す。 + if (examBindingFailedOnly) { + lines.push(c('red', ` Exam binding failed: ${result.exam!.binding!.reason ?? 'see section below'}`)); + } + if (screenshotsFailedOnly) { + lines.push( + c( + 'red', + ` Screenshots failed: ${tamperedShots}/${result.screenshots!.total} tampered — hash mismatch or not backed by the chain` + ) + ); + } } else { if (result.errorMessage) { lines.push(c('red', ` Error: ${result.errorMessage}`)); diff --git a/packages/verify-cli/src/verify.ts b/packages/verify-cli/src/verify.ts index 597a6dd..ca86b6a 100644 --- a/packages/verify-cli/src/verify.ts +++ b/packages/verify-cli/src/verify.ts @@ -77,6 +77,21 @@ export interface CLIVerificationResult { screenshots?: ScreenshotVerificationSummary; } +/** + * Tier A バンドル (ADR-0024) の `integrityValid` へ渡す値を導く (#219)。 + * + * - `CLIVerificationResult.valid` は `--require-root-anchor` などの **gate 込み**の総合判定なので、 + * 「派生元 proof が整合性検証を通ったか」という ADR-0024 の契約とはずれる。そのまま渡すと + * gate で落ちただけの proof が `integrityValid: false` かつ `assurance.integrity: 'proven'` という + * 自己矛盾したレコードになる。 + * - `=== 'proven'` にしないのは、ADR-0031 (#253) で `IntegrityLevel` に `'partial'` が入り fast モードが + * `'partial'` になるため。`'partial'` は「実施していない検査がある」であって「整合性が失敗した」では + * ないので、ここで false に潰してはいけない。 + */ +export function toBundleIntegrityValid(assurance: AssuranceResult): boolean { + return assurance.integrity !== 'failed'; +} + export interface VerifyProofOptions { mode?: VerificationMode; /** `.tcexam` 問題パッケージ (任意)。あれば署名/復号/内容まで完全検証する。 */