第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+-rfrm -rf(フォルダごと強制削除)。\s+ は「スペース1個以上」。rm -rf のように間延びしても捕まえます。
  • sudo … 管理者権限で実行するコマンド。AIに管理者の力を渡す理由はまずありません。
  • curl\s+.*\|\s*sh … 「ネットから落としたものを、確かめずにそのまま実行(curl ... | sh)」という、よくある乗っ取り口
  • if (DENY_PATTERN.test(command)) … 渡されたコマンドが型紙のどれかに当てはまったら…
  • return "危険なコマンドのため拒否しました" … 実行せず、文字列でお断りします(AIには結果として伝わる)。

🛑 ここが超重要な注意書き:このやり方は denylist(でにーりすと=拒否リスト方式)=「ダメなものを並べて、それ以外は許す」です。手軽ですが、必ず漏れますrm -rf は弾けても rm -rfind ... -delete> 大事なファイル(中身を空にする)……と、危険な書き方は無数にあり、全部を列挙しきれないからです。

本来あるべきは allowlist(あろうりすと=許可リスト方式)=「使ってよいコマンドだけを並べて、それ以外は全部拒否」。たとえば「lscatnpm 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-secretstartsWith("/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 がまさにこれ。「慎重に動け」「まず計画を述べよ」「勝手に消すな」といったしつけ(育ち)を言葉で与えると、同じ道具・同じ囲いでも、エージェントの振る舞いがぐっと賢く・安全になります。

← 目次・はじめにへもどる