Git diffはclean。でもagent run中のfile操作は消えていないかもしれない。AfterimageでNTFS履歴を調べる

coding agentにひとつのタスクを頼んだあと、git diffを見ると何も変わっていない——そういう最終状態は、途中で何も起きなかったことを意味しない。

  • 一時ファイルを作って、すぐ消した
  • ファイルの内容を書き換えて、元のbytesに戻した
  • ファイルをrenameして、元の名前に戻した

こうした操作は、最終状態だけを見る限り痕跡が残らない。git diff・git statusはcleanのままだ。

USN Journalとは

NTFSは、ボリューム上のファイル・ディレクトリへの変更を記録する仕組み(USN change journal)を持っている。ここに記録されるのは、変更があったという事実と、その種類・タイムスタンプ・関係するファイル識別子であり、変更の内容そのものではない。誰が(どのプロセスが)その変更を行ったかを直接示すものでもなく、保持期間(retention)にも限界がある。

Afterimage

Afterimageは、この既存のUSN Journalを事後に読み、agentのsession時間窓とworking directoryのスコープに絞って、final diffには残らないファイルシステム上の痕跡を調査するCLIツールだ。USN journalを解析すること自体は既存の能力であり、Afterimageが発明したものではない。ここでの中心は、coding agentの作業に絞った小さな調査ワークフローだ。

事前起動が不要という前提

Procmonのような事前capture型のツールは、起動している間の詳細なイベントを記録する。Afterimageはそれとは違い、agentのrunが終わったあとに初めて起動しても、OSがすでに保持しているUSN Journalを読むだけでよい。ただし、これは「起動していなくても常に調査できる」という意味ではなく、対象のNTFSボリュームに十分な期間のjournalが実際に残っていることが前提になる。

session時間窓とworking directoryでスコープを絞る

Afterimageはbetween、since、agentの3つのサブコマンドを持つ。agentはClaude/CodexのJSONLログから最初と最後のtimestampとcwdを取り出し、開始側に5秒のマージンを加えて時間窓を作る。対応するログ形式は意図的に狭く、汎用のJSONLimportではない。–prefixで指定したworking directoryの範囲に、観測対象を絞り込む。

実際に試す

今回、公開リポジトリのtest.ps1(決定的なロジックテスト、権限もjournalアクセスも不要)を実際に再実行した。

.\build.ps1
.\test.ps1

今回の実行結果は次のとおりで、35/35 PASSだった。

PASS zero-events-complete
PASS failure-before-usable-batch-not-zero-success
PASS access-denied
PASS journal-wrap-or-replacement
PASS unprivileged-zero-not-negative-proof
...
PASS drive-root-scope-boundary
ALL_PASS=True tests=35

一方、live-tests.ps1によるNTFS journalへの実アクセスは管理者権限で新しい一時ディレクトリ上で実行する必要があり、今回のこの記事執筆環境では管理者権限を持っていないため実行していない。そのため、以下のcreate/delete・modify/restore・rename/backのシナリオと、その後のAfterimage実行結果は、公開リポジトリに同梱されているpublic-demo-result.json・HARDENING-REPORT.mdに記録された、以前のクリーンルーム環境での実行結果を紹介する(今回新たに実行したものではない)。

シナリオは次のとおりだ。

  1. 使い捨てのGitリポジトリを新規作成し、baseline commitを打つ
  2. ファイルを作成し、削除する
  3. 別のファイルの内容を変更し、元のbytesに戻す
  4. さらに別のファイルをrenameし、元の名前に戻す
  5. この時点でgit diff・git statusはclean
  6. ここで初めてAfterimageを起動する

記録されている結果は、status: COMPLETE、exit 0、observedInScope=16 files=3。つまり、最終状態がcleanに見える状況でも、スコープ内で16件の記録・3つのファイル識別子が観測された、ということだ。境界条件(file rename、parent rename、scope内外双方向のmove、parentの移動、rename後delete)を確認した別のケースでは、status: COMPLETE・observedInScope=36 files=8・outside=17だったことも記録されている。

4つのresult status

Afterimageのクエリ結果は、次の4状態のいずれかとして返る。

StatusExitMeaning
COMPLETE0保持されているrecordの範囲で、時間窓に基づくqueryが、観測されたcoverage checkとscope候補の解決を伴って完了した
PARTIAL3時間窓内の一部recordは読めたが、完全性チェックのいずれかが失敗した。scopeに一致する出力が空のままの場合もある
INCONCLUSIVE2時間窓についての使える証拠がなく、否定的な結論を支持できない。無効な入力や予期しないqueryエラーもここに入る
UNAVAILABLE4scopeやjournal自体を開けなかった、あるいは最初のqueryに失敗した

ここで重要なのは、「該当するrecordが0件」であることと「queryが失敗・不完全である」ことは違う、という点だ。読み取り失敗、進捗のない読み取り、壊れたrecord、アクセス拒否、journalの欠落、journalのwrapや置き換えの検出、名前解決の失敗、未解決のhistorical scopeは、いずれも黙って「成功したが空の結果」に変換されることはない。

そして、COMPLETEは「ファイルシステム操作の完全な履歴」を保証する言葉ではない。COMPLETEはあくまで「保持されているrecordの範囲についてのbounded queryが、観測されたチェックを通過して完了した」ことを意味する。0件のマッチであっても、それは「その時間窓でファイルシステム上の活動が一切なかった」ことの証明ではない。

公開されているnegativeライブクエリの一つでは、保持されているもっとも古いrecordより前の期間を指定した実行がstatus: INCONCLUSIVE、exit 2、reason: requested-start-before-oldest-observable-recordとして記録されている。同じデモ期間に対して非管理者権限で実行したケースはPARTIAL、exit 3、未解決recordがあり、activityはUNKNOWNとして扱われる。

historicalNameとcurrentResolvedPath

Afterimageが出力する各recordには、historicalName(USN recordに記録された名前)、parentFrn/frn/usn(recordの識別子)、pathCandidate/basis(そのUSN時点でのjournalのディレクトリ名・parentリンク、現在のディレクトリパスに紐づけられている場合もある)、そしてcurrentResolvedPath(現在時点で別途解決したパス。scope外の値はredactされる)が含まれる。

pathCandidateは「証明されたhistorical path」ではない。currentResolvedPathの解決に失敗した場合も、それは「未解決、または既に存在しない」ことを示すだけで、削除されたことの証明ではない。parentディレクトリのrename/moverecordは、そのイベントのUSN時点で使われ、要求した終了時刻より後のrecordも対象になり得る。欠落しているhistorical parentは推測されない。

privileged/unprivileged

管理者権限(privileged)で読み取ると、ボリュームからhistorical nameを取得でき、削除・移動されたエントリの調査がより有用になる。非管理者(unprivileged)の場合はフォールバック読み取りとなり、名前が省略されることがある。scopeの証拠が未解決のクエリはPARTIALとして報告され、単純な「該当なし」にはならない。

attribution labelは推測にすぎない

DURING_RUN・LIKELY_RELATED・BACKGROUND_LIKELYというラベルが付くことがあるが、これらはタイミングと名前に基づくヒューリスティックな分類であり、書き込みを行ったプロセスの証明でも、agentが実際にその操作を行ったという証明でもない。BACKGROUND_LIKELYは、record内に埋め込まれたPIDが、その時間窓より前から存在していたプロセスに解決される場合にのみ、名前の一致度を弱める。PIDの再利用、命名規則、すでに終了したプロセス、アクセス拒否があれば、この判定は制限される。

実際に何が嬉しいのか

agent runのあと、git diffがcleanな状態で、次のような疑問に対する調査の入口が欲しいときにAfterimageは意味を持つ。

  • 何かファイルを作って、消していないか
  • renameしていないか
  • 最終的なdiffには残っていない操作が、途中にあったのではないか

Afterimageは、その「保持されているUSN Journalの範囲で、何が観測できたか」を保守的に報告するツールだ。

Procmon・USN forensicツールとの違い

Procmonは、起動している間のプロセスに関連付けられた詳細なイベントを、事前capture型で記録する。既存のUSN forensicツー

制約

  • USN recordは変更理由をまとめて記録することがあり、すべての書き込みやその内容を保持しているわけではない
  • 現在のパス解決(live MFT)はアトミックなスナップショットではない
  • journalのretentionには限界があり、古い期間はINCONCLUSIVEになる
  • 20,000,000レコード・約30秒というscan上限があり、上限に達した場合は不完全な結果として扱われる
  • deleteされたparentchainのうち保持された証拠の外にあるもの、hard link、reparse point、特殊なファイルシステム構成は対象外
  • secret-likeな名前のmasking機能は便宜的なものであり、プライバシー保証ではない
  • journalを作成・リセット・削除したり、その設定を変更することはない。ファイルの内容を復元することもない

GitHub

ソースコード、テスト、公開 evidenceはこちらで公開している。

https://github.com/iwadjp/afterimage

関連するAI coding調査toolは、まとめページに整理しています。