第9章 サンドボックスと最小権限 — 取り消せる設計で守る
📖 この章のゴール:前章の「実行していい?」(確認)だけに頼らず、そもそもAIが触れる範囲を最初から狭める(最小権限)ことで守れるようになる。具体的には、作業フォルダの外に出さない(パス封じ込め)/危険なコマンドを弾く/git で取り消せるようにする、の3つを実際に作ります。合言葉は 「隠して守る」のではなく「権限で守る」。 ← 目次・はじめにへもどる
📱 Claude Code ではこう見える
Claude Code を使っていると、こんな場面に気づきます。
- ファイルを読む・書くとき、だいたい「いま開いているプロジェクトのフォルダの中」で動いている。いきなりパソコン全体の
/(ルート)や、別の人のホームフォルダを触りにはいかない。 rm -rf /(全消し)のような、いかにもヤバいコマンドを頼むと、そもそも拒否されたり、強い警告とともに止まる。- 作業の前にことわりなく
gitで状態を記録していて、おかしくなっても 前の状態に戻せる。
つまり Claude Code は、確認を出すだけではありません。「動ける範囲」そのものを最初から狭めています。これがこの章のテーマ「サンドボックス(砂場)と最小権限」です。
🏖 たとえ:サンドボックス(sandbox=砂場) は、子どもが思いきり遊べるけれど、その囲いの外(道路)には出られない場所のこと。AIにも「この砂場の中なら自由にどうぞ。外(パソコン全体)はダメ」という囲いを作ってあげる、というイメージです。
🤔 なぜ?/やらないとどうなる
確認は安全弁。でも人は「yes」を連打する(🟢 基礎)
前章(第8章)で、危ない操作の前に「実行していい?」と聞く human-in-the-loop(ひゅーまん・いん・ざ・るーぷ=人間の確認をはさむ) を作りました。これは大事な安全弁です。
でも、正直に考えてみてください。同じ確認が10回20回と続くと、人は中身を読まずに y を連打しはじめます。これは意志が弱いのではなく、人間ならふつうに起きること。だから、確認「だけ」を頼りにした安全は、いつか破れます。
🍳 厨房のたとえで言うと、見習い(あなたのコード)がいちいち「この鍋つかっていい?」と聞いてきて、あなた(人間)が毎回「いいよ」と答える……を100回やったら、101回目に出てきた「ガスの元栓を全開にしていい?」にも、つい「いいよ」と言ってしまう。これが「yes連打」の怖さです。
だから根本対策=最小権限(そもそも触れる範囲を狭める)(🟢 基礎)
ここで効くのが 最小権限(さいしょうけんげん) という考え方です。
最小権限=AIに渡す力を、仕事に必要なぶんだけに絞ること。「できることが少なければ、暴走しても被害が小さい」。
確認は「やる直前に人が止める」しくみ。最小権限は「そもそもできないようにしておく」しくみ。順番が違うのがポイントです。最小権限は、人が居眠りしていても効きます。だから本命はこっち。確認は最後の保険です。
実は、ここまでの章で わざと弱いまま残していた穴があります。
- 第5章(ファイルを読む)の伏線:
read_file("../../etc/passwd")のように、パスに../(一つ上のフォルダへ)を混ぜると、作業フォルダの外まで読めてしまう。あのとき「これは第9章で封じます」と予告しました。この章でいよいよ封じます。 - 第7章(コマンド実行):
run_commandは一番強くて一番危険。rm -rfのような操作を門前払いしたい。
「隠して守る」は守りではない(🟢 基礎)
ここで、第1弾(Twitterクローン)でも出てきた大事な教訓をもう一度。
「隠して守る」≒ security by obscurity(せきゅりてぃ・ばい・おぶすきゅありてぃ) =「場所や名前を分かりにくくすれば安全だろう」という考え方。これは守りの本体にしてはいけません。
たとえば「危ない道具があることをAIに教えなければ使われないだろう」「フォルダの名前を複雑にすれば見つからないだろう」——これらは全部、たまたまバレなければ助かるだけ。AIは推測が得意ですし、エラーメッセージや別ルートからすぐ気づきます。第1弾で「フロントの if で隠してもAPIを直接叩かれたら終わり」「連番IDをUUIDにしても、それは”めくりにくくする”だけ。本当の鍵は権限」と学んだのと、まったく同じ話です。
🔑 守りの本体は、いつも「権限」。「
resolveInsideという関所を必ず通すから外に出られない」——これが権限で守るということ。隠すのは、せいぜい「おまけ」です。
取り消せること、も安全のうち(🟢 基礎)
もう1つ、地味だけど効くのが 「取り消せる(戻せる)」 こと。
人間が確認しても、最小権限で囲っても、ミスはゼロにはなりません。だったら「やらかしても元に戻せる」状態にしておけば、被害は一時的で済みます。
🧯 たとえ:消火器を置いてあるからといってボヤを歓迎するわけではない。でも「いざとなったら消せる」だけで、ぐっと安心して作業できます。プログラミングでの消火器が git(ぎっと) です。作業前に状態を記録しておけば、AIがファイルをぐちゃぐちゃにしても
git restoreで一発で戻せます。
⚠️ ただし注意。世の中には「取り消せない操作」もあります。メールを送る、本番のデータベースを消す、お金を払う……これらは git では戻せません。だから「取り消せるか?」は、道具を渡す前に毎回考えるべき問いです(詳しくは⚠️で)。
🛠 こう作る
3つを順に足します。①パス封じ込め、②危険コマンドの拒否、③git で取り消せる、です。難しい新しい概念は出てきません。「関所を1つはさむ」だけです。
① パス封じ込め:作業フォルダの外を触れなくする(🟢 基礎)
まず、作業の基準フォルダ(作業ルート) を決めます。そして「渡されたパスを、必ずこの関所に通してから使う」ようにします。
import path from "node:path";
const WORK_DIR = process.cwd(); // エージェントを起動したフォルダを「作業ルート」にする
function resolveInside(p: string): string {
const abs = path.resolve(WORK_DIR, p); // 相対パスも絶対パスに直す(../ もここで実際の場所に変換される)
if (!abs.startsWith(WORK_DIR)) {
throw new Error("作業フォルダの外は触れません");
}
return abs;
}
1行ずつ読むと:
import path from "node:path"… パス(ファイルの場所を表す文字列)を安全にあつかう、Node.js 標準の道具を読み込みます。/を自分で文字列連結するより、ずっと安全です。const WORK_DIR = process.cwd()…process.cwd()は「いまエージェントを起動したフォルダ」。これを 作業ルート(砂場の囲い) と決めます。WORK_DIRは途中で変えない定数にします。function resolveInside(p)… 「渡されたパスpが囲いの中か確かめて、OKなら本当のパスを返す」関所です。path.resolve(WORK_DIR, p)… 渡されたパスを 作業ルートを基準に、1本の絶対パスへ正規化します。ここがキモ。"../secret"のような../も、ここで「実際にどこを指すか」に計算されます。だから後の比較でズルが効きません。if (!abs.startsWith(WORK_DIR))… 計算後の絶対パスが、作業ルートで始まっていなければ=囲いの外を指しているということ。throw new Error("作業フォルダの外は触れません")… その場合はエラーで止めます。これで../を使った“外への抜け道”が塞がります。return abs… 安全だと分かったパスだけを返します。
⚠️ この
startsWithチェックは 学習用の簡易版 です。/home/me/work-secretのような“似た名前”や、symlink(近道)で抜けられる穴があります。理由と厳密な書き方(WORK_DIR + path.sepで確認・fs.realpath)は、本章末の「⚠️ ハマりどころ」で説明します。
あとは、これを ファイル系の道具すべてに通すだけ。第5・6章の read_file / write_file を、こう書き換えます。
import { promises as fs } from "node:fs";
async function executeTool(name: string, input: any): Promise<string> {
switch (name) {
case "read_file":
return await fs.readFile(resolveInside(input.path), "utf-8");
case "write_file":
await fs.writeFile(resolveInside(input.path), input.content);
return `書き込みました: ${input.path}`;
// list_files も同じく resolveInside(input.dir) を通す
default:
return `不明な道具: ${name}`;
}
}
1行ずつ読むと:
case "read_file"… ファイルを読む道具。input.pathをそのまま渡さず、resolveInside(input.path)という関所を通してからfs.readFileに渡します。これだけで../抜けが封じられます。case "write_file"… 書き込みも同じ。resolveInside(input.path)を通すので、囲いの外のファイルは上書きできません。- ポイントは「1か所(resolveInside)に判断を集める」こと。道具ごとにバラバラにチェックを書くと、書き忘れた道具から漏れます。関所は1つ、全員そこを通す、が鉄則です。
🔧 応用:エラーは文字列で返すと親切。スペック通り、本来は
try/catchでエラーを文字列にしてLLMに返すと、AIが「外は触れないのか、では別の手を考えよう」と立て直せます(上の例は短さ優先で省略)。catch (e) { return "失敗: " + (e as Error).message; }のように包みます。
② 危険なコマンドを弾く(🟢 基礎)
次に、一番危険な run_command(第7章)に、明らかにヤバいコマンドを門前払いする関所をつけます。
const DENY_PATTERN = /rm\s+-rf|sudo|curl\s+.*\|\s*sh/;
async function runCommand(command: string): Promise<string> {
if (DENY_PATTERN.test(command)) {
return "危険なコマンドのため拒否しました";
}
// ここから先で実際にコマンドを実行する(第7章の exec)
// const { stdout } = await exec(command); return stdout;
}
1行ずつ読むと:
const DENY_PATTERN = /.../… 拒否したいコマンドの形を並べた検査パターン(正規表現=文字列の“型紙”)です。rm\s+-rf…rm -rf(フォルダごと強制削除)。\s+は「スペース1個以上」。rm -rfのように間延びしても捕まえます。sudo… 管理者権限で実行するコマンド。AIに管理者の力を渡す理由はまずありません。curl\s+.*\|\s*sh… 「ネットから落としたものを、確かめずにそのまま実行(curl ... | sh)」という、よくある乗っ取り口。if (DENY_PATTERN.test(command))… 渡されたコマンドが型紙のどれかに当てはまったら…return "危険なコマンドのため拒否しました"… 実行せず、文字列でお断りします(AIには結果として伝わる)。
🛑 ここが超重要な注意書き:このやり方は denylist(でにーりすと=拒否リスト方式)=「ダメなものを並べて、それ以外は許す」です。手軽ですが、必ず漏れます。
rm -rfは弾けてもrm -r、find ... -delete、> 大事なファイル(中身を空にする)……と、危険な書き方は無数にあり、全部を列挙しきれないからです。本来あるべきは allowlist(あろうりすと=許可リスト方式)=「使ってよいコマンドだけを並べて、それ以外は全部拒否」。たとえば「
lsとcatとnpm testだけ許す」。これなら知らない危険コマンドも自動で全部ブロックされます。最小権限の考え方そのものです。学習用には denylist で雰囲気をつかみ、本気で守るなら allowlist、と覚えてください。
③ git で「取り消せる」を作る(🟢 基礎)
最後に、消火器。エージェントが作業を始める前に、git で状態を記録しておきます。これだけで「いつでも作業前に戻れる」状態が手に入ります。
# エージェントを動かす前に、作業フォルダで一度だけ
git init # まだgit管理でなければ、ここで始める
git add -A # いまのファイルを全部
git commit -m "AI作業前の状態" # 「セーブポイント」として記録
1行ずつ読むと:
git init… そのフォルダを git の管理下にします(すでに管理されていれば不要)。git は「ファイルの変更履歴を記録・復元できる道具」です。git add -A… いまある全ファイルを「記録する対象」に入れます。git commit -m "AI作業前の状態"… 現在の状態に名前をつけて保存=セーブポイントを作ります。ゲームのセーブと同じ。
もし AI が暴走してファイルをぐちゃぐちゃにしても、1コマンドで巻き戻せます。
git restore . # 直前のセーブポイントまで、変更を全部取り消す
1行ずつ読むと:
git restore .… いまのフォルダの変更をまるごと捨てて、最後のコミット(セーブポイント)の状態に戻す。AI が壊した分が一瞬で消えて、作業前に戻ります。
🔧 応用:作業前コミットを自動化する。スペックの「エージェント本体」
runAgentの冒頭で、exec("git add -A && git commit -m 'AI作業前' --allow-empty")を一度だけ走らせておくと、毎回手で打たずに済みます(--allow-emptyは変更が無くても失敗しないため)。ただし自動コミットはあくまで保険で、最小権限とパス封じ込めの代わりにはなりません。
💡 3つは役割が違うので全部いります。①パス封じ込め=外に出さない、②コマンド拒否=ヤバい命令を入れない、③git=やらかしても戻せる。確認(第8章)も合わせて、何重にも重ねるのが安全設計のコツです(1枚の壁に穴があっても、次の壁で止まる)。
⚠️ ハマりどころ
-
startsWithだけだと“似た名前”に注意(🔧 応用)WORK_DIRが/home/me/workのとき、/home/me/work-secretもstartsWith("/home/me/work")を通ってしまいます(先頭文字列が一致するため)。厳密には「WORK_DIRぴったり」か「WORK_DIR + path.sep(区切り文字)で始まる」かを確認します。学習用にはstartsWithで十分ですが、本気で守るならここまで詰めます。 -
シンボリックリンク(近道)でも抜けられる(🔧 応用) 作業フォルダの中に、外を指すシンボリックリンク(symlink=近道のショートカット)が置いてあると、パス文字列は囲いの中なのに、実体は囲いの外、ということが起こります。本気で守るなら
fs.realpath(近道をたどった本当の場所)を解決してから比較します。「文字列のパスを信じすぎない」が教訓です。 -
denylist は必ず漏れる(許可制の方が堅い)(🟢 基礎) ②で強調した通り。「危ないものリスト」は、新しい危ない書き方が出るたびに穴が空きます。迷ったら allowlist(許可制)。「これとこれだけOK、あとは全部ダメ」の方が、考え漏れがあっても安全側に倒れます。
-
取り消せない操作を見落とす(🟢 基礎) git で戻せるのは自分のフォルダの中のファイル変更だけです。メール送信・SNS投稿・本番DBの削除・課金・外部APIへの送信は、git では戻りません。こういう「外の世界に出ていく/お金や信用が動く」操作は、最小権限・確認を特に厳しく。「やり直しがきくか?」を道具ごとに必ず問いましょう。
-
「隠して守る」の誤り(🟢 基礎) 「危ない道具をシステムプロンプトで隠せば使われない」「フォルダ名を複雑にすればバレない」——どれも守りになりません。AIは推測し、エラーや別ルートから気づきます。守りは必ず
resolveInsideのような“通さざるを得ない関所(=権限)” で実装します。 -
サンドボックスを過信しない(最後は人の確認も)(🟢 基礎) この章で作った囲いは強力ですが、完璧ではありません(上の symlink などの抜け道がある)。最小権限・パス封じ込め・取り消せる設計・人の確認を、重ねて使ってこそ安全です。どれか1つに「これで万全」と寄りかからないこと。
🤖 AIに頼むなら(Vibe codingのコツ)
「サンドボックスを付けて」と漠然と頼むと、見た目だけそれっぽいコードが出がちです。「関所を1か所に集める/許可制/取り消せる」を名指しで頼むと、筋の良いコードに誘導できます。
🗣 プロンプト例: 「いまの TypeScript 製CLIエージェントにサンドボックスを足したい。次の3つをこの方針で: ① パス封じ込め:
WORK_DIR = process.cwd()を作業ルートにし、resolveInside(p)という関数でpath.resolveしてからWORK_DIRの外なら例外を投げる。read_file/write_file/list_filesは必ずこの関数を通す(チェックを1か所に集める)。 ② コマンド制限:run_commandは、できれば allowlist(許可したコマンドだけ実行、他は拒否) で。難しければ denylist でも可だが『漏れる』と分かるようコメントを残して。 ③ 取り消せる:作業前にgitでコミットして戻せるようにする手順も教えて。 シンボリックリンクやstartsWithの落とし穴があれば、コメントで指摘して。」
出てきたコードの確認チェックリスト:
- パス封じ込め:ファイル系の道具が全部
resolveInside(外なら例外)を通っているか? 1つでも素通りの道具が無いか? - 最小権限:渡している道具は本当に必要なものだけか? いらない強い道具を足していないか?
- 危険コマンドの拒否:
run_commandに拒否のしくみがあるか? できれば許可制(allowlist)になっているか?(denylist なら「漏れる」前提のコメントがあるか) - 取り消せる(git):作業前にセーブポイント(コミット)を作る手順があるか?
git restoreで戻せると確認したか? - 守りを「隠す(名前を分かりにくく)」ではなく「権限(必ず通す関所)」で実装しているか?
📝 ことばメモ
- サンドボックス(sandbox/砂場):プログラムが動ける範囲を、安全な“囲いの中”に限定するしくみ。中では自由、外には出られない
- 最小権限(さいしょうけんげん):道具や権限を「仕事に必要なぶんだけ」に絞る原則。できることが少なければ、暴走しても被害が小さい
- パス封じ込め:ファイル操作を作業フォルダの中だけに制限すること。
../などで外へ抜けられないよう、関所(resolveInside)を必ず通す - security by obscurity(せきゅりてぃ・ばい・おぶすきゅありてぃ/隠すことによる安全):場所や名前を分かりにくくして守ろうとする考え方。守りの本体にしてはいけない(権限で守るのが正解)
- allowlist(あろうりすと/許可リスト):使ってよいものだけを並べ、それ以外は全部拒否する方式。漏れに強く、最小権限と相性が良い
- denylist(でにーりすと/拒否リスト):ダメなものを並べてそれ以外は許す方式。手軽だが列挙しきれず必ず漏れる
- シンボリックリンク(symlink/近道のショートカット):別の場所を指す“近道”。パス文字列は囲いの中でも実体は外、という抜け道になりうる
➡️ 次の章へ
これで安全の土台ができました。パス封じ込め(外に出さない)・コマンド制限(ヤバい命令を入れない)・git(取り消せる)、そして前章の確認。「権限で守る/取り消せる」が、あなたのエージェントに備わりました。
ここまでは「できること/できないこと」という、いわばルールの外枠を作ってきました。次の第10章では、エージェントの内面——「性格と規範」を決めるシステムプロンプトを扱います。Claude Code の CLAUDE.md がまさにこれ。「慎重に動け」「まず計画を述べよ」「勝手に消すな」といったしつけ(育ち)を言葉で与えると、同じ道具・同じ囲いでも、エージェントの振る舞いがぐっと賢く・安全になります。