Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/verify-cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` として渡る

Expand All @@ -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 <out.json>` (任意): 全 proof 分の `{filename, valid, analysis}` を JSON でファイル出力する。分析器の評価ハーネス / コホート集計の機械可読な入口 (Phase 8 W5)。advisory のみで exit code 非干渉
- `--analysis-bundle <out.json>` (任意, 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 <out.json>` (任意, 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 <module>` (任意・反復可) / `--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 のみで中身は外部)

Expand Down
33 changes: 33 additions & 0 deletions packages/verify-cli/src/__tests__/bundleIntegrity.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
114 changes: 113 additions & 1 deletion packages/verify-cli/src/__tests__/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -43,6 +44,117 @@ function output(overrides: Partial<VerificationOutput> = {}): VerificationOutput
};
}

function screenshots(overrides: Partial<ScreenshotVerificationSummary> = {}): 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(
Expand Down
5 changes: 3 additions & 2 deletions packages/verify-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -188,7 +188,8 @@ async function main(): Promise<void> {
// 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,
Expand Down
19 changes: 17 additions & 2 deletions packages/verify-cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`));
Expand Down
15 changes: 15 additions & 0 deletions packages/verify-cli/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` 問題パッケージ (任意)。あれば署名/復号/内容まで完全検証する。 */
Expand Down
Loading