Featured image of post 確認を全部許可にしたら、AIが検証環境のデータを消した。読む確認だけ残す【許可リスト】

確認を全部許可にしたら、AIが検証環境のデータを消した。読む確認だけ残す【許可リスト】

確認が一日三百回出るのに疲れて AI の操作を全部許可にしたら、AI が検証環境のデータを消した。通す・聞く・止める操作を先に決めて、読む確認だけを残す仕組み(許可リスト)を TypeScript と node --test で書きます。

第1幕: 現場 ── 三百回の「はい」

AI がコマンドを打つたびに出る確認に、俺は一日三百回、「はい」を押していた。二週間前に、それを全部やめた。確認を出さずに、AI の操作を全部通す設定にした。そして昨日、AI が確認なしで打った一行が、取引先が使っている検証環境のデータを消した。

それでも俺は、今でも思っている。読まずに押す確認は、無いのと同じだ。

午後二時。空港の貨物地区の、保安ゲートの詰所。海から吹いてくる風が強くて、詰所の横の旗竿の綱が、金具に当たってカンカン鳴っていた。俺は先輩の瀬尾さんと二人で、詰所の外のベンチの前に立っていた。

今日は、鷹匠が来る。うちの倉庫の軒下にハトが住みついていて、空港が鳥の追い払いを頼んでいる業者に、会社がついでに倉庫のほうもお願いしたらしい。空港の制限区域に入るには、許可を受けた人の付き添いが要る。社員の入構証の手続きは瀬尾さんの仕事なので、付き添いも瀬尾さんの役目になった。俺は、瀬尾さんに連れてこられた。ノートパソコンを持ってこい、と言われた。今日中に、AI の設定を直させるつもりなのだ。

鷹匠を待っているつもりだった。

詰所の窓口をのぞくと、もう着いていた。カウンターの上に、臨時の通行証が一枚置いてある。顔写真の横に、色の違う小さな四角い印がいくつか並んでいる。入っていい区域の印だ、と瀬尾さんがあとで教えてくれた。警備員が、その印を一つずつ指でたどりながら読み上げている。貨物地区。エプロンの東側。誘導路わきの草地は、管制と交信のうえで。

窓口の前に立っているのは、若い女の人だった。薄手の上着の裾が、風であおられている。ポケットの多いズボン。左の手首から肘までを、革の手袋が覆っていた。その拳の上に、茶色い鳥がいた。

鷹だった。

濃い茶色の羽に、肩だけが赤みがかっている。尾の先に、白い線が一本入っている。ちょうど詰所の前を、パレットを積んだトラックが通りすぎた。地面が少し揺れるくらいの音がした。鷹は首だけを少し回して、拳の上から一歩も動かなかった。

「鷹宮スズさん。区域、以上で間違いないですね」

警備員が言った。女の人は、はい、とだけ答えた。それ以外は何も言わなかった。待たされているのに、急かす様子もなかった。

瀬尾さんが窓口に入って、付き添いの書類に署名した。それから、鷹宮さんがこちらを向いた。

「鷹宮です」

名前だけだった。俺は、どうも、と小さく頭を下げた。鷹のほうは、俺のことを見もしなかった。

瀬尾さんが運転する作業車で、貨物地区の倉庫へ向かった。鷹宮さんと鷹は後部座席に乗った。俺は助手席で、膝の上のノートパソコンを開いていた。開いただけで、手は動かなかった。

昨日のことを、順に思い出していた。

一昨日の夕方、取引先から連絡が来た。取引先は、航空貨物をうちの倉庫に預ける会社だ。うちの社内アプリの新しい画面を、本番に出す前に試してもらっている。試しているのは、検証環境のほうだ。検証環境というのは、取引先や社内の人が動きを確かめるための、本番に似せたもう一つの環境のことだ。データベースも、本番とは別に一つある。そこで、予約の一覧の並びがおかしい、と言われた。

俺は不具合を再現するために、手元の .env の DATABASE_URL を、検証環境のデータベースに向けた。.env は、接続先やパスワードのような、環境ごとに変わる値を書いておくファイルだ。DATABASE_URL は、アプリがどのデータベースにつなぐかを決める値になる。再現はできた。直しかたの見当もついた。そこで、その日は帰った。

戻すのを、忘れた。

昨日の午後、俺はコーディングエージェントに、予約の一覧のテストが落ちているので直してほしい、と頼んだ。コーディングエージェントというのは、こちらが用件を書いて渡すと、必要なファイルを自分で開き、直し、コマンドを打って確かめるところまで進めてしまう AI のことだ。うちで使っているのは Claude Code だ。社内アプリのコードの多くは、AI に書かせている。

夕方、別の件で、検証環境の管理画面を開いた。予約の一覧が、空だった。

一件も無かった。取引先が受け入れ試験のために、二週間かけて入れてくれた試験用の予約が、全部消えていた。

AI とのやりとりの記録をさかのぼった。テストを直す途中で、AI はこう打っていた。

1
npm run db:reset

その前に、テストデータを作り直します、という AI の説明が一行あった。npm run db:reset は、package.json の scripts に登録したコマンドを走らせる。npm run のあとに、登録した名前を書く。db:reset は、データベースの表を全部消して、空から作り直す。手元で開発するときに、データを初期の状態に戻すために作ったものだ。

AI がそれを打ったとき、.env は検証環境を向いていた。

夜のうちに、前の晩のバックアップから戻した。昨日の昼間に取引先が入れた分は、戻らなかった。受け入れ試験は、一日延びた。

作業車は、貨物地区の中の道を、ゆっくり走っていた。倉庫の壁が続いている。シャッターの前で、フォークリフトが行き来している。

うちの会社では、AI がコマンドを打つたびに、確認の画面が出る。このコマンドを実行していいですか、と聞いてくる。俺が「はい」を押すまで、AI は待っている。会社の設定で、そうなっている。

Claude Code には、AI の操作が危ないかどうかを別の AI に判断させて、確認を減らすモードもある。auto モードという。今の Claude Code では、そのモードが最初から入っている。でもうちでは、会社の設定で切ってある。判断する仕組みを、社内でまだ審査していないからだ。切ったのは、瀬尾さんだ。

最初は、一つずつ読んでいた。何をしようとしているのか、目で追ってから押した。でも、AI はよく働く。テストを走らせ、lint(コードの書き方の誤りや、決まりから外れた書き方を探す道具)をかけ、型を確かめ、またテストを走らせる。そのたびに確認が出る。一日に三百回を超えたころから、俺は読まなくなった。画面に何か出たら、「はい」を押す。手が覚えた。

読まずに押すのなら、確認があってもなくても同じだ。そう考えて、二週間前に、Claude Code を --dangerously-skip-permissions という指定を付けて起動するようにした。確認を一つも出さずに、AI の操作を全部通すモードになる。全許可、と俺は呼んでいる。公式のドキュメントには、コンテナや仮想マシンのように、壊れても困らない場所でだけ使え、と書いてある。読んだ。読んだうえで、自分のノートパソコンだし、どうせ読まずに押しているのだから同じだ、と思った。

二週間、何も起きなかった。速くなった。それは本当だった。

今朝、瀬尾さんに全部話した。

瀬尾さんは、最後まで黙って聞いていた。聞き終えてから、全部確認に戻せ、と言った。一回ずつ読め、と言った。それだけだった。

三百回を一回ずつ読めるんですか、と言い返したかった。言わなかった。消したのは俺だからだ。

作業車の中は静かだった。エンジンの音と、ウインカーの音だけがしていた。倉庫の角を曲がるとき、瀬尾さんが前を見たまま言った。

「取引先には、朝のうちに電話した」

俺が、すみません、と言うと、瀬尾さんは何も言わずにハンドルを切った。

作業車が、貨物地区の奥のゲートを抜けた。後ろの席で、鷹宮さんが、手のひらに収まるくらいの無線機のつまみを回していた。ザッ、という短い雑音がして、すぐに消えた。何に使うのかは、俺には分からなかった。

第2幕: 再現 ── 三百十二行

倉庫の前の、エプロンの端で車が停まった。エプロンというのは、空港の中で、飛行機が停まったり荷物を積み下ろしたりする舗装された場所のことだ。うちの倉庫の前は、その東の端に当たる。飛行機は通らない側で、貨物を載せたパレットと、それを運ぶ車が行き来している。

倉庫の軒の下に、梁が一本通っている。その上に、ハトが並んでいた。二十羽はいる。下に積んだパレットのシートに、白いものが点々と落ちている。倉庫の現場の人たちが、この春から毎日シートを拭いている、と聞いていた。お客さんの荷物の上だから、放っておけない。

鷹宮さんは車を降りると、無線機には触らずに、軒のほうへ歩いていった。軒下の十メートルほど手前まで来ると、拳を少し前に出した。

鷹が、拳を蹴って飛んだ。

低く、速かった。軒の下の暗がりに、まっすぐ入っていった。ハトの列が崩れた。羽音がいっぺんに軒の下にこもって、鳥たちは次々に外へ飛び出し、ばらばらにほどけて、エプロンの外のフェンスの向こうへ散っていった。鷹は軒下をくぐり抜けて、大きく弧を描いて戻ってきた。鷹宮さんが拳を上げる。鷹は、その上にとまった。笛も、声も、使わなかった。

鷹宮さんは、拳の上の鷹の足元を見ていた。

俺は、作業車のボンネットにノートパソコンを置いた。瀬尾さんは車の横で、誰かと電話をしている。

報告書には、数字が要る。昨日、AI は何をしていたのか。確認があったら、俺は何回押していたのか。

Claude Code は、AI とのやりとりを、手元のファイルに残している。AI が書いた文章も、AI が使った道具も、打ったコマンドも、全部入っている。一行に一件ずつ、JSON の形で並んでいる。拡張子は .jsonl だ。ただ、このファイルの形は、公式に説明されている仕様ではない。俺の手元の Claude Code(2.1.302)で見えた形だ。昨日のファイルから、AI がコマンドを打った行を一つ抜き出すと、使うところはこうなっている。

1
2
3
4
5
6
7
8
{
  "type": "assistant",
  "message": {
    "content": [
      { "type": "tool_use", "name": "Bash", "input": { "command": "npm run db:reset" } }
    ]
  }
}

type が assistant の行が、AI の側の発言だ。message.content の中に、AI が書いた文章や、AI が道具を使った記録が並ぶ。tool_use が道具を使った記録で、name が道具の名前。コマンドを打つ道具は Bash という。input.command が、打ったコマンドそのものだ。

この行から、コマンドを取り出す関数を書いた。

replay.ts に書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 会話の記録(.jsonl)から Bash の command を取り出す。
// 書きかけの最後の行など、読めない行は飛ばす
export function extractCommands(jsonl: string): string[] {
  const commands: string[] = [];
  for (const line of jsonl.split('\n')) {
    let entry: any;
    try {
      entry = JSON.parse(line);
    } catch {
      continue;
    }
    if (entry?.type !== 'assistant' || !Array.isArray(entry.message?.content)) continue;
    for (const block of entry.message.content) {
      if (block?.type === 'tool_use' && block.name === 'Bash' && typeof block.input?.command === 'string') {
        commands.push(block.input.command);
      }
    }
  }
  return commands;
}

ファイルの中身を改行で一行ずつに分けて、JSON.parse で JavaScript の値に直す。JSON.parse は、JSON の文字列を読んで、オブジェクトや配列に直す関数だ。読めない文字列を渡すと、例外を投げる。Claude Code が書いている途中のファイルを読むと、最後の行が書きかけのことがあるので、try と catch で受けて、その行は飛ばす。entry に付けた any は、TypeScript に「この値の形は問わない」と伝える型だ。記録の形は決まった仕様ではないので、?. で一段ずつ、そのキーが無くてもエラーにならずに undefined になるようにたどっている。Array.isArray は、値が配列かどうかを確かめる。

本物の記録には、社内のファイルの場所や、取引先の名前が混じる。だからリポジトリには入れない。テストには、記録と同じ形の三行を自分で書いて使った。テストのために用意する、こういう決まった入力を fixture と呼ぶ。

テストに使う三行は、fixtures.ts に置いた。

1
2
3
4
5
6
// 会話の記録の3行。Bash は2件、Read とツール呼び出しの無い行は取り出さない
export const transcript = [
  '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"npm test"}}]}}',
  '{"type":"assistant","message":{"content":[{"type":"text","text":"テストを直します"},{"type":"tool_use","name":"Read","input":{"file_path":"src/booking/list.ts"}}]}}',
  '{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"npm run db:reset"}}]}}',
].join('\n');

テストは allowlist.test.ts に書いた。

1
2
3
test('会話の記録の3行から、Bash の2件だけを取り出す', () => {
  assert.deepEqual(extractCommands(transcript), ['npm test', 'npm run db:reset']);
});

test は、テストの名前と中身を渡して、テストを一本登録する関数だ。assert.deepEqual は、配列やオブジェクトを中身まで比べて、等しいかを確かめる(equal だと、中身が同じでも別々に作った配列は等しくならない)。どちらも Node に付いてくる node:test と node:assert から読み込む。二行目の Read は、ファイルを読む道具だ。コマンドではないので、取り出さない。

昨日のファイルに当てると、コマンドがずらりと出てきた。そこから、ls や git status のような、読むだけのコマンドを除いた。読むだけのコマンドは、もともと確認が出ない。どれが確認なしで通るかは、公式のドキュメントに一覧がある。

残ったのは、三百十二件だった。

全許可にしていなければ、昨日、俺はこれだけ「はい」を押していたことになる。

次に、回数を数えた。同じコマンドを何回打ったか。多い順に並べる。

(replay.ts の続き)

1
2
3
4
5
6
// 回数の多い順。同数は文字列の順
export function countByFrequency(commands: string[]): [string, number][] {
  const counts = new Map<string, number>();
  for (const c of commands) counts.set(c, (counts.get(c) ?? 0) + 1);
  return [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1));
}

Map は、キーと値の組を覚えておく入れ物だ。コマンドをキーにして、回数を値にする。counts.get(c) は、まだ一度も出ていないコマンドだと undefined を返すので、?? 0 でそのときは0として扱う。[...counts] で、Map の中身を [コマンド, 回数] の組の配列に開く。戻り値の型の [string, number][] は、文字列と数の組が並んだ配列、という意味だ。sort に渡した関数は、二つの組を比べて、負なら a を前、正なら b を前に置く。b[1] - a[1] で回数の多いほうを前にし、回数が同じで0になったときだけ、|| の右側で、文字列の順に並べる。

テストに使う一日分は、昨日の記録の内訳だけを写して、架空の三百十二件として組み立てた(fixtures.ts の dayLog。並びは毎回同じになるように混ぜてある)。数えると、こう並んだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
npm test                                     118
npm run lint                                  54
npm run typecheck                             41
npm run build                                 22
git add src/booking/list.ts                   20
git add src/booking/list.test.ts              16
git commit -m "fix: 予約一覧のテストを直す"      12
git commit -m "test: 予約一覧の境界を足す"       9
npm run lint && npm test                       9
npm install zod                                3
git push origin feat/reservation-list          2
npm run db:seed                                2
rm -rf dist                                    2
echo "$(git rev-parse HEAD)" > dist/VERSION    1
npm run db:reset                               1

いちばん上は、テストだった。百十八回。lint、型の確認、ビルドが続く。AI はテストを直すあいだ、こまめにこれを走らせていた。

いちばん下に、npm run db:reset があった。

一回。

三百十二回のうちの、一回だった。

俺は、昨日までの自分を関数にした。全許可は、何を打たれても全部通す。

型は types.ts にまとめた。

1
2
3
4
5
6
7
// 判定の結果。allow=確認なしで通す/ask=確認に回す/deny=止める
export type Decision = 'allow' | 'ask' | 'deny';

// .claude/settings.json の permissions と同じ形。中身は "Bash(npm test *)" のような文字列
export type Rules = { allow: string[]; ask: string[]; deny: string[] };

export type Judge = (rules: Rules, command: string) => Decision;

全許可は before.ts に置いた。

1
2
3
4
5
6
import type { Decision, Rules } from './types.ts';

// 全許可。確認を出さずに、全部通す
export function decideBypass(_rules: Rules, _command: string): Decision {
  return 'allow';
}

Decision は、判定の結果の三通りだ。allow は確認なしで通す。ask は確認に回す。deny は止める。Rules は、どのコマンドを通すかを書いておく設定の形で、あとで使う。Judge は、設定とコマンドを受け取って判定を返す関数の形だ。全許可も、このあと書く判定も、この形にそろえる。全許可は設定を見ないので、引数の名前の頭に _ を付けて、使わないことを示した。import type は、型だけを読み込む書き方だ(型は実行のときには消えるので、type と書いて区別する)。読み込むファイル名に .ts まで書くのは、Node で TypeScript を直接動かすときの決まりだ。

それから、瀬尾さんの言い分も関数にした。全部、確認に回す。

瀬尾さんの言い分は ask-all.ts に置いた。

1
2
3
4
5
6
import type { Decision, Rules } from './types.ts';

// 先輩の言い分。全部、確認に回す
export function decideAskAll(_rules: Rules, _command: string): Decision {
  return 'ask';
}

二つを、三百十二件に当てて数える関数を書いた。

(types.ts の続き)

1
2
// asked と denied は、確認に回ったコマンドと止まったコマンドの種類(回数ぶんは入れない)
export type Tally = { allow: number; ask: number; deny: number; asked: string[]; denied: string[] };

(replay.ts の続き)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 許可設定と判定器を受け取り、コマンドの列に当てて数える
export function buildReplay(rules: Rules, judge: Judge): (commands: string[]) => Tally {
  return (commands) => {
    const tally: Tally = { allow: 0, ask: 0, deny: 0, asked: [], denied: [] };
    for (const command of commands) {
      const d = judge(rules, command);
      tally[d]++;
      const seen = d === 'ask' ? tally.asked : d === 'deny' ? tally.denied : null;
      if (seen !== null && !seen.includes(command)) seen.push(command);
    }
    return tally;
  };
}

buildReplay は、設定と判定の関数を受け取って、「コマンドの列を受け取って数える関数」を返す。使うときは buildReplay(設定, 判定)(コマンドの列) と、括弧を二つ続けて呼ぶ。一つ目の括弧で、何で数えるかを決める。二つ目の括弧で、何を数えるかを渡す。tally[d]++ は、判定の結果('allow' など)をそのままキーにして、その数を一つ増やしている。seen は、確認なら tally.asked、止めたなら tally.denied の配列そのものを指し、通したなら null になる(? : を二つ重ねて、上から順に当てはまるものを選んでいる)。seen に push すると、tally の中の一覧にそのまま足される。確認に回ったものと止まったものは、どのコマンドだったかも、種類だけ一覧に残す。

当てた結果は、こうなった。

1
2
buildReplay(noRules, decideBypass)(dayLog()); // allow 312・ask 0・deny 0
buildReplay(noRules, decideAskAll)(dayLog()); // allow 0・ask 312・deny 0

noRules は、何も書いていない設定だ。全許可なら、三百十二件が全部通る。npm run db:reset も通る。昨日、実際にそうなった。瀬尾さんの言い分なら、三百十二件が全部、確認に回る。npm run db:reset も聞かれる。聞かれてはいた。三百十二回のうちの、一回として。

昨日までの俺は、その一回を、読まずに押していた。

俺は、テストを二本書いた。

stage-act2-bypass.ts に書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import test from 'node:test';
import assert from 'node:assert/strict';
import { decideBypass as judge } from './before.ts';
import { buildReplay } from './replay.ts';
import { dayLog, noRules } from './fixtures.ts';

test('db:reset は確認なしで通さない', () => {
  assert.notEqual(judge(noRules, 'npm run db:reset'), 'allow');
});

test('確認は一日30回まで', () => {
  assert.ok(buildReplay(noRules, judge)(dayLog()).ask <= 30);
});

一本目は、npm run db:reset の判定が 'allow' ではないことを確かめる。確認に回っても、止まっても、どちらでもいい。黙って通らなければいい。assert.notEqual は、二つの値が等しくないことを確かめる。

二本目は、確認の数が三十以下であることを確かめる。三十という数に、根拠は無い。俺が決めた。仕事の合間に、本当に読める数だ。八時間で、十六分に一回くらい。それなら、一回ずつ目で追える。そう思った数を書いた。assert.ok は、渡した値が真であることを確かめる。

import { decideBypass as judge } の as は、読み込んだ関数に別の名前を付ける書き方だ。同じテストを、judge のところだけ decideAskAll に差し替えたファイルも作った。

走らせた。テストは node --test で走る。Node に最初から入っているテストの仕組みで(.ts のファイルをそのまま渡せるのは、型の書き込みを取り除いて動かせる Node 24 だからだ。型が正しいかどうかの検査まではしない)、通ったテストは緑、落ちたテストは赤で出る。

全許可は、一本目が赤で、二本目が緑だった。

瀬尾さんの言い分は、一本目が緑で、二本目が赤だった。

どっちに倒しても、どっちかが赤い。

電話を終えた瀬尾さんが、ボンネットの横から画面をのぞき込んだ。少し眺めてから、指で一本目の赤を指した。

「それが昨日だろ」

「こっちは三百十二回です」

俺は二本目の赤を指した。

「読めばいい」

「読めないから、こうなったんです」

瀬尾さんは何も言わなかった。俺の指の先の赤を見て、それから自分の指の先の赤を見て、また黙った。言い返す言葉を探している顔には、見えなかった。俺のほうも、瀬尾さんの赤を認める気になれなかった。二人とも、相手の赤を見ていなかった。

鷹宮さんは、少し離れたところに立っていた。聞いているのかいないのか、分からなかった。拳に鷹を据えて、無線機を口元に持っていきながら、誘導路のほうを見ていた。

誘導路というのは、滑走路と駐機場のあいだを、飛行機が地上で走るための道だ。エプロンの端から、フェンス越しに見える。その手前に、細長い草地が続いている。ハトが何羽か、草の上を歩いていた。

鷹宮さんが、無線機のボタンを押して、短く何か言った。地上の管制を呼んでいる。誘導路わきの草地へ、どの道で入っていいかを確かめているらしかった。言葉は、ほとんど聞き取れなかった。

答えは、すぐに返ってきた。ボタンを離して、ほんの二、三秒だった。無線機から、落ち着いた男の人の声が、短く返した。

そのあいだ、鷹は拳の上で、一度だけ草地のほうへ身を低くした。鷹宮さんは、鷹の脚に付いた細い革紐を指で握ったまま、放さなかった。管制の声が終わってから、草地のほうへ歩き、そこで放った。

俺は、ボンネットから離れて、鷹宮さんの後を追った。

「さっき、倉庫のところでは、呼ばなかったですよね」

鷹宮さんは、草地の上を低く飛ぶ鷹を目で追ったまま、うなずいた。

「なんで、ここでは呼ぶんですか。毎回呼べば、そのほうが安全じゃないですか。それか、さっきみたいに呼ばなくても、飛ばせるんじゃないんですか」

言いながら、自分でも、どっちを聞きたいのか分からなくなっていた。

「倉庫のほうは、決めてある」

「決めてある?」

「朝。空港と」

それだけだった。倉庫の前で飛ばすことは、朝のうちに空港の人と決めてある、ということらしかった。俺はそれを、空港の手続きの話として聞いた。そういう決まりがあるのだろう。

「じゃあ、ここも毎回呼ぶのは、変わらないんですね。管制の人も、慣れてるだろうし。呼べば呼ぶほど、安全なんじゃないですか」

瀬尾さんの言い分の側に、一度立ってみるつもりで聞いた。

鷹宮さんは、草地の端で、拳を上げた。鷹が戻ってきて、その上にとまった。それから俺のほうを見ないまま、言った。

「呼ぶのは、少ないほうがいい」

「なんでですか」

「多いと、聞き流される」

鷹宮さんは、それ以上は何も言わなかった。理由も足さなかった。鷹の足元を見て、また誘導路のほうへ目を戻した。

俺は、それも空港の話として聞いた。管制の人が忙しいから、用もないのに呼ぶな、ということなのだろう。

作業車に戻ると、瀬尾さんは運転席でまた電話をしていた。俺はボンネットのノートパソコンで、確認を減らす方法を探しはじめた。公式のドキュメントの近くに、Anthropic の技術ブログの記事があった。auto モードを作った理由を書いた記事だった。

Over time that leads to approval fatigue, where people stop paying close attention to what they’re approving.

(2026年3月25日の Anthropic の記事「Claude Code auto mode」より。2026年10月18日に参照)

確認を何度も押していると、人は自分が何を認めているのかに、注意を払わなくなる。英語では approval fatigue と名付けている。確認疲れ、くらいの意味だ。同じ記事には、こうも書いてあった。

Manual prompts sit in the middle, and in practice users accept 93% of them anyway.

確認を一つずつ出すやり方は、全部通すやり方と、ほかのやり方とのあいだにある。そして実際には、利用者はその九十三パーセントを承認している。

自分だけではなかった。

そう思って、少しだけ肩が軽くなった気がした。すぐに、それで消えたデータが戻るわけじゃない、と思い直した。

聞き流される、と鷹宮さんは言った。管制の人の話だと思って聞いた。でも、聞き流していたのは、俺だった。三百回の確認を、俺は聞き流していた。

全部を確認に回せば、確認する側は読まなくなる。そこまでは分かった。その先は、まだ分からなかった。

第3幕: 手順を整える ── 一日一回

俺は、Claude Code の permissions のページを開いた。AI の操作を、確認なしで通すか、聞くか、止めるかを決める設定のページだ。

そこに書いてある仕組みは、許可リストと呼ばれているものだった。許可リストは、AI がしてよい操作、聞いてほしい操作、してはいけない操作を、道具の名前とコマンドの形で、先に書いておく表だ。Claude Code では、allow、ask、deny の三つの一覧に分けて書く。allow に一致した操作は、確認なしで通る。ask に一致したものは、確認に回る。deny に一致したものは、止まる。どこにも一致しないものは、確認に回る。

三つの一覧の見る順番も、書いてあった。

Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn’t change the order.

(Claude Code docs「permissions」より。2026年10月18日に参照)

まず deny を見る。次に ask、最後に allow。その順で最初に一致したものが、結果を決める。どのルールのほうが細かく書いてあるかは、順番に関係ない。

モードのページには、こうあった。

Deny rules block in every mode, including bypassPermissions.

Allow rules have no effect in bypassPermissions.

(Claude Code docs「permission modes」より。2026年10月18日に参照)

bypassPermissions は、俺がこの二週間使っていた全許可のことだ。--dangerously-skip-permissions を付けて起動すると、このモードになる。全許可のときは、allow は何の意味も持たない。全部通すのだから、通す一覧は要らない。でも、deny は効く。全許可のときでも、止まる。

昨日、deny に一行書いてあれば、全許可のままでも、npm run db:reset は止まっていた。

一瞬そう思って、すぐに打ち消した。それでも、三百回は消えない。全許可をやめたら、また三百回が戻ってくる。deny に一行足すだけでは、俺が押し続けていた三百回は、何も変わらない。

ドキュメントには、三つの一覧に書いておけ、と書いてある。三百十二回のうち、毎日打つものを allow に書けば、確認はそのぶん減るはずだ。

書き方の決まりを、日付つきで手元のメモに写した。2026年10月18日時点の Claude Code の設定で、書き方や読み方は、Claude Code の改訂で変わることがある。

  • ルールは Bash(npm run lint) のように、道具の名前と、括弧の中にコマンドの形を書く
  • 括弧の中の * は、どんな文字にも一致する。Bash(npm run *) は npm run build にも npm run test --watch にも一致する
  • 末尾の * の前の空白は、ルールの一部だ。Bash(ls *) は、ls のあとに「空白と何か」が続くものか、ls だけで終わるもののどちらかに一致する。だから ls と ls -la には一致し、lsof には一致しない
  • Bash(ls:*) のように末尾に :* と書いても、 * と同じ意味になる(末尾に書いたときだけ)
  • * の無いルールは、そのコマンドと完全に同じときだけ一致する。Bash(npm run build) は npm run build --watch に一致しない
  • &&(前が成功したら次)、||(前が失敗したら次)、;(順に続けて)、|(前の出力を次に渡す)、&(後ろで走らせたまま次へ)、改行でつないだコマンドは、道具が一つずつに分けて、それぞれを確かめる。全部が allow に一致したときだけ、確認なしで通る。deny と ask は、どれか一つが一致すれば効く
  • $(...) やバッククォート、<(...) のように、コマンドの中で別のコマンドを走らせる形は、道具が実行の前に中身を確かめきれない。そういう形は、allow に一致していても確認に回る

本物の判定は、Claude Code がやる。俺がやりたいのは、設定を変える前に、昨日の三百十二件に当ててみることだ。この設定なら、昨日の確認は何回になっていたか。危ないものは、どこに入るか。それを数えるために、道具と同じ順番と書き方で判定する関数を書く。判定する道具を作り直すのではない。当ててみるためだけのものだ。

まず、ルールが一つのコマンドに一致するかを見る関数。

after.ts に書いた。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import type { Decision, Rules } from './types.ts';

// "Bash(...)" の中身を取り出して、コマンドに一致するかを見る
export function matchRule(rule: string, command: string): boolean {
  const m = /^Bash\((.*)\)$/s.exec(rule);
  if (m === null) return false;
  const pattern = m[1]!;
  if (pattern.endsWith(' *') || pattern.endsWith(':*')) {
    const head = pattern.slice(0, -2);
    return command === head || command.startsWith(head + ' ');
  }
  if (pattern.endsWith('*')) return command.startsWith(pattern.slice(0, -1));
  return command === pattern;
}

正規表現の ^Bash\((.*)\)$ で、Bash( と ) に挟まれた中身を取り出す。\( は、括弧そのものに一致させる書き方だ(ただの ( は、取り出す範囲の印になる)。最後の s は、. が改行にも一致するようにする印だ。exec は、一致しなければ null を、一致すれば一致した部分を並べた配列を返す。取り出した中身は m[1] に入る。その後ろの ! は、TypeScript に「ここは undefined ではない」と伝える印だ。一致したなら、中身は必ずある。

中身の末尾が * か :* なら、その手前(slice(0, -2) で末尾の2文字を落とした残り)とまったく同じか、手前に空白を足したもので始まれば一致にする。空白を足すのが、lsof を ls に一致させないための一手だ。空白の無い * で終わっていれば、手前の文字で始まるだけで一致にする。* が無ければ、完全に同じときだけ。

次に、つないだコマンドを一つずつに分ける関数。

(after.ts の続き)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// &&  ||  ;  |  &  改行で分ける。引用符の中は分けない。
// 解析できない形($(  バッククォート  <(  末尾の && ||)は null を返す。
// ponytail: 完全なシェルの字句解析ではない。heredoc・バックスラッシュ・2>&1 の扱いは道具より粗い。
export function splitCommand(command: string): string[] | null {
  const parts: string[] = [];
  let quote: string | null = null;
  let start = 0;
  for (let i = 0; i < command.length; i++) {
    const c = command[i]!;
    const two = command.slice(i, i + 2);
    if (c === '`' || two === '$(') return null;
    if (quote !== null) {
      if (c === quote) quote = null;
      continue;
    }
    if (c === "'" || c === '"') quote = c;
    else if (two === '<(') return null;
    else if ('&|;\n'.includes(c)) {
      parts.push(command.slice(start, i));
      if (two === '&&' || two === '||') i++;
      start = i + 1;
    }
  }
  parts.push(command.slice(start));
  if (/(&&|\|\|)\s*$/.test(command)) return null;
  return parts.map((p) => p.trim()).filter((p) => p !== '');
}

コマンドの文字列を、頭から一文字ずつ見ていく。&、|、;、改行のどれかに当たったら、そこで切る。&& と || は二文字なので、一つ余分に進める。引用符の中は切らない。git commit -m "fix: a && b" の && は、コミットのメッセージの一部であって、コマンドのつなぎ目ではないからだ。quote に、今どの引用符の中にいるかを覚えておき、閉じる引用符に当たるまでは区切りを見ない。

$( とバッククォートと <( に当たったら、そこで null を返す。戻り値の型の string[] | null は、分けた文字列の配列か、null か、という意味だ。null は「分けられなかった」の印として使う。末尾が && や || で終わっている(その先が無い)ときも、分けられなかった扱いにする。/(&&|\|\|)\s*$/ は「&& か || のあとに空白だけが続いて終わる」という正規表現で、.test は一致するかどうかを真か偽で返す。最後に、分けた一つずつの前後の空白を trim で落とし、空のものを捨てる。

コメントの頭の ponytail: は、わざと手を抜いた箇所に付けている印だ。書いたとおり、これは本物のシェルの読み方ではない。heredoc(複数行の文字列をコマンドに渡す書き方)や 2>&1(エラーの出力を普通の出力にまとめる書き方)は扱っていない。道具がやっていることより、ずっと粗い。昨日の三百十二件を当てるのに足りる分だけを書いた。

最後に、判定の関数。

(after.ts の続き)

1
2
3
4
5
6
7
8
9
// 評価順は deny、ask、allow。書いていないものは聞く
export function decide(rules: Rules, command: string): Decision {
  const parts = splitCommand(command);
  const hit = (list: string[], text: string) => list.some((rule) => matchRule(rule, text));
  if (parts === null) return hit(rules.deny, command) ? 'deny' : 'ask';
  if (parts.some((p) => hit(rules.deny, p))) return 'deny';
  if (parts.some((p) => hit(rules.ask, p))) return 'ask';
  return parts.every((p) => hit(rules.allow, p)) ? 'allow' : 'ask';
}

hit は、一覧のどれか一つのルールが一致するかを見る、小さな関数だ。some は、配列のどれか一つでも条件を満たせば真になる。every は、全部が条件を満たしたときだけ真になる。

分けられなかったコマンドは、deny にだけは全体の文字列で当てて、一致すれば止める。それ以外は、確認に回す。分けられたら、どれか一つでも deny に一致すれば止める。次に、どれか一つでも ask に一致すれば聞く。最後に、全部がそれぞれ allow に一致したときだけ通す。一つでも、どこにも一致しないものがあれば、聞く。書いていないものは、聞く。

それから、countByFrequency の出力を上から見ながら、許可リストを書いた。

いちばん上の npm test を通す。npm run lint、npm run typecheck、npm run build と、npm run で始まるものが続く。三行書くつもりだったが、ドキュメントの例に、Bash(npm run *) という書き方がそのまま載っていた。npm のスクリプトを、聞かずに走らせる例として書いてある。それなら一行で済む。公式の書き方だ。俺は少し安心して、それを写した。git add と git commit も通す。

(fixtures.ts)

1
2
3
4
5
6
// 最初の許可設定。回数の多い順に上から許した
export const firstRules: Rules = {
  allow: ['Bash(npm test *)', 'Bash(npm run *)', 'Bash(git add *)', 'Bash(git commit *)'],
  ask: [],
  deny: [],
};

当てた。

1
buildReplay(firstRules, decide)(dayLog()); // allow 304・ask 8・deny 0

確認は、八回だった。

三十どころか、一桁になった。三百十二が、八になった。その数字に目が行ったきり、危ないものがどこに入ったかを確かめる手順のほうは、頭から抜けていた。

昨日のあれは、allow に書いていない。書いていないものは聞かれる。だから、この八回の中で、ちゃんと聞かれる。八回なら、俺は一回ずつ読める。

俺はテストを走らせる前に、ボンネットを離れた。草地の端に、鷹宮さんが立っていた。

「あの、さっきの」

鷹宮さんは、拳の上の鷹を見たまま、少しだけ首をこちらへ向けた。

「回数の多いやつから順に、通していいものを先に決めておいたら、確認が一日八回になったんです。三百十二回が、八回。八回なら、読めます。鷹宮さんの無線と同じですよね。いつも飛ばすところは決めておいて、呼ぶのは少しだけにする」

言い終えてから、少し得意になっている自分に気づいた。

鷹宮さんは、うなずかなかった。首を横にも振らなかった。誘導路のほうを見たまま、言った。

「一日一回のは?」

「え?」

鷹宮さんは、それきり口を閉じていた。誘導路の向こうから、低い音が近づいてきていた。大きな機体が、ゆっくりこちらへ曲がってくる。胴体の窓が少ない。貨物機だ。

あとで瀬尾さんに聞いたら、この時刻に誘導路のこちら側を通るのは、一日に一機だけだという。

鷹宮さんは、鷹を拳に据えたまま、草地には放たなかった。無線機を耳に当てて、じっと立っていた。貨物機は、俺たちの目の前、草地の向こうの誘導路を、ゆっくり通りすぎていった。風が、エンジンの熱を少しだけ運んできた。鷹は拳の上で、羽を締めていた。

一日一回、と言われた意味が、分からないまま、俺はボンネットに戻った。

一日一回のは、どこに入った。

自分でそう問い直して、八回の確認の中身を開いた。Tally の asked の一覧だ。

1
2
3
4
rm -rf dist
echo "$(git rev-parse HEAD)" > dist/VERSION
npm install zod
git push origin feat/reservation-list

四種類で、八回。rm -rf dist が二回、echo の行が一回、npm install zod が三回、git push が二回。echo の行は、$(...) で別のコマンドを中に含んでいるので、通す一覧に書いてあったとしても、確認に回る形だ。

npm run db:reset が、無かった。

ボンネットで最初に書いた一本目のテストを、まだ走らせていなかった。件数を見て、すぐに鷹宮さんのところへ行ったからだ。走らせた。

db:reset は確認なしで通さない が、赤だった。

decide(firstRules, 'npm run db:reset') の結果は、'allow' だった。

書いた覚えのない allow を、探した。探すまでもなかった。Bash(npm run *)。npm run のあとは、何でも一致する。lint も build も、db:reset も。ドキュメントの例のとおりだった。db:seed も、同じように通っていた。

俺は、昨日のあれを allow に書かなかった。書いたのは、npm run で始まるもの全部だった。

回数の多い順に通していくと、いちばん下の一回は、広く書いた一行に、一緒に入ってしまう。そして、危ないものは、たいてい一日一回だ。毎日何十回も打つものは、テストや lint のような、何度やっても困らないものだ。データを消すようなものは、めったに打たない。めったに打たないから、回数の表のいちばん下に来る。回数で決めていたのは、聞く回数を減らすことだった。危ないものを分けることではなかった。

鷹宮さんは、一日に一回だけ来る貨物機のために、草地に鷹を放たなかった。いつもの飛ばし方の中に、その一回を入れなかった。

俺は、許可リストを直した。

まず、deny に Bash(npm run db:reset *) を足した。最初は Bash(npm run db:*) と書きかけた。db: で始まるもの全部を止めるつもりだった。でも、末尾の :* は * と同じ意味だ。npm run db に空白が続くものにしか一致しない。npm run db:reset は、db のすぐあとがコロンなので、一致しない。消して、名前を書いた。db:seed も、データベースに書き込むので、同じように止める。

Bash(npm run *) を残したまま deny を足しても、db:reset は止まる。deny が先に見られるからだ。

An allow rule can’t carve an exception out of a deny rule.

(Claude Code docs「permissions」より。2026年10月18日に参照)

allow のルールで、deny のルールに例外の穴を開けることはできない。それを、テストで確かめた。

(fixtures.ts)

1
2
// 最初の設定に、deny を1行だけ足した
export const denyOnlyRules: Rules = { ...firstRules, deny: ['Bash(npm run db:reset *)'] };

(allowlist.test.ts)

1
2
3
4
test('回数の多いものを広く許しても、db:reset は確認なしで通さない', () => {
  assert.equal(decide(denyOnlyRules, 'npm run db:reset'), 'deny');
  assert.equal(decide(denyOnlyRules, 'npm run lint'), 'allow');
});

{ ...firstRules, deny: [...] } は、firstRules の中身を全部写したうえで、deny だけを置き換えたオブジェクトを作る書き方だ。緑になった。

それでも、Bash(npm run *) は狭めることにした。deny には、俺が知っている危ないものしか書けない。今日 deny に書いたのは、昨日のログに出てきた二つだけだ。来週、AI が package.json に新しいスクリプトを足したら、Bash(npm run *) はそれも黙って通す。それが危ないものかどうかを、俺が知る前に。だから、毎日打つ三つだけを、一つずつ名前で書いた。

git push と rm は、ask に書いた。止めたいわけではない。正しい作業のときもある。ただ、打たれるなら、一回ずつ読みたい。

(fixtures.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 直した許可設定。settings.json の permissions と同じ
export const fixedRules: Rules = {
  allow: [
    'Bash(npm test *)',
    'Bash(npm run lint)',
    'Bash(npm run typecheck)',
    'Bash(npm run build)',
    'Bash(git add *)',
    'Bash(git commit *)',
  ],
  ask: ['Bash(git push *)', 'Bash(rm *)'],
  deny: ['Bash(npm run db:reset *)', 'Bash(npm run db:seed *)'],
};

当て直した。

1
buildReplay(fixedRules, decide)(dayLog()); // allow 301・ask 8・deny 3

通すのが三百一回。聞くのが八回。止めるのが三回。止まった三回は、npm run db:seed が二回と、npm run db:reset が一回だった。最初に書いた二本のテストは、どちらも緑になった。

確認の八回の中身は、さっきと同じ四種類だった。rm -rf dist、echo の行、npm install zod、git push。どれも、打たれたら一回ずつ読みたいものだった。

直した許可リストで、一件のコマンドがどこへ落ちていくのかを、メモに縦に書き出した。紺の実線は、先に決めておいて毎回同じに働くところだ。橙の破線は、AI がその場で打つところで、それが入ってくるのはいちばん上の一か所だけだった。

一件のコマンドが振り分けられる縦の流れ図。橙の破線の札「AIがコマンドを一行打つ。」と、紺の札「俺が、通すもの・聞くもの・止めるものを、先にsettings.jsonへ書いておく。」の両方から、紺の札「Claude Codeが、つないだコマンドを一つずつに分けて照らし合わせる。」へ矢印が入る。そこから紺の矢印が下へ順に、紺で塗った「最初にdenyを見て、一つでも当たれば止める。昨日は3回。」、「次にaskを見て、一つでも当たれば確認に回す。」、「最後にallowを見て、全部が当たったときだけ確認なしで通す。昨日は301回。」、「どこにも当たらないものと、中身を確かめきれない形は、確認に回す。」とたどる。askの札と最後の札から、紺の札「確認に回った昨日の8回を、俺が一回ずつ読む。」へ矢印が集まる。allowの札の横には、灰の点線の札「回数の多い順に広く書いたallowの一行は、一日一回の危ない操作まで通してしまう。」が点線で添えてある

下へ落ちる矢印は、どれも「当たらなかったら次を見る」だ。最初に deny を見るから、広く書いた allow があっても、止めると決めたものは止まる。俺が読むのは、いちばん下に残った八回だけでいい。

俺は、もう一つテストを書いた。毎日の作業が、全許可のときと同じように通るかどうか。

全許可から許可リストに変えて、毎日のテストや lint まで確認に回るようになったら、また三百回が戻ってくる。それでは意味がない。AI がふつうに正しく仕事をしているときは、全許可のときと同じに通ってほしい。違っていいのは、危ないものと、まだ決めていないものだけだ。

(allowlist.test.ts。上の二行はファイルの頭にあり、テストはその下に並んでいる。DANGEROUS は npm run db:seed と npm run db:reset の二つを入れた配列、UNPARSABLE は echo "$(git rev-parse HEAD)" > dist/VERSION の一行で、どちらも fixtures.ts に置いた)

1
2
const log = dayLog();
const routine = log.filter((c) => !DANGEROUS.includes(c));
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
test('同値範囲: 毎日の作業は全許可でも直した版でも確認なしで通る', () => {
  const daily = routine.filter((c) => !/^(rm|npm install|git push)/.test(c) && c !== UNPARSABLE);
  assert.equal(daily.length, 301);
  for (const c of daily) {
    assert.equal(decideBypass(noRules, c), 'allow', c);
    assert.equal(decide(fixedRules, c), 'allow', c);
  }
});

test('正しい作業は1件も deny にならない', () => {
  const t = buildReplay(fixedRules, decide)(routine);
  assert.equal(routine.length, 309);
  assert.equal(t.deny, 0);
});

filter で、三百十二件から危ない三件を除くと、三百九件の正しい作業が残る。そこから、聞いてほしいもの(rm、npm install、git push、echo の行)を除いた三百一件が、毎日の作業だ。

テストの名前に入れた同値範囲というのは、仕掛けを入れる前と後で、結果が同じになるはずの入力の範囲のことだ。ここでは、毎日の作業がそれに当たる。その三百一件の一件ずつが、全許可でも、直した許可リストでも、どちらも 'allow' になることを確かめる。assert.equal の三つ目の引数にコマンドを渡しているのは、落ちたときに、どのコマンドで落ちたかが出るようにするためだ。もう一本は、正しい作業の三百九件が、一件も止まらないことを確かめる。allow に書いていない正しい作業(npm install など)は、止めずに聞く。

つないだコマンドと、分けられない形のテストも書いた。

(allowlist.test.ts)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
test('複合: lint && test は通す、test && db:reset は止める、test && rm は聞く', () => {
  assert.equal(decide(fixedRules, 'npm run lint && npm test'), 'allow');
  assert.equal(decide(fixedRules, 'npm test && npm run db:reset'), 'deny');
  assert.equal(decide(fixedRules, 'npm test && rm -rf dist'), 'ask');
  assert.equal(decide(firstRules, 'npm test && rm -rf dist'), 'ask');
  assert.equal(decide(fixedRules, 'npm test; npm run lint | cat'), 'ask');
});

test('解析できない形は、allow に一致しても聞く', () => {
  for (const c of ['npm test $(whoami)', 'npm test `whoami`', 'npm test <(ls)', 'npm test &&', 'npm test ||', UNPARSABLE]) {
    assert.equal(decide(firstRules, c), 'ask', c);
  }
  assert.equal(decide(denyOnlyRules, 'npm run db:reset $(whoami)'), 'deny');
});

npm test && npm run db:reset は、前半が通るものでも、後半が止めるものなので、止まる。npm test; npm run lint | cat は、cat がどこにも書いていないので、聞く。最初の設定でも npm test && rm -rf dist が聞かれるのは、rm がどこにも書いていないからだ。分けられない形でも、deny だけは全体の文字列に当てるので、npm run db:reset $(whoami) は止まる。道具が実際にやっていることを、自分の関数でも写しただけだ。

俺は、自分の言葉で、ノートパソコンのメモに書いた。

何を通すかを、その場で聞かれて決めるのを、やめた。先に決めておく。決めるときは、回数の多い順ではなく、危ないものから分ける。いちばん下の一回から先に見る。聞かれるのは、決めていないものだけ。聞く回数を減らしたのは、聞かれたものを読むためだ。

誘導路のほうで、無線機の声がした。貨物機が、誘導路の向こうへ消えていくところだった。鷹宮さんが、ボタンを押して短く答えた。それから、草地へ鷹を放った。

第4幕: 検める ── 名前の外

二十本のテストを、まとめて走らせた。

1
2
cd agents/code-falconer/tests/agent-permission-allowlist
node --test allowlist.test.ts stages.test.ts

全部、緑だった。stages.test.ts は、この記事に載せた途中のテスト(全許可で一本目が赤、瀬尾さんの言い分で二本目が赤、最初の許可リストで db:reset が通る)が、そのとおりに赤くなることを確かめている。

それから、判定の関数をわざと壊してみた。見る順番を allow から先にする。分けずに全体の文字列で当てる。分けられない形を通す。 * の空白を無視する。どこにも一致しないものを通す。引用符の中も区切る。どこを壊しても、赤くなるテストが必ず一本はあった。直せば、緑が戻った。テストが、何も確かめずに緑になっているわけではなかった。

件数の出力を、運転席の瀬尾さんに見せた。通すのが三百一、聞くのが八、止めるのが三。

瀬尾さんは、件数の行には目を留めなかった。下にスクロールして、deny の二行を見ていた。しばらく見てから、言った。

「名前で止めてるだけだろ」

「名前?」

「npm run db:reset って書いてあるから止まる。中身を npx で直接走らせたら? sh -c で包んだら? 書き方が違うだけで、同じことが起きる」

db:reset の中身は、データベースの道具を呼ぶ一行だ。npx は、プロジェクトに入っている道具を直接呼ぶコマンドなので、AI がその一行を npx で打てば、npm run db:reset という文字は一度も出てこない。sh -c '...' は、文字列をもう一つのシェルに渡して実行させる書き方で、これも外から見える文字が変わる。

俺は言い返しかけて、やめた。知らない名前のスクリプトのことは、さっき考えた。でも、知っている db:reset でも、書き方を変えればすり抜ける。そこまでは考えていなかった。

「あれと同じだよ」

瀬尾さんは、フロントガラスの向こうの、エプロンの外を囲むフェンスを、顎で指した。

「向こう側の道路から、こっちへは入れない。何を名乗っても、どう言っても、柵があるから来られない。検証環境のデータベースは、そもそも、手元からつながらないようにしておけ」

俺は黙って、permissions のページに戻った。探していたものは、すぐに見つかった。

a deny or ask rule covers the invocation Claude usually produces and isn’t a security boundary around the program.

(Claude Code docs「permissions」より。2026年10月18日に参照)

deny や ask のルールは、AI がふつう打つ形を押さえるものであって、プログラムのまわりを囲う安全の境界ではない。同じページには、Bash(rm *) は /bin/rm -rf build/ や bash -c 'rm -rf build/' を止めない、という例まで載っていた。書き方が違えば、すり抜ける。

許可リストは、AI が打とうとしたコマンドの文字を見て決める。文字が違えば、すり抜けうる。瀬尾さんが言っているのは、文字のほうではなかった。動いているプログラムが、どこへつながれるかのほうだった。

同じページの先に、それを受け持つ仕組みの名前があった。サンドボックスだ。サンドボックスは、AI が打ったコマンドを、OS が決めた枠の中で動かして、書き込める場所とつなげる先を制限する仕組みだ。コマンドの名前ではなく、動いているプログラムそのものに効く。

これも、2026年10月18日時点の Claude Code の設定として残しておく。

1
2
3
4
5
6
7
{
  "sandbox": {
    "enabled": true,
    "allowUnsandboxedCommands": false,
    "network": { "allowedDomains": ["registry.npmjs.org"] }
  }
}
  • 書き込めるのは、作業しているフォルダと、一時ファイルの置き場所だけになる
  • ネットワークには、直接の道が無い。手元で動くプロキシ(通信を中継する係)が、allowedDomains に書いたドメインだけを通す。最初は空なので、npm のパッケージを取ってくる registry.npmjs.org だけを書いた
  • このプロキシが中継できるのは、Web と同じ種類の通信だけだ。データベースのドライバ(アプリがデータベースと話すための部品)は、データベースに直接つなぎに行くので、通り道が無い。許可したホストにもつながらない。公式のドキュメントには、most database drivers … can’t connect, even to an allowed host(たいていのデータベースのドライバは、許可したホストにもつながらない)とある
  • allowUnsandboxedCommands: false は、サンドボックスの中で失敗したコマンドを、AI が外で試し直す道を閉じる

最後の一つには、理由がある。

An allow rule that matches a command also approves an unsandboxed retry of it, so the command can run outside the sandbox with no prompt.

(Claude Code docs「sandboxing」より。2026年10月18日に参照)

コマンドに一致する allow のルールは、そのコマンドをサンドボックスの外で試し直すことまで、確認なしで認めてしまう。もし Bash(npm run *) を残していたら、サンドボックスの中で失敗した npm run db:reset を、外でそのまま試し直せたことになる。さっき allow を狭めたことが、ここでも効いていた。それでも、試し直しの道そのものを閉じておく。

うちの単体テストは、データベースにつながない。メモリの上に、仮のデータベースを作って使っている。だから、サンドボックスを入れても、毎日の npm test はそのまま通るはずだ。データベースにつなぐテストがある会社なら、その分の設定を足す必要がある。

ただ、サンドボックスのほうは、テストしていない。俺のテストが確かめたのは、許可リストを当てた件数までだ。検証環境のデータベースに本当に届かないかは、手元で一度、自分で確かめる。それを手順に書いた。

俺は、運転席の瀬尾さんに向き直った。

「先に決めておくのが、許可リストですよね。それは、コマンドの文字で決める。文字がすり抜けても届かないようにするのは、つながる先を OS が切る、サンドボックスのほう。で、両方要る」

瀬尾さんは、少し間を置いて、うなずいた。何も足さなかった。

それから、自分のスマートフォンを出して、何か打ちはじめた。検証環境のデータベースに、社員のノートパソコンから直接つなげないようにする、と言った。接続は、検証環境の中の決まった場所からだけにする。それは瀬尾さんの仕事だった。俺は、.env の DATABASE_URL を手元のデータベースに戻した。検証環境の接続情報は、手元に置かないことにした。

確認を減らす、が俺の言い分だった。つながる先を断つ、が瀬尾さんの言い分だった。どちらかではなかった。

俺の言い分と瀬尾さんの言い分を、メモの一枚に並べてみた。図で「OSの枠」と書いたのが、サンドボックスのことだ。

許可リストとその外側の層を示す流れ図。橙の破線の札「AIが、検証環境のデータを消すコマンドを打とうとする。」から、橙の破線矢印が二つに分かれる。一方の橙の破線の札「AIが、npm run db:resetと名前どおりに打つ。」は、紺で塗った「許可リストが、db:resetの名前を見て止める。」に届く。もう一方の橙の破線の札「AIが、同じ中身をnpxで直接呼ぶか、sh -cで包んで打つ。」は、灰の点線の札「名前が違うので、許可リストの文字の照合をすり抜ける。」を抜けて、紺の札「OSの枠が、動いているプログラムのつながる先を、許可したドメインだけに絞る。」へ進む。その札と、隣の紺の札「瀬尾さんが、社員の手元から検証環境のデータへつながる道を閉じる。」から、紺の矢印が紺で塗った「どちらの書き方でも、検証環境のデータまでは届かない。」に集まる

許可リストで止まるのは、名前どおりに打たれたものだけだ。名前の外へ回ったものは、下の二つが受ける。その二つは、俺のテストではまだ確かめていない。

日が少し傾いてきた。エプロンの向こうで、空港の作業車が一台、こちらへ走ってきていた。鷹宮さんは、草地の端で、それを待っていた。滑走路のほうの作業の時間が、近づいているらしい。飛行機の動きが無い、決まった時間にだけ、滑走路の近くへ入るのだと、瀬尾さんが言っていた。

俺は、鷹宮さんのところへ歩いた。

「朝に決めておいた区域が、途中で変わったら、どうするんですか」

鷹宮さんは、近づいてくる作業車を見ていた。

「変わったら、呼ぶ」

「決め直すのは?」

「明日の朝。あたしと、空港で」

鷹宮さんは、拳の上の鷹を、少し持ち上げた。

許可リストを書き換えるのは、人だ。

.claude/settings.json は、リポジトリに入れる。チームの全員が同じ許可リストで AI を動かすことになる。変えるときは、PR を出して、誰かに見てもらう。AI のほうは、確認ありのモードなら、.claude の中を勝手に書き換えられない。公式のドキュメントで、保護されたパスとして決められている。allow に書いても、それは変わらない。

でも、全許可のときは、書き換えられる。保護されたパスへの書き込みも、確認なしで通るからだ。

だから、全許可は、自分で封じることにした。自分のユーザー設定のファイル、~/.claude/settings.json に、一行足した。

1
2
3
{
  "permissions": { "disableBypassPermissionsMode": "disable" }
}

これで、俺のノートパソコンでは、--dangerously-skip-permissions を付けても全許可にならない(2026年10月18日時点の Claude Code の設定)。瀬尾さんに言われる前に、自分で書いた。速さが惜しくなったとき、俺はまた付けたくなるかもしれない。そのときの自分を、先に封じておきたかった。

それから、当てるためのスクリプトの頭に、確かめていないことを書いておいた。

この見積もりは、Claude Code の本物の判定と一致するとは限らない。俺の decide は、公式に書いてある規則の一部を写しただけだ。timeout 60 npm test のようにコマンドを包むものを剥がす決まりや、NODE_ENV=test npm test のように先頭に書いた環境変数の扱い、Bash(git * main) のようにルールの途中に書いた *、heredoc、読むだけのコマンドの一覧は、写していない。テストが確かめるのは、自作の fixture に対する、自作の関数の振る舞いまでだ。Claude Code の仕様が変われば、同じ設定でも、効き方が黙って変わりうる。fixture の頭には、出典と参照した日付を書いた。

この見積もりは、明日のログを知らない。npx や sh -c で、別の書き方をするかもしれない。deny は、AI がふつう打つ形を押さえる層で、それより外は、サンドボックスの受け持ちだ。

サンドボックスの効き目は、確かめていない。公式のドキュメントに書いてあることをもとに設定しただけだ。

確認が八回になっても、読むのは俺だ。少なくしたのは、読めるようにするためで、読むかどうかは、人の側の話だ。

.env が検証環境を向いていたことは、許可リストでは防げない。確認の画面に出るのは npm run db:reset という文字だけで、その向き先は出ない。だから、接続の側を分ける。

この見積もりが当てたのは、このリポジトリの .claude/settings.json の permissions だけだ。ユーザーの設定や、会社が配る設定に書いた deny も、本物の Claude Code では合わさって効く。この関数は、それを見ていない。

作業車が、鷹宮さんの前で停まった。空港の車だった。屋根に、黄色い回転灯が付いている。

鷹宮さんは、無線機のボタンを押して、短く何か言った。答えはすぐに返ってきた。鷹を拳に据えたまま、助手席に乗り込んだ。車は、誘導路のほうへ走っていった。窓の向こうで、鷹宮さんの拳の上の茶色い影が、小さくなっていった。

俺は、それを見送った。何も言わなかった。言う間もなかった。

夕方、帰りの保安ゲートで、臨時の通行証を返した。来たときに鷹宮さんの区域を読み上げていた警備員が、受け取った通行証と引き換えに、カードを一枚、窓口から差し出した。

「鷹宮さんから。付き添いの方に、って」

名刺より少し大きい、厚手のカードだった。手書きで、四行。一行ずつ、短かった。五行目の欄だけが、白いまま残っていた。

帰りの作業車の助手席で、俺はそのカードを膝の上に置いた。ノートパソコンの上で、ボールペンで、空いた一行に書いた。

瀬尾さんは、前を見て運転していた。俺が何を書いているのか、のぞきもしなかった。書き終えてカードをしまうとき、一言だけ言った。

「明日、取引先の試験、九時からな」

翌日、受け入れ試験は再開した。その日、AI の確認は七回出た。俺は、七回とも読んだ。そのうち一回は、rm -rf のあとに、思っていたのと違うフォルダの名前が書いてあった。「いいえ」を押した。読めたのはその日の一例で、明日も読めるという約束ではない。

放鷹カード

  • 合図: 許可リスト。通すもの・聞くもの・止めるものを、先に書いておく。聞くのは、決めていないものだけ
  • 検め方: 昨日のログに当てて、確認が何回残るかと、一日一回のものがどこに入るかを数える
  • 止めないもの: 毎日の作業(テスト・lint・型の確認・ビルド・add・commit)を、全許可のときと同じく、確認なしで通すこと。allow に書いていない正しい作業は、止めずに聞く
  • 鷹のせいじゃなかった: 全部聞くか、全部通すかしか持っていなかった。三百十二回のうちの一回を、読まずに押していた
  • 自分の言葉で: 減らしたのは、読むため。名前で止めきれない分は、つながる先で断つ。瀬尾さんの赤も、俺の赤も、どっちも本物だった
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource へ
Hugo で構築されています。
テーマ Stack は Jimmy によって設計されています。