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
2 changes: 1 addition & 1 deletion packages/verify-cli/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 はタブ毎に独立した `<name>_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` へ寄せる)
Expand Down
3 changes: 2 additions & 1 deletion packages/verify-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ TypedCode は「判定するツール」ではなく「多様な分析手法を

- `--analyzer <module>` に、ADR-0009 の Analyzer 契約を `default` / `analyzer` / `analyzers` で export する ES モジュールのパスを渡します (反復可)
- 既定では同梱の分析器に**追加**されます。`--no-default-analyzers` を付けると外部のみになります
- 分析結果は **advisory** で、**exit code には一切影響しません**
- 分析結果は **advisory** で、**exit code には一切影響しません**。渡される検証結果は凍結済みで、書き換えようとすると分析器側で例外になります (他の分析器は止まりません)
- 分析器は**ハンドルを残さないでください** (タイマー・ソケット・開いたままのファイル)。CLI は終了コードを立てたあとイベントループが空になるのを待って終了するため、残ったハンドルはプロセスの終了を妨げます

> ⚠️ `--analyzer` は指定したモジュールを動的 import します = **任意コード実行**。信頼できるモジュールのみを渡してください。

Expand Down
191 changes: 191 additions & 0 deletions packages/verify-cli/src/__tests__/analyzerIsolation.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): 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<ProofFile> {
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<ProofFile> {
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<string, unknown>;
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);
});
});
36 changes: 36 additions & 0 deletions packages/verify-cli/src/__tests__/exitCode.test.ts
Original file line number Diff line number Diff line change
@@ -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;/);
});
});
40 changes: 39 additions & 1 deletion packages/verify-cli/src/__tests__/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 を落とす。 */
Expand Down Expand Up @@ -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/);
});
});
28 changes: 18 additions & 10 deletions packages/verify-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ async function main(): Promise<void> {

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)。
Expand All @@ -48,15 +49,17 @@ async function main(): Promise<void> {
if (flagError !== null) {
printError(flagError);
printUsage();
process.exit(1);
process.exitCode = 1;
return;
}

const mode = parseModeFlag(args);
const positional = nonFlagArgs(args);
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();
Expand Down Expand Up @@ -84,14 +87,16 @@ async function main(): Promise<void> {
if (analyzerPaths.length > 0 || noDefaultAnalyzers) {
if (noDefaultAnalyzers && analyzerPaths.length === 0) {
printError('--no-default-analyzers requires at least one --analyzer <path>.');
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(', ');
Expand All @@ -106,7 +111,8 @@ async function main(): Promise<void> {
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 なので
Expand Down Expand Up @@ -140,7 +146,8 @@ async function main(): Promise<void> {
} else {
spinner.stop();
printError(`Unsupported file type: ${ext}. Use .json or .zip`);
process.exit(1);
process.exitCode = 1;
return;
}

// 問題パッケージ (.tcexam) の読込・パース (任意)
Expand All @@ -151,7 +158,8 @@ async function main(): Promise<void> {
if (!parsed) {
spinner.stop();
printError(`Invalid exam package (.tcexam): ${examPackagePath}`);
process.exit(1);
process.exitCode = 1;
return;
}
examPackageManifest = parsed;
}
Expand Down Expand Up @@ -223,10 +231,10 @@ async function main(): Promise<void> {
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;
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/verify-cli/src/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading