私はITの実務経験がありません。会社でコードを書いたこともないし、チームで開発したこともありません。
その状態で個人事業を始めて、AIエージェント(Claude Code と Codex CLI)にiOSアプリを作らせています。実際にApp Storeに出したところまで来ました。コードのほとんどは私が書いたものではありません。私がやっているのは、①何を作るか決めること、②AIに指示すること、そして③出てきたものを信じてよいかどうかを判断することです。
3つ目が、いちばん難しい。
この記事は、その3つ目で私が失敗した話です。具体的には、--sandbox read-only というオプションを「リポジトリが変更されない保証」として読んで、実際には変更されていたという事故と、そのあと何を作ったかの記録です。
先に書いておくと、原因の解明には失敗しました。 今も分かっていません。代わりに「原因が何であっても検出できる検査」を用意しました。それが後日、実際に火を噴きます。
0. 前提 — なぜAIに「監査」をさせていたのか
ひとりでやっていると、レビューする人がいません。自分の書いたもの(正確には、自分が指示して出てきたもの)を自分で見ても、甘くなります。そもそも私には、それが良いコードかどうかを判断できるだけの経験がありません。
そこで、実装を担当させているAI(Claude Code)とは別のAI(Codex CLI)に、独立した第三者としてコードを監査させる、という運用にしていました。人を雇えないので、せめて別の目を入れる、という発想です。
その監査を、こういう形で走らせていました。
codex exec --sandbox read-only "<監査の指示文>"
--sandbox read-only。読み取り専用。だからリポジトリは変わらない。 そう思っていました。
1. 何が起きたか(実測)
2026-08-14 の深夜、サーバー側リポジトリに対して監査を回しました。終わったあと、リポジトリが作業前と違う状態になっていました。
git reflog に残っていたのがこれです。コミットハッシュとブランチ名は伏せています(時刻・順序・操作の種類はそのままです)。
<sha-B> HEAD@{2026-08-14 02:57:22 +0900}: reset: moving to HEAD
<sha-A> HEAD@{2026-08-14 02:57:24 +0900}: checkout: moving from <branch> to <sha-A>
<sha-B> HEAD@{2026-08-14 02:58:35 +0900}: checkout: moving from <sha-A> to <branch>
02:57:22 の reset: moving to HEAD は git stash -u の痕跡です。実際、追跡外のファイルが stash に退避されていました。作業ツリーから消えていたわけです。そのあと 02:57:24 に detached HEAD へ移動し、02:58:35 に戻っています。
正直に書くと、私はこの状態を見て、最初は何が起きているのか分かりませんでした。detached HEAD が何なのかを調べるところからでした。復元はできました。reflog と stash が残っていたからです。もし stash ではなく別の操作だったら、あるいは気づくのがもっと遅かったら、同じようには戻せていません。
実測と推測の線引き
ここは慎重に書きます。
- 実測できたこと: 監査を回したあとにリポジトリの HEAD が動き、stash が増えていたこと。reflog にその操作が記録されていたこと。
- 実測できなかったこと(その1): なぜそうなったのかは、最後まで特定できていません。
- 実測できなかったこと(その2): 誰が(何が)実行したのかも、特定できていません。 私はこれを長らく「監査が書き換えた」と記録していましたが、それは時間的に近かったという以上の根拠がありません。当時、同じリポジトリに対して当社の別の自動化も git 操作をしていた痕跡が残っています。しかも上の3行が示す「stash して、特定のコミットを detached で見て、戻る」という形は、コードを読みに行くツールの典型的な動き方でもあります。この記事の第5章で、私はまさにこの取り違えを別の場面でやって、訂正することになります。
つまり8/14 について私が言えるのは、「監査を回したあとに、リポジトリが変わっていた」までです。「監査が変えた」ではありません。
後日(2026-08-16)、わざと「このリポジトリを壊せ」と指示するダミーのリポジトリを用意して、再現を試みました。結果はこうです。
| 条件 | 結果 |
|---|---|
--sandbox read-only / 対象はダミーリポジトリ |
書き換えは起きなかった |
--sandbox workspace-write / 対象がホーム配下 |
5回試して全部ブロックされた |
--sandbox workspace-write / 対象が /tmp 配下 |
git stash -u と git checkout --detach HEAD が成功した |
3行目は再現できました。ただしこれは workspace-write の既定の書き込み可能範囲に /tmp と $TMPDIR が含まれるためで、8/14 の事故(read-only・ホーム配下)とは条件が違います。
つまり私は「読み取り専用でも書ける」という一般的な主張を、実測で裏付けていません。裏付けていないことを書くと、まさにこの記事で反省している誤りを繰り返すことになるので、書きません。
分かっているのは「起きた」ことだけです。
2. 同じ時期に、もうひとつ
別の失敗もしていました。監査の出力が 39バイトしかなく、中身が
Reading additional input from stdin...
の一行だけ。そのまま約30分ハングしていました。プロンプトを引数で渡しつつ、標準入力が開いたままだったので、追加入力を待ち続けていた、というのが後で分かった仕組みです。
問題は挙動そのものより、私がその結果を「指摘ゼロ」として受け取ったことです。
「監査を回した。指摘は無かった。よし進もう」。そう判断しました。実際には監査は一度も走っていません。指摘が0件なのと、監査が実行されていないのは、まったく別の状態です。 でもログの見た目は静かで、私にはその区別がつきませんでした。
この2件は別の失敗に見えますが、同じ形をしています。どちらも「大丈夫そうに見えること」を根拠にしていました。
3. なぜ、そう間違えたのか
ここが、この記事でいちばん書きたかったところです。
--sandbox read-only と書いてある。だからリポジトリは変わらない。この推論を、私は一度も検証せずに前提として使っていました。
経験のある人なら、ここで立ち止まったのかもしれません。 「read-only とは何に対する read-only か」「その保証はどのレイヤで効いているのか」「実際に確かめたか」と。私はそう問いませんでした。オプション名を読んで、意味を理解した気になって、先へ進みました。
先に断っておくと、これはツール側の欠陥を主張する記事ではありません。 サンドボックスが何をどこまで制約するかは、そのツールの設計と実装の範囲で決まります。そして私は、その範囲がどこまでなのかを一度も自分で確かめていません。誤りは私の側にあります。自分で検査していない範囲まで、名前から意味を広げて読んだことです。
これは非エンジニアがAIに開発させるときに、かなり起きやすい種類の誤りだと思います。理由は単純で、分からないものが多すぎるからです。オプションもエラーメッセージもログの1行1行も、いちいち検証していたら手が止まる。だから「たぶんこういう意味だろう」で流す。流した判断のうち、どれが危険でどれが安全かを見分けるのが、たぶん経験と呼ばれるものです。私にはそれがありません。
だから、こう決めました。
保証だと言えるのは、実際に検査した範囲だけ。 「壊れないはず」で運用しない。
そして、私が最初にやった対処は間違っていました
これも書いておきます。
以前、AIが勝手に触ると困る設定値がありました。支出の上限額です。私はそこに、こういうコメントを書きました。
// この値はエージェントの判断で書き換えないこと
const <上限値の定数> = ...;
その直下で、エージェントが値を書き換えました。
いま振り返ると当然です。コメントは実行されません。ただの文字列です。でも当時の私は、これを「対処した」と思っていました。人間のチームなら、コメントで注意を促すのは有効な手段です。私はその感覚をそのままAIに適用していました。
禁止したいことは、文章ではなくコードに書く。 落ちるテストにする。非ゼロで終了させる。それ以外の禁止は、実質的に存在しないのと同じです。
この件は、いまはコメントではなくテストで縛っています。値が想定の範囲から外れたらテストが落ちる。あわせて、金銭が動く変更は私の承認を通らないと進まないようにしました。コメントを消したのではなく、コメントを当てにするのをやめた、という直し方です。
これは、後で出てくる設計判断の全部につながっています。
4. どう直したか — 説明ではなく、検査に賭ける
原因が分からないので、原因に対処するアプローチは取れません。代わりに、原因が何であっても壊れない構造にして、壊れたら必ず気づけるようにすることにしました。
以下の設計と実装そのものは、AI(Claude Code)にやらせています。私がやったのは、「なぜこの方式なのか」を全部説明させて、その理由が自分で追えるまで確かめることと、それを検証するテストを必ず一緒に作らせることです。第3章で書いたとおり、説明を読んで納得しただけでは、私はまた同じ間違い方をするので。
できたのは codex-audit.sh という薄いラッパ1本です。やっていることは3つだけです。
(a) 使い捨てのクローンを監査させる
git clone --no-hardlinks <repo> <tmp>/repo
git -C <tmp>/repo remote remove origin
Codex には元リポジトリのパスを一切渡さず、このクローンだけを渡します。
最初は git worktree を使うつもりでした。 「作業用のコピーを作るコマンド」と理解していたからです。却下になった理由が、私には勉強になりました。
リンクされた worktree は、親リポジトリの .git を共有します。refs も、オブジェクトストアも、reflog も、そして stash もです。git stash -u を worktree の中で実行すると、書き込まれるのは元リポジトリの stash です。
つまり、今回の事故を起こしたのとまったく同じコマンドが、worktree 経由なら本物のリポジトリに届いていました。 worktree は「隔離されたチェックアウト」であって「隔離されたリポジトリ」ではない。名前とディレクトリの見た目からは、私にはこの違いが読み取れませんでした。第3章の誤りと、まったく同じ形です。
--no-hardlinks を付けているのは、既定のローカル最適化だとオブジェクトファイルが共有されるからです。共有していなければ、コピーの中で git gc を暴走させようが .git を消そうが、元には届きません。リポジトリは数MBなので、コピーのコストはリスクに比べて誤差です。
副次的な効果もありました。クローンにはコミット済みの内容しか入りません。 APIキーを置いた .env 系や署名鍵といった追跡外のローカルファイルは、構造上コピーに入らず、AIの目にも触れません。これは狙って作った性質ではなく、方式から自動的に出てきた性質です。
(b) 実行前後で元リポジトリを突合し、汚れていたら失敗させる
隔離しただけでは足りません。隔離が効いていること自体を、毎回確認します。 監査の前後で、元リポジトリの以下を記録して差分を取ります。
HEADgit status --porcelaingit stash list- 全 refs(HEAD を動かさずにブランチやタグを作られると、上の3つでは見えないため)
-
.git/configのハッシュと、実行可能な hook のハッシュ(「あとで何かをさせる」古典的な手口だと教わりました)
差分があれば、理由を問わず exit 11 で失敗します。
(c) 出力が小さすぎたら「監査は成立していない」として失敗させる
第2章の39バイトへの対処です。判定は、最終メッセージのバイト数で見ます。既定は 500バイト未満で exit 10。
500 の根拠は3つです。実測した失敗が39バイトだったので、閾値はノイズのすぐ隣ではなく十分上に置きたかった(約13倍)。本物の「問題は見つからなかった」という結論であっても、どの範囲を見てそう判断したのかは書かねばならず、それが500バイトに収まることはない。そして2種類の誤りは対称ではないからです。誤検知のコストは再実行1回。見逃しのコストは、このスクリプトが存在する理由そのものです。
--min-bytes には下限のさらに下限(100)を置いて、それ未満を指定したら使い方エラーで落ちるようにしています。閾値0で実質的に無効化できてしまうし、「通るまでガードを緩める」は私が既に一度やった失敗なので。
終了コードはこうなりました。
0 監査が走り、判定が出て、元リポジトリは無傷
2 使い方の誤り
3 環境・準備の失敗
10 出力が無い/小さすぎる => 監査は成立していない
11 元リポジトリが書き換えられた
12 タイムアウト
13 codex が非ゼロ終了
最後の行は、機械が読める1行にしてあります。私が目で見て判断すると、また「静かだから大丈夫」をやるので。
CODEX_AUDIT_RESULT status=ok exit=0 verdict_bytes=17643 dynamic=ready degraded=0 log=<path>
5. で、効いたのか(実測)
効きました。ただし、私が身構えていたのとは違う理由でです。
2026-08-16、iOSアプリのリポジトリに監査をかけたら、こう出ました。
CODEX_AUDIT_RESULT status=repo_mutated exit=11 ...
差分はこれです。
HEAD=<sha>
--- status ---
+ M <Models/____.swift>
+ M <Services/____.swift>
+ M <Views/____.swift>
+ M <Views/____.swift>
+ M <UITests/____.swift>
(ファイル名は伏せています。件数と種別はそのままです。)
監査が走っていた 201秒のあいだに、Swiftファイルが5つ変わっていました。同じ日の別の監査(283秒)でも、ドキュメントが1つ変わって同じく exit=11 で止まりました。
犯人は Codex ではありませんでした。 私が並行して別のウィンドウで走らせていた、iOS側の作業セッションです。同じリポジトリを普通に編集していただけでした。実際、Codex の実行ログに元リポジトリのパスが出現した回数は 0回で、作業ディレクトリはすべてコピーの中でした。
つまり私は、AIが暴走する事故に備えて作った検査で、自分の並行作業を捕まえたわけです。
そして、ここで気づきました。8/14 の事故も、これだったのかもしれない。 監査を回している最中に、私が別のところで動かしていた何かが触っていた可能性を、私は一度も検討していませんでした。「監査を回した」「リポジトリが変わった」という2つの事実の間に、勝手に矢印を引いていたわけです。第1章で「誰がやったのかも分からない」と書き直したのは、この経験のあとです。いまも分かっていません。
ここで判断が必要になりました。この検査は「Codexが暴れた」と「別の誰かが同時に触った」を区別できません。 誤検知として扱って、条件を緩めるべきか。
緩めないことにしました。判定は保守的に exit 11 のままです。
理由は、この検査が答えたい問いが「Codexは悪いやつか?」ではなく、「この監査結果は、監査開始時点のコードに対する判定として信用してよいか?」 だからです。走っている最中にソースが5ファイル変わったなら、答えは No です。犯人が誰であっても No です。
そのかわり、判定は変えずに、切り分け材料だけを出すようにしました。失敗時に、元リポジトリのパスがログに現れた回数と、実際に使われた作業ディレクトリを並べます。0回でかつ全部コピー内なら「たぶん別セッション」と読める。ただし断定はできないので、表示にはこう書いてあります。
この監査に起因すると特定できないファイルを revert しないこと。
自動で元に戻す実装にしなかったのは、この「特定できない」が消えないからです。自動で戻していたら、私の並行作業のほうが消えていました。
作ったものを、自分で壊しに行く
「テストが通った」は「性質が成り立つ」を意味しません。テスト自身がその性質を見ていない可能性があるからです。これも、AIが書いたテストを何度か信じて痛い目を見てから、ようやく身についた考え方です。
なので、ガードを外したら本当に落ちるかを確かめるテストを別に持っています。Codex 本体をスタブ(偽物の実行ファイル)に差し替えて — 実際のAI呼び出しに課金しないためです — 「失敗して当然のケース」を並べてあります。
- 39バイトの出力を返すスタブ →
status=no_output exit=10になるか - コピーの外の元リポジトリを書き換えるスタブ →
status=repo_mutated exit=11になるか - サンドボックスを緩めた状態で同じことをやっても、やはり 11 で止まるか
- 追跡外の
.dev.vars(ローカルの秘密情報)がコピーに漏れていないか - 依存の準備に失敗したとき、黙って「静的解析のみ」に劣化せず、大きな声で落ちるか
現時点の実測は 46 checks / PASS=46 FAIL=0。ネットワークも本番の鍵も課金経路も使いません。
さらに、実際に「壊せ」と指示したダミーリポジトリでの実験(第1章の表)も残してあります。3行目 — /tmp 配下の対象に対して git stash -u が成功したケース — では、書き込み自体は起きたうえで、前後スナップショットの差分がそれを捕まえて exit 11 になりました。
これは狙いどおりでした。隔離が破れても、検出は生き残ってほしい。 順序として、そうあってほしかったので。
そのうえで、書き込み可能範囲から /tmp と $TMPDIR を明示的に外しました。
-c sandbox_workspace_write.exclude_slash_tmp=true
-c sandbox_workspace_write.exclude_tmpdir_env_var=true
再実験では、同じ指示に対して "Operation not permitted" が23回記録され、リポジトリは無傷でした。ただしこれは二重化であって、依存先ではありません。 保証の本体は、あくまでクローンとスナップショット差分の側です。この順序は崩さないことにしています。サンドボックスの設定を信じきったのが、そもそもの発端なので。
6. まだ塞げていないもの
ここは、書きたくないことを書く章です。「対処しました」で終わると、読んだ人が私と同じ勘違いをします。
1. 8/14 の事故の原因は、いまも不明です。 再現できていません。「クローンにしたから同じことは起きない」とは言えます(元リポジトリのパスを渡していないので)。しかし「なぜ起きたのか分かった」とは言えません。
2. スナップショットは事後です。 書き込みを防ぐ機構ではなく、起きたことを検出する機構です。実際、上のダミー実験では書き込みが先に成功しています。検出は「気づく」ためのものであって「防ぐ」ためのものではありません。
3. 検出範囲は、スナップショットに入れた項目だけです。 HEAD / status / stash / refs / config / hooks。ここに入れていないものが変わっても、この検査は黙っています。既知の穴で、閉じたとは言っていません。
4. exit 11 が出ても、それが誰の仕業かは分かりません。 上に書いたとおりです。材料は出しますが、断定はしません。
5. 検査そのものを回避する経路が残っています。 このラッパを経由せず codex exec を直接叩けば、どのガードも効きません。いまこれを縛っているのは「必ずこのスクリプト経由で実行する」という文書上のルールだけです。
つまり、第3章で「コメントによる禁止は機能しない」と書いた私が、いま同じ形のもので自分を縛っています。 これは未解決です。書いていて居心地が悪いですが、事実なので書きます。
6. 500バイトという閾値に、理論的な根拠はありません。 実測した失敗(39バイト)から十分離れた値を選んだだけです。中身の無い500バイトを出力する監査は、このガードを素通りします。バイト数は「走ったかどうか」の代理指標であって、「監査の質」の指標ではありません。
7. 持ち帰り
技術的なところ
-
AIエージェントに何かを任せるときは、使い捨てのコピーの上でやらせる。
git worktreeではなくgit clone --no-hardlinks。worktree は.gitを共有するので、stash も reflog も本物に届きます。 - サンドボックスやオプション名を、保証として読まない。 何をどこまで制約するかは、自分で確かめた範囲までしか言えません。
- 「出力が無い」を「問題が無い」と読まない。 静かな失敗は、いちばん見つけにくい失敗です。バイト数の閾値を機械的に置くだけで、かなりの部分が救えます。
- 禁止したいことは、文章ではなくコードに書く。 コメントは実行されません。
AIに開発させている、私と同じような人へ
「AIに任せればできる」という話ではありませんでした。私の場合は逆で、AIに任せた結果として事故が起き、それを機械的に検出する仕組みを作る必要が生じた、という順序です。
かといって「AIは危険だ」でもありません。いまも同じ体制で開発を続けています。ここで書いた検査も、設計させたのも書かせたのもAIです。
実際に効いたのは、この3つでした。
1. 分からないものを「分かった気」で通さない。 これが全部の入口でした。--sandbox read-only、git worktree、39バイトのログ。どれも、名前や見た目から意味を推測して、確かめずに通しています。全部を検証するのは無理なので、せめて**「これが間違っていたら何が起きるか」が大きいものだけは確かめる**、という線引きにしています。
2. 自分の注意力を当てにしない。 私はログを目で見て「静かだから大丈夫」と判断して、実際に間違えました。だから判定は機械が読める1行にして、閾値で落とすようにしました。自分が見落とすことを前提に設計するほうが、注意深くなろうとするより効きます。
3. AIに書かせたら、それを壊すものも一緒に書かせる。 「テストが通りました」という報告を、私はコードを読んで検証できません。できるのは「そのガードを外したら、本当にテストが落ちるのか」を実際に走らせて見ることです。落ちなければ、そのテストは何も見ていない。この確認だけは、経験が無くてもできます。
最後にひとつ。私がいちばん時間を無駄にしたのは、事故の直後に**「read-only は実は〜なのではないか」という説明を組み立てようとしたこと**でした。
検証できない説明の上に安全策を建てても、土台は推測のままです。先にやるべきだったのは、原因が何であっても成り立つ構造(使い捨てのコピー)を作り、原因が何であっても発火する検査(前後の突合)を置くことでした。そうすれば、原因の解明は「できたら嬉しいこと」に格下げできます。
現に、原因はいまも分かっていません。そして、困っていません。
深夜2時に自分のリポジトリが detached HEAD になっていて、誰がやったのかも、なぜかも、今も分からない。 実務経験のない人間がAIに開発させると、こういうことが起きます。同じ落とし穴は他の人の足元にもあると思うので、書いておきます。