diff --git a/packages/shared/CLAUDE.md b/packages/shared/CLAUDE.md index 2b6981f..95a1aff 100644 --- a/packages/shared/CLAUDE.md +++ b/packages/shared/CLAUDE.md @@ -16,6 +16,7 @@ 7. **anchored root 式の二重定義を一致させる** (ADR-0017): casual/class の anchored root は `computeAnchoredChainRoot` (verifier) と `HashChainManager.generateAnchoredInitialHash` (editor) の **2 箇所**に `SHA256(fp ‖ localNonce ‖ serverNonce)` を持つ。exam の `computeExamChainRoot`/`generateExamInitialHash` と同じ轍。式を変えるなら両方+テストを同時に直す 5. **`CheckpointManager`** はステートフル。`shouldCreateCheckpoint` の判定は最終 cp の eventIndex / 時刻に依存する。`setCheckpoints` で復元する際は内部状態も再構築すること (実装済み) 6. **検証は cp の間隔を仮定しない**: `verify` 側は cp の存在を補助メタデータとしてのみ扱い、未署名 cp の sampling は信頼しない ([docs/adr/0004-verifier-checkpoint-stance.md](../../docs/adr/0004-verifier-checkpoint-stance.md)) +8. **`isPureTyping` の判定点は 1 つ** (#235): `structuralEdit.ts` の `evaluatePureTyping` だけが決める。export 時の自己申告 (`TypingProof`) と採点側の再計算 (`verifyProofMetadata`) が同じ関数を呼ぶので、proof に焼かれる値と検証器の結論が食い違わない。**別の場所に式を書き足さないこと**。なお `metadata.bulkInsertEvents` は `isSuspiciousBulkInsert` の素のカウントのままで、こちらは申告値との完全一致を要求するので定義を変えると既存 proof が invalid になる ## モジュール一覧 @@ -47,7 +48,8 @@ - `types/` 配下にカテゴリ別 (`events.ts`, `proof.ts`, `storage.ts`, ...) で配置 - ルート `types.ts` がすべて re-export -- **新しいイベント / 入力タイプを追加するときは `types/events.ts` だけでなく `InputTypeValidator.ts` と CLAUDE.md / system-spec.md / shared README も同時更新**。過去にこの同期が崩れて事故った +- **新しいイベント / 入力タイプを追加するときは `types/events.ts` だけでなく CLAUDE.md / system-spec.md / shared README も同時更新**。過去にこの同期が崩れて事故った (#222: `VALID_EVENT_TYPES` が 26 種のまま union の 29 種から取り残された) +- `InputTypeValidator.ts` の `VALID_EVENT_TYPES` / `VALID_INPUT_TYPES` は **union から導出済み**なので手動同期は不要 — `EventType` / `InputType` に型を足すと **tsc が記入漏れを検出**する (#235 と同じ「文書ルールでなく機械で強制する」方針) ## テスト規約 (#4 テスト強化の方針) diff --git a/packages/shared/src/__tests__/inputTypeValidatorSync.test.ts b/packages/shared/src/__tests__/inputTypeValidatorSync.test.ts new file mode 100644 index 0000000..5288220 --- /dev/null +++ b/packages/shared/src/__tests__/inputTypeValidatorSync.test.ts @@ -0,0 +1,105 @@ +/** + * InputTypeValidator の実行時許可セットが型 union と同期していることのテスト (#222)。 + * + * かつて `VALID_EVENT_TYPES` は手書きの Set で、`EventType` union に追加された + * `environmentProbe` / `fullscreenChange` / `examOpened` が漏れていた。実装側は + * `Record` から導出して tsc に漏れを検出させているが、ここでは + * 「union の全メンバを受理し、union 外を持たない」ことを実行時にも固定する。 + * + * 下のリテラル一覧も `Record` / `Record` なので、 + * union に型を足すとこのテスト自身も tsc に指摘される (仕様の二重記述ではなく確認)。 + */ + +import { describe, expect, it } from 'vitest'; +import { + VALID_EVENT_TYPES, + VALID_INPUT_TYPES, + validateEventType, + validateInputType, +} from '../typingProof/InputTypeValidator.js'; +import type { EventType, InputType } from '../types.js'; + +const ALL_EVENT_TYPES: Record = { + humanAttestation: true, + preExportAttestation: true, + termsAccepted: true, + contentChange: true, + contentSnapshot: true, + cursorPositionChange: true, + selectionChange: true, + externalInput: true, + editorInitialized: true, + mousePositionChange: true, + visibilityChange: true, + focusChange: true, + keyDown: true, + keyUp: true, + windowResize: true, + networkStatusChange: true, + codeExecution: true, + terminalInput: true, + screenshotCapture: true, + screenShareStart: true, + screenShareStop: true, + templateInjection: true, + sessionResumed: true, + copyOperation: true, + screenShareOptOut: true, + reflectionNote: true, + environmentProbe: true, + fullscreenChange: true, + examOpened: true, +}; + +const ALL_INPUT_TYPES: Record = { + insertText: true, + insertLineBreak: true, + insertParagraph: true, + insertTab: true, + insertFromComposition: true, + insertCompositionText: true, + deleteCompositionText: true, + deleteContentBackward: true, + deleteContentForward: true, + deleteWordBackward: true, + deleteWordForward: true, + deleteSoftLineBackward: true, + deleteSoftLineForward: true, + deleteHardLineBackward: true, + deleteHardLineForward: true, + deleteByDrag: true, + deleteByCut: true, + historyUndo: true, + historyRedo: true, + insertFromPaste: true, + insertFromDrop: true, + insertFromYank: true, + insertReplacementText: true, + insertFromPasteAsQuotation: true, + insertFromInternalPaste: true, + replaceContent: true, +}; + +describe('InputTypeValidator stays in sync with the type unions (#222)', () => { + it('accepts every member of the EventType union', () => { + for (const eventType of Object.keys(ALL_EVENT_TYPES)) { + expect(validateEventType(eventType), eventType).toBe(true); + } + }); + + it('has no valid event type outside the EventType union', () => { + expect([...VALID_EVENT_TYPES].sort()).toEqual(Object.keys(ALL_EVENT_TYPES).sort()); + expect(validateEventType('notAnEventType')).toBe(false); + }); + + it('accepts every member of the InputType union', () => { + for (const inputType of Object.keys(ALL_INPUT_TYPES)) { + expect(validateInputType(inputType), inputType).toBe(true); + } + }); + + it('has no valid input type outside the InputType union', () => { + expect([...VALID_INPUT_TYPES].sort()).toEqual(Object.keys(ALL_INPUT_TYPES).sort()); + expect(validateInputType('notAnInputType')).toBe(false); + }); +}); diff --git a/packages/shared/src/__tests__/pureTypingEvaluation.test.ts b/packages/shared/src/__tests__/pureTypingEvaluation.test.ts new file mode 100644 index 0000000..a4fbeb9 --- /dev/null +++ b/packages/shared/src/__tests__/pureTypingEvaluation.test.ts @@ -0,0 +1,125 @@ +/** + * isPureTyping 判定の単一化 (#235) のテスト。 + * + * export 側の自己申告 (`TypingProof.generateTypingProofHash`) と採点側の再計算 + * (`verifyProofMetadata`) が同じ関数を使うこと、括弧自動閉じのような editor 由来の + * 複数文字挿入で自己申告が崩れないことを固定する。 + */ + +import { describe, expect, it } from 'vitest'; +import { TypingProof, computeHash, verifyProofMetadata } from '../index.js'; +import type { EventType, FingerprintComponents, InputType, ProofData, StoredEvent } from '../types.js'; + +const createMockFingerprintComponents = (): FingerprintComponents => ({ + userAgent: 'Mozilla/5.0 (PureTyping 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, +}); + +interface RecordedEvent { + type: EventType; + inputType: InputType; + data: string; + rangeOffset: number; + rangeLength: number; +} + +/** + * Monaco の括弧自動閉じ (既定 `autoClosingBrackets: 'languageDefined'`) だけを含むセッション。 + * `(` の 1 打鍵で `()` が `insertReplacementText` として、閉じ括弧の type-over が + * `replaceContent` として記録される。どちらも構造文字のみで内容 (コード) を運ばない。 + */ +function autoClosingBracketSession(): RecordedEvent[] { + return [ + { type: 'contentChange', inputType: 'insertText', data: 'f', rangeOffset: 0, rangeLength: 0 }, + { type: 'contentChange', inputType: 'insertText', data: 'o', rangeOffset: 1, rangeLength: 0 }, + { type: 'contentChange', inputType: 'insertText', data: 'o', rangeOffset: 2, rangeLength: 0 }, + // `(` を打つと editor が `()` を挿入する + { type: 'contentChange', inputType: 'insertReplacementText', data: '()', rangeOffset: 3, rangeLength: 0 }, + // 自動で閉じた `)` の上から `)` を打つ (type-over) + { type: 'contentChange', inputType: 'replaceContent', data: ')', rangeOffset: 4, rangeLength: 1 }, + ]; +} + +/** AI/スニペットによる複数行のコード一括投入 (打鍵で書いていない)。 */ +function bulkCodeInsertSession(): RecordedEvent[] { + return [ + { type: 'contentChange', inputType: 'insertText', data: 'f', rangeOffset: 0, rangeLength: 0 }, + { + type: 'contentChange', + inputType: 'insertParagraph', + data: 'unction solve(n) {\n return n < 2 ? n : solve(n - 1) + solve(n - 2);\n}\n', + rangeOffset: 1, + rangeLength: 0, + }, + ]; +} + +function replayContent(events: RecordedEvent[]): string { + let content = ''; + for (const event of events) { + content = content.slice(0, event.rangeOffset) + event.data + content.slice(event.rangeOffset + event.rangeLength); + } + return content; +} + +async function exportSession(events: RecordedEvent[]): Promise<{ + exportedIsPureTyping: boolean; + storedEvents: StoredEvent[]; + proofData: ProofData; +}> { + const components = createMockFingerprintComponents(); + const fingerprintHash = await computeHash(JSON.stringify(components, null, 0)); + const proof = new TypingProof(); + await proof.initialize(fingerprintHash, components); + for (const event of events) { + await proof.recordEvent(event); + } + const exported = await proof.exportProof(replayContent(events)); + return { + exportedIsPureTyping: exported.metadata.isPureTyping, + storedEvents: exported.proof.events, + proofData: exported.typingProofData, + }; +} + +describe('isPureTyping single source of truth (#235)', () => { + it('keeps the exported isPureTyping true for a session whose only multi-char inserts are auto-closing brackets', async () => { + const { exportedIsPureTyping } = await exportSession(autoClosingBracketSession()); + expect(exportedIsPureTyping).toBe(true); + }); + + it('drops the exported isPureTyping to false for a multi-line bulk code insert', async () => { + const { exportedIsPureTyping } = await exportSession(bulkCodeInsertSession()); + expect(exportedIsPureTyping).toBe(false); + }); + + it('agrees with the verifier recomputation on the same events', async () => { + for (const session of [autoClosingBracketSession(), bulkCodeInsertSession()]) { + const { exportedIsPureTyping, storedEvents, proofData } = await exportSession(session); + const recomputed = verifyProofMetadata(proofData, storedEvents); + expect(recomputed.valid).toBe(true); + expect(recomputed.isPureTyping).toBe(exportedIsPureTyping); + } + }); +}); diff --git a/packages/shared/src/typingProof/InputTypeValidator.ts b/packages/shared/src/typingProof/InputTypeValidator.ts index e00d162..e6992a1 100644 --- a/packages/shared/src/typingProof/InputTypeValidator.ts +++ b/packages/shared/src/typingProof/InputTypeValidator.ts @@ -5,65 +5,79 @@ import type { InputType, EventType } from '../types.js'; -// 有効なイベントタイプ(実行時検証用) -const VALID_EVENT_TYPES: ReadonlySet = new Set([ - 'humanAttestation', - 'preExportAttestation', - 'termsAccepted', - 'contentChange', - 'contentSnapshot', - 'cursorPositionChange', - 'selectionChange', - 'externalInput', - 'editorInitialized', - 'reflectionNote', - 'mousePositionChange', - 'visibilityChange', - 'focusChange', - 'keyDown', - 'keyUp', - 'windowResize', - 'networkStatusChange', - 'codeExecution', - 'terminalInput', - 'screenshotCapture', - 'screenShareStart', - 'screenShareStop', - 'templateInjection', - 'sessionResumed', - 'copyOperation', - 'screenShareOptOut', -]); +/** + * 有効なイベントタイプ(実行時検証用)。 + * + * **手書きの Set にしないこと** (#222)。`Record` から導出しているので、 + * `EventType` union に型を足すとこの表の記入漏れを tsc が検出する。かつて手書きの Set が + * union から取り残され、`environmentProbe` / `fullscreenChange` / `examOpened` の 3 種が + * 実行時検証で「無効」になっていた (同期の崩れは CLAUDE.md の「よくある罠」でも既知)。 + */ +const EVENT_TYPE_MEMBERS: Record = { + humanAttestation: true, + preExportAttestation: true, + termsAccepted: true, + contentChange: true, + contentSnapshot: true, + cursorPositionChange: true, + selectionChange: true, + externalInput: true, + editorInitialized: true, + reflectionNote: true, + mousePositionChange: true, + visibilityChange: true, + focusChange: true, + keyDown: true, + keyUp: true, + windowResize: true, + networkStatusChange: true, + codeExecution: true, + terminalInput: true, + screenshotCapture: true, + screenShareStart: true, + screenShareStop: true, + templateInjection: true, + sessionResumed: true, + copyOperation: true, + screenShareOptOut: true, + environmentProbe: true, + fullscreenChange: true, + examOpened: true, +}; -// 有効な入力タイプ(実行時検証用) -const VALID_INPUT_TYPES: ReadonlySet = new Set([ - 'insertText', - 'insertLineBreak', - 'insertParagraph', - 'insertTab', - 'insertFromComposition', - 'insertCompositionText', - 'deleteCompositionText', - 'deleteContentBackward', - 'deleteContentForward', - 'deleteWordBackward', - 'deleteWordForward', - 'deleteSoftLineBackward', - 'deleteSoftLineForward', - 'deleteHardLineBackward', - 'deleteHardLineForward', - 'deleteByDrag', - 'deleteByCut', - 'historyUndo', - 'historyRedo', - 'insertFromPaste', - 'insertFromDrop', - 'insertFromYank', - 'insertReplacementText', - 'insertFromPasteAsQuotation', - 'insertFromInternalPaste', - 'replaceContent', -]); +export const VALID_EVENT_TYPES: ReadonlySet = new Set(Object.keys(EVENT_TYPE_MEMBERS) as EventType[]); + +/** 有効な入力タイプ(実行時検証用)。`EVENT_TYPE_MEMBERS` と同じく union から導出する (#222)。 */ +const INPUT_TYPE_MEMBERS: Record = { + insertText: true, + insertLineBreak: true, + insertParagraph: true, + insertTab: true, + insertFromComposition: true, + insertCompositionText: true, + deleteCompositionText: true, + deleteContentBackward: true, + deleteContentForward: true, + deleteWordBackward: true, + deleteWordForward: true, + deleteSoftLineBackward: true, + deleteSoftLineForward: true, + deleteHardLineBackward: true, + deleteHardLineForward: true, + deleteByDrag: true, + deleteByCut: true, + historyUndo: true, + historyRedo: true, + insertFromPaste: true, + insertFromDrop: true, + insertFromYank: true, + insertReplacementText: true, + insertFromPasteAsQuotation: true, + insertFromInternalPaste: true, + replaceContent: true, +}; + +export const VALID_INPUT_TYPES: ReadonlySet = new Set(Object.keys(INPUT_TYPE_MEMBERS) as InputType[]); // 許可される入力タイプ const ALLOWED_INPUT_TYPES: readonly InputType[] = [ @@ -130,12 +144,12 @@ export function getProhibitedInputTypes(): readonly InputType[] { * イベントタイプが有効かチェック(実行時検証) */ export function validateEventType(type: unknown): type is EventType { - return typeof type === 'string' && VALID_EVENT_TYPES.has(type); + return typeof type === 'string' && VALID_EVENT_TYPES.has(type as EventType); } /** * 入力タイプが有効かチェック(実行時検証) */ export function validateInputType(type: unknown): type is InputType { - return typeof type === 'string' && VALID_INPUT_TYPES.has(type); + return typeof type === 'string' && VALID_INPUT_TYPES.has(type as InputType); } diff --git a/packages/shared/src/typingProof/TypingProof.ts b/packages/shared/src/typingProof/TypingProof.ts index 2c31b3c..e8f09be 100644 --- a/packages/shared/src/typingProof/TypingProof.ts +++ b/packages/shared/src/typingProof/TypingProof.ts @@ -42,6 +42,7 @@ import type { CheckpointCreatedHook } from './CheckpointManager.js'; import type { SignedCheckpointEnvelope } from '../types.js'; import { StatisticsCalculator } from './StatisticsCalculator.js'; import { isAllowedInputType, isProhibitedInputType } from './InputTypeValidator.js'; +import { evaluatePureTyping } from './structuralEdit.js'; import { waitForQueueDrain, type QueueDrainOptions, type QueueDrainResult } from './queueDrain.js'; import { sharedDebugLog } from '../utils/debug.js'; @@ -817,9 +818,11 @@ export class TypingProof { const proofString = JSON.stringify(proofData); const typingProofHash = await this.hashChainManager.computeHash(proofString); - // isPureTyping: 外部ペースト/ドロップがない場合はtrue - // 内部ペーストは許可されているため、isPureTypingには影響しない - const isPureTyping = stats.pasteEvents === 0 && stats.dropEvents === 0 && stats.bulkInsertEvents === 0; + // isPureTyping (advisory): 判定は structuralEdit の単一定義に委譲する (#235)。採点側の + // 再計算 (`verifyProofMetadata`) と同じ関数なので、proof に焼かれる自己申告と検証器の + // 結論が食い違わない。以前はここだけ `bulkInsertEvents === 0` という benign 除外なしの + // 式で、括弧自動閉じ (Monaco 既定 on) の `(` 1 つで false に落ちていた。 + const isPureTyping = evaluatePureTyping(events).isPureTyping; return { typingProofHash, @@ -836,6 +839,15 @@ export class TypingProof { /** * タイピング証明ハッシュを検証 + * + * **ここが返す `isPureTyping` は判定の 3 つ目の定義ではない** (#235)。本メソッドは + * `proofData.metadata` (自己申告のカウント) しか受け取らず events を持たないため、 + * 括弧自動閉じ等の benign 除外・内部ペーストの replay 検証・乖離 snapshot の検出 + * (すべて events が要る) を行えない。**メタデータのみからの粗い自己チェック**であり、 + * 採点側の結論 (`verifyProofMetadata` → `evaluatePureTyping`) とは一致しない + * (こちらの方が厳しく false に倒れる)。採点・表示に使う値は必ず events を渡せる + * `verifyProofMetadata` から取ること。 + * * @param typingProofHash - 検証するハッシュ * @param proofData - 証明データ * @param finalContent - 最終コード @@ -864,6 +876,7 @@ export class TypingProof { }; } + // events が無いためメタデータのみの粗い判定 (上の JSDoc 参照)。 const isPureTyping = proofData.metadata.pasteEvents === 0 && proofData.metadata.dropEvents === 0 && diff --git a/packages/shared/src/typingProof/structuralEdit.ts b/packages/shared/src/typingProof/structuralEdit.ts index 1ffddf2..1d2393e 100644 --- a/packages/shared/src/typingProof/structuralEdit.ts +++ b/packages/shared/src/typingProof/structuralEdit.ts @@ -201,3 +201,77 @@ export function isFlaggedBulkInsert(event: StoredEvent, sessionDerived: boolean) // 内部ペースト (自分のコード) の実挿入は許可。AI/外部の一括投入だけ残す。 return !sessionDerived; } + +/** `evaluatePureTyping` の結果。isPureTyping の内訳を呼び出し側へ返す。 */ +export interface PureTypingEvaluation { + /** advisory な「打鍵だけで書かれたか」。決定ロジックは `evaluatePureTyping` だけが持つ。 */ + isPureTyping: boolean; + /** isPureTyping を崩した bulk 挿入の件数 (benign / 検証済み内部ペーストを除いたもの)。 */ + nonBenignBulkInsertCount: number; + /** `isSuspiciousBulkInsert` の素のヒット位置。`metadata.bulkInsertEvents` の再計算値。 */ + suspiciousBulkInsertEventIndexes: number[]; + /** replay 文書と乖離した contentSnapshot の位置 (#175)。 */ + divergentContentSnapshotEventIndexes: number[]; +} + +/** + * advisory な `isPureTyping` の **単一の判定点** (#235)。 + * + * 以前は 3 箇所に別々の式があり、export 時の自己申告 (`TypingProof`) は + * `bulkInsertEvents === 0`、つまり benign 除外なしの厳しい式だった。Monaco の括弧自動閉じ + * (既定 on) で `(` を 1 つ打つだけで自己申告が false に落ち、採点側 (`verifyProofMetadata`) + * の true と食い違う値が proof に焼かれていた。判定はこの関数だけが持つ。 + * + * isPureTyping を崩すのは 4 つ: + * - 外部ペースト / ドロップ (禁止入力そのもの) + * - 「正規でない bulk 挿入」= (a) 従来の suspicious bulk (insertReplacementText / + * replaceContent / insertText>1 / 大きな内部ペースト) のうち benign (括弧自動閉じ・ + * 上限内の単一行補完) と検証済み内部ペースト (#138) を除いたもの、(b) 複数行の実コード + * 一括投入 (AI/snippet)。Monaco は (b) を `insertParagraph` で記録するので (a) の + * suspicious 判定には載らず、別途拾う必要がある + * - replay 文書と乖離した contentSnapshot (#175。挿入イベント無しで文書を丸ごと差し替え + * られる唯一の口) + * + * **`metadata.bulkInsertEvents` (= `suspiciousBulkInsertEventIndexes.length`) の定義は + * ここでも変えない。** `verifyProofMetadata` が申告値との完全一致を要求するため、変えると + * 既存 proof が invalid になる (`isSuspiciousBulkInsert` の警告コメント参照)。 + */ +export function evaluatePureTyping(events: readonly StoredEvent[]): PureTypingEvaluation { + let pasteEvents = 0; + let dropEvents = 0; + let nonBenignBulkInsertCount = 0; + const suspiciousBulkInsertEventIndexes: number[] = []; + const divergentContentSnapshotEventIndexes: number[] = []; + // #138: 内部ペーストの許可はマーカー (自己申告) でなく、replay で検証したセッション内在性。 + const ledger = new SessionProvenanceLedger(); + + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (!event) continue; + + if (event.inputType === 'insertFromPaste') pasteEvents++; + if (event.inputType === 'insertFromDrop') dropEvents++; + + // 乖離判定は台帳へ適用する**前**の文書に対して行う (適用後だと必ず一致してしまう)。 + if (isDivergentContentSnapshot(event, ledger.currentContent)) { + divergentContentSnapshotEventIndexes.push(i); + } + const sessionDerived = ledger.checkAndApply(event); + const suspicious = isSuspiciousBulkInsert(event); + if (suspicious) suspiciousBulkInsertEventIndexes.push(i); + if ((suspicious && !isBenignEditorInsert(event) && !sessionDerived) || isFlaggedBulkInsert(event, sessionDerived)) { + nonBenignBulkInsertCount++; + } + } + + return { + isPureTyping: + pasteEvents === 0 && + dropEvents === 0 && + nonBenignBulkInsertCount === 0 && + divergentContentSnapshotEventIndexes.length === 0, + nonBenignBulkInsertCount, + suspiciousBulkInsertEventIndexes, + divergentContentSnapshotEventIndexes, + }; +} diff --git a/packages/shared/src/verification.ts b/packages/shared/src/verification.ts index fded5c6..a8e4108 100644 --- a/packages/shared/src/verification.ts +++ b/packages/shared/src/verification.ts @@ -24,13 +24,8 @@ export { import { deterministicStringify, computeHash } from './utils/hashUtils.js'; import { computeExamChainRoot } from './exam/examPackage.js'; -import { - isBenignEditorInsert, - isFlaggedBulkInsert, - isSuspiciousBulkInsert, - SessionProvenanceLedger, -} from './typingProof/structuralEdit.js'; -import { isDivergentContentSnapshot, isTemplateInjectionData, offsetFromRange } from './typingProof/replay.js'; +import { evaluatePureTyping } from './typingProof/structuralEdit.js'; +import { isTemplateInjectionData, offsetFromRange } from './typingProof/replay.js'; import { POSW_ITERATIONS, EXAM_ROOT_BINDING_V2 } from './version.js'; import { verifyProofSignedCheckpoints } from './signedCheckpoints.js'; import { verifySessionStartToken, computeAnchoredChainRoot } from './sessionStartToken.js'; @@ -469,27 +464,8 @@ function recomputeProofMetadata(events: StoredEvent[]): ProofMetadataVerificatio let dropEvents = 0; let insertEvents = 0; let deleteEvents = 0; - const suspiciousBulkInsertEventIndexes: number[] = []; - // isPureTyping を崩す「正規でない bulk 挿入」の数。2 種を拾う: - // (a) 従来の suspicious bulk (insertReplacementText/replaceContent/insertText>1/大内部ペースト) - // のうち、括弧自動閉じ・単一行補完などの benign と、検証済み内部ペースト (自分の - // コードのコピペ = 許可。#138 の replay 検証に合格したもの) を除いたもの。 - // (b) 複数行の実コード一括投入 (AI/snippet)。Monaco は insertParagraph で記録するため - // (a) の suspicious 判定には載らないので別途拾う。空白のみの auto-indent と - // 検証済み内部ペーストの実挿入は除外。 - // bulkInsertEvents の申告メタデータ照合 (verifyProofMetadata) は従来どおり suspicious のみ - // 数えるので、既存 proof との後方互換と整合性チェックは保たれる。 - let nonBenignBulkInsertCount = 0; - // #175: replay 文書と乖離した contentSnapshot。挿入イベント無しで文書を丸ごと差し替え - // られる唯一の口なので、外部入力相当として isPureTyping を崩す (正規 snapshot は常に - // 一致する no-op なので既存 proof に影響しない)。claimed metadata との照合カウントには - // 含めない (後方互換)。 - const divergentContentSnapshotEventIndexes: number[] = []; - // #138: 内部ペーストの許可はマーカー (自己申告) でなく、replay で検証したセッション内在性。 - const ledger = new SessionProvenanceLedger(); - for (let i = 0; i < events.length; i++) { - const event = events[i]; + for (const event of events) { if (!event) continue; if (event.inputType === 'insertFromPaste') pasteEvents++; @@ -497,18 +473,15 @@ function recomputeProofMetadata(events: StoredEvent[]): ProofMetadataVerificatio if (event.inputType === 'insertFromDrop') dropEvents++; if (event.type === 'contentChange' && event.data) insertEvents++; if (event.inputType?.startsWith('delete')) deleteEvents++; - - if (isDivergentContentSnapshot(event, ledger.currentContent)) { - divergentContentSnapshotEventIndexes.push(i); - } - const sessionDerived = ledger.checkAndApply(event); - const suspicious = isSuspiciousBulkInsert(event); - if (suspicious) suspiciousBulkInsertEventIndexes.push(i); - if ((suspicious && !isBenignEditorInsert(event) && !sessionDerived) || isFlaggedBulkInsert(event, sessionDerived)) { - nonBenignBulkInsertCount++; - } } + // isPureTyping (advisory) の判定は structuralEdit の単一定義に委譲する (#235)。export 側の + // 自己申告 (`TypingProof.generateTypingProofHash`) も同じ関数を使うため、proof に焼かれる + // 申告値と採点側の結論が食い違わない。`suspiciousBulkInsertEventIndexes` (= 申告メタデータ + // 照合に使う bulkInsertEvents の再計算値) の定義は従来どおりで、後方互換は保たれる。 + const pureTyping = evaluatePureTyping(events); + const { suspiciousBulkInsertEventIndexes, divergentContentSnapshotEventIndexes } = pureTyping; + const totalTypingTime = events[events.length - 1]?.timestamp ?? 0; const averageTypingSpeed = totalTypingTime > 0 ? Math.round((insertEvents / (totalTypingTime / 60000)) * 10) / 10 : 0; @@ -526,11 +499,7 @@ function recomputeProofMetadata(events: StoredEvent[]): ProofMetadataVerificatio return { valid: true, - isPureTyping: - pasteEvents === 0 && - dropEvents === 0 && - nonBenignBulkInsertCount === 0 && - divergentContentSnapshotEventIndexes.length === 0, + isPureTyping: pureTyping.isPureTyping, recomputedMetadata, suspiciousBulkInsertEventIndexes, divergentContentSnapshotEventIndexes,