Claude Codeのエージェントが確認に止まらず自律しつつ危険操作を確実に止める仕組みを、権限モデル(deny→ask→allow)・PreToolUseフック・guard.sh・subagent継承まで公式仕様で解剖。「裸のBash」の誤解とgit -Cバイパスも精査する技術解説。

「git log を見るだけ」「設定を1行直すだけ」。そのたびに画面が止まり、「許可しますか? (y/n)」が出る。AIエージェントに開発を任せた人が、最初の30分で50回 Enter を叩いて心が折れるのはこの瞬間だ。だったら全部許可すればいい――そう思った先には、別の地獄が口を開けている。ある夜、気をきかせたエージェントが rm -rf を走らせ、本番ブランチへ push する。速く走らせたい欲求と、取り返しのつかない一手だけは何があっても止めたいという要求は、正面からぶつかる。

Claude Codeはこの矛盾を、力ずくの全許可でも、臆病な全確認でもない二段構えで解く。後戻りできる操作の許可は思い切って広げ、危険な操作は許可とは別系統の独立した層が必ず止める。鍵を握るのは、AIの判断が外部からの注入や勘違いで揺れても、コマンド文字列だけを見て機械的に弾く決定論的なガードだ。

ところが、その入口で多くの開発者がつまずく。「Bash と一語書けば全部許可されるはず」という広く信じられた思い込みは、公式仕様の前で崩れる。さらに厄介なことに、main を守るために置いたはずの最も基本的な防御を、git -C /repo push origin main というたった一行が平然とすり抜ける。本稿は、ある開発現場の実装マニュアル(2026年6月11日初版、fa09460)を教材に、設定ファイルと4本のシェルスクリプトを実際に開き、この一行が4つの層をどう通り、最後にどこで止まるのかをコード1行ずつ追う。読み終えたとき、同じ檻を自分のプロジェクトにそのまま移せるようになっている。

50回「許可しますか?」の正体 ― 権限モデルの三段

Claude Codeの権限は .claude/settings.jsonpermissions で決まる。最小の動く例を置く。

{
  "permissions": {
    "defaultMode": "default",
    "allow": [
      "Read", "Grep", "Glob",
      "Bash(git status:*)", "Bash(git log:*)", "Bash(git diff:*)",
      "Bash(git add -u:*)", "Bash(git commit:*)",
      "Bash(npm run test:*)", "Bash(php -l:*)"
    ],
    "deny": [
      "Read(./.env)", "Read(./.env.*)",
      "Bash(git push:*)", "Bash(curl:*)", "Bash(wget:*)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash|Edit|Write",
        "hooks": [ { "type": "command", "command": ".claude/hooks/guard.sh" } ] }
    ]
  }
}

ルールは3種類で、評価順序は deny → ask → allow(公式権限ドキュメント)。最初にマッチした段で結果が決まり、ルールの「具体性」は順序を変えない。だから危険物を1個でも deny に置けば、どれだけ細かい allow があっても deny が勝つ。ReadGrepGlob のような読み取り系は括弧なしの裸名で全許可になり、lscatgrepgit log などは設定不要で常に無確認で走る(組み込みの読み取り専用判定)。

ここに、現場が必ず引っかかる誤解がある。「裸の Bash は効かない」という説だ。公式仕様は逆で、Bash は「すべてのBashコマンドにマッチ(Matches all Bash commands)」し、Bash(*) と等価で全コマンドを許可する。それでも毎回確認が出るなら、原因は「裸のBashが無効」だからではなく、deny→ask→allow の順序か、設定の優先順位(managed > コマンドライン > local > project > user)で、その裸の Bash が実効設定に効いていないかのどちらかだ。

ではなぜ上の例は裸の Bash を使わないのか。安全のためである。裸の Bash 許可は rm -rf / まで素通しにする「既定で許可」。対して可逆コマンドだけを Bash(git log:*) と列挙する「既定で拒否」は、許可漏れがあっても確認に倒れ、安全側で止まる。最小権限の原則そのものだ。

allowを書く前に知るべきBash照合の罠

Bash(...) のマッチには、知らないと穴を開ける機微がある。ワイルドカード * はどの位置にも置け、末尾の :* * と等価(末尾だけ有効)。決定的なのは語境界で、空白を伴う Bash(ls *)ls -la にマッチするが lsof にはマッチしない。空白の無い Bash(ls*) は両方にマッチしてしまう。

さらに二つ、ガード設計に直結する仕様がある。第一に、Claude Codeはシェル演算子(&& || ; | & 改行)を解釈し、複合コマンドを部分ごとに照合する。Bash(safe-cmd *) を許可しても safe-cmd && rm -rf x は通らない。第二に、timeoutnicenohup・フラグ無し xargs は照合前に剥がされるが、devbox runnpxdocker exec は剥がされない。だから次は危険な許可になる。

# ❌ allow に入れてはいけない例
"Bash(devbox run *)"   # devbox run rm -rf . まで許す
"Bash(find *)"         # find . -delete / -exec rm は常に確認だが、glob 展開の穴
"Bash(curl http://github.com/ *)"  # -X や https:// や $URL で簡単にすり抜ける

公式も「引数を制約するBashパターンは脆い」と明記し、ネットワークは curl/wget を deny にして WebFetch(domain:...) で許す方式を勧める。許可リストは「動詞(コマンド名)」で切り、「目的語(引数)」で守ろうとしないのが鉄則だ。

guard.sh を1行ずつ読む ― PreToolUseフックの実装

許可をどれだけ慎重に書いても、人間がうっかり危険な allow を足す日が来る。そこで最後の砦になるのが PreToolUseフック――AIがツールを実行する直前に走る外部スクリプトだ。フックには標準入力からJSONが渡る。

{
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": { "command": "git -C /repo push origin main" }
}

フックは終了コードで応答する。exit 2 でブロック(stderrの理由がClaudeに返る)、exit 0 で通過。重要なのは、公式が「exit 2 のブロックは許可ルールが評価される前にツールを止め、allow が通すはずの呼び出しもブロックする」と明記している点だ。つまりフックは allow を上書きできる。以下が .claude/hooks/guard.sh の中核(documented R1–R10 を実装した教材版)。

#!/usr/bin/env bash
# .claude/hooks/guard.sh — 決定論ガード(R1–R10)。危険なら exit 2 で止める。
set -euo pipefail
input="$(cat)"                                        # フックのJSONを丸ごと読む
tool="(jq -r '.tool_name // empty' <<<"input")"
cmd="( jq -r '.tool_input.command // empty' <<<"input")"
file="(jq -r '.tool_input.file_path // empty' <<<"input")"

block(){ echo "BLOCKED(1):2" >&2; exit 2; }        # exit 2 = allow より優先

# ── R7: enforcement ファイルへの AI 編集を最優先で禁止 ──
case "$file" in
  *.claude/hooks/guard.sh|*.claude/settings.json|*tools/assert-deploy-sha.sh)
    [ "tool" = Edit ] || [ "tool" = Write ] && block R7 "guard/settings の自己改変は禁止" ;;
esac
[ "$tool" = Bash ] || exit 0                           # 以降は Bash のみ検査

# ── GITOPT: git -C <path> / -c k=v / --git-dir=… を吸収しサブコマンドを露出 ──
norm="(sed -E 's/( +(-C +[^ ]+|-c +[^ ]+|--git-dir=[^ ]+))+/git/g' <<<"cmd")"

grep -Eq '\bgit +add +(-A|--all|\.)\b'        <<<"$norm" && block R1  "git add -A 禁止"
grep -Eq '\bgit +push\b.*\bmain\b'            <<<"$norm" && block R3  "main 直 push"
grep -Eq '\bgit +push\b.*(--force|-f)\b'      <<<"$norm" && block R10 "force-push"
grep -Eq '\bgit +(filter-repo|filter-branch)\b'<<<"$norm" && block R10 "履歴改変"
grep -Eq '(^|[^-])\brm +-[a-z]*[rf]'          <<<"$cmd"  && block R4  "rm -rf"
grep -Eq '\b(DROP|TRUNCATE)\b'                <<<"$cmd"  && block R4  "DROP/TRUNCATE"
grep -Eq '\b(curl|wget)\b.*(-X +(POST|PUT|DELETE)|--data|--upload-file|-T )' <<<"$cmd" && block R9 "外部送信"

exit 0                                                 # 全ルール通過 → 許可判定に委ねる

読みどころは3点。jq で JSON から tool_namecommand を取り出すこと。block() が必ず exit 2 で返すこと(JSON permissionDecision:"deny" 方式は対象が allow 掲載時に無視され得る不具合報告 #18312 があり、exit 2 が堅い)。そして norm を作る sed ――これがガードの心臓だ。

「git -C /repo push origin main」は4層をどう通るか

なぜ GITOPT が要るのか。Claude Codeはシェル演算子では分割するが、git 自身のオプション -C <path> までは正規化しない。だから naive な deny Bash(git push:*)git -C /repo push… を取りこぼす。1つのコマンドが防御を通る様子を最後まで追う。

入力: Claude が Bash で  git -C /repo push origin main  を実行しようとする
  │
  ├─[層0] settings deny  "Bash(git push:*)"
  │        → 文字列は "git -C /repo push…" で始まる → ❌ マッチせず素通り
  │
  ├─[層1/2] PreToolUse フック guard.sh 発火(exit 2 は allow にも優先)
  │        sed で  git -C /repo  →  git  に正規化
  │        norm = "git push origin main"
  │        R3 "git push … main" にマッチ → exit 2 で 🛑 ブロック
  │        stderr "BLOCKED(R3): main 直 push" が Claude に返る
  │
  └─(仮に層1/2を抜けても)
           [層3] .githooks/pre-commit … 人間が端末で叩いた場合の砦
           [層4] session_autolog に guard=PASS/FAIL が残る(改ざん痕跡)

ここに2つの教訓が凝縮する。第一に、deny の glob 単独では git -C を防げない。フックの正規化が無ければ、最も基本的な「main を守る」がすり抜ける。第二に、フックはAIのツール呼び出しにしか介在しない。人間が端末で同じコマンドを叩けば guard.sh は走らない。その穴を塞ぐのが、Claude Codeの外側で git が回す pre-commit 層だ。AI用の檻と人間用の檻は別物で、両方を張って初めて閉じる。

檻が自分を検証する ― selftest と pre-commit のコード

防御ルールを足すたび「他に穴が空いてないか」を手で確かめるのは続かない。bin/guard-selftest.sh は、合成したフックJSONを guard.sh に流し込み、終了コードだけを照合する回帰テストだ。本物の git は一切実行しないから安全に何百回でも回せる。

#!/usr/bin/env bash
# bin/guard-selftest.sh — guard.sh の自動回帰テスト(git は実行しない)
PASS=0; FAIL=0
mk(){ printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$1"; }
B(){  # B <名前> <JSON> <期待: PASS|BLOCK>   ← 名前と引数の間のスペース必須(落とし穴#9)
  local name="1" json="2" want="$3" rc
  printf '%s' "json" | .claude/hooks/guard.sh >/dev/null 2>&1; rc=?
  { [ "want" = BLOCK ] && [rc -eq 2 ]; } || { [ "want" = PASS ] && [rc -eq 0 ]; } \
    && PASS=((PASS+1)) || FAIL=((FAIL+1)); echo "FAIL: name (rc=rc want=$want)"; }
}
B "git log は通す"            "$(mk 'git log --oneline')"             PASS
B "rm -rf を止める"            "$(mk 'rm -rf build')"                  BLOCK
B "git -C 越し push を止める"  "$(mk 'git -C /repo push origin main')" BLOCK   # ← GITOPT の核心
B "filter-repo を止める"       "$(mk 'git filter-repo --force')"       BLOCK
echo "PASS=PASS FAIL=FAIL"

健全性の証は PASS=40 FAIL=0(現場の値)。新しい防御 R11 を足すなら、まず「止めるべき/通すべき」ケースを selftest に書いてから guard.sh を直す――テストが仕様を先に固定する。その固定を歴史に刻むのが .githooks/pre-commit だ。

#!/usr/bin/env bash
# .githooks/pre-commit  (git config core.hooksPath .githooks で配線)
set -euo pipefail
# ① 退行した檻はコミットさせない
bash bin/guard-selftest.sh | grep -q 'PASS=40 FAIL=0' \
  || { echo "pre-commit: guard-selftest FAIL。退行をコミット不可。" >&2; exit 1; }
# ② enforcement 改変は明示承認を要求
if git diff --cached --name-only \
   | grep -Eq '\.claude/(hooks/guard\.sh|settings\.json)|tools/assert-deploy-sha\.sh'; then
  [ "${ALLOW_GUARD_CHANGE:-0}" = 1 ] \
    || { echo "enforcement 改変には ALLOW_GUARD_CHANGE=1 が必要" >&2; exit 1; }
fi

これは人間のコミットに効く(guard.sh が届かない領域)。当初はファイル属性 chattr +i で物理的に改変不能にする案だったが、リポジトリが Windows ドライブ上の 9p/drvfs に載るため lsattr が "Operation not supported" を返し非対応。git フックによるプラットフォーム非依存の代替へ切り替えた。改行混入で $'\r' エラーが出る罠は .gitattributes*.sh 等の eol=lf を書いて恒久対処する。最後に bin/session-record.sh がセッション終了時に memory/session_autolog.mdbranch/HEAD/dirty/guard=PASS を一行追記し、毎回の判定を痕跡として残す。提案はAI・適用は人間(R7)→selftestで穴を即可視化→pre-commitで退行を拒否→autologに記録、という輪が回り、檻が自分を検証し続ける。

subagentはどの檻で動くか ― 委譲と継承の実際

main エージェントが派生させる subagent も、同じ檻の中にいる建前だ。subagent は独自のシステムプロンプト・文脈窓・ツール一覧・権限モードを持つ隔離インスタンスで、中間のツール呼び出しを自分の文脈に留め、最終出力だけを親へ返す。定義は YAML フロントマター付きの Markdown である。

---
name: repo-auditor
description: リポジトリの構成を読み取り専用で監査する。書き込みはしない。
tools: Read, Grep, Glob, Bash      # ← 省略すると MCP 含む全ツールを継承してしまう
model: sonnet
---
あなたはリポジトリ監査担当です。ファイルを読み、構成を要約します。
変更や削除は提案に留め、自ら書き込み・コミット・push はしません。

tools を省くと全ツールを継承するため、意図的に絞るのが定石。会話全体を引き継ぐ「fork」は隔離を捨てて文脈を共有する変種で、Agent(Explore) のような権限ルールで使える subagent を deny 制御できる。ただし「同じ檻」は版に依存する。subagent が親の許可リストを継承しない不具合(GitHub #22665・#14714)、v2.1.56 以降に読み取りすら毎回確認を求める退行(#28584)、フック/権限の継承を求める要望(#27661)が報告されてきた。新案件へ移すなら、subagent 経由でも guard が実際に発火するかを selftest で実走確認する――一貫性を信じず、檻が閉じているかを各機構で測るのが安全側の作法だ。

越えられない壁 ― 設定の優先順位と人間ゲート

自律を広げても侵せない床は、設定の優先順位で物理的に守れる。Claude Codeの managed settings(組織配布の管理設定)に置いた deny は、コマンドライン引数でも project/user 設定でも覆せない。優先順位は managed > コマンドライン > local project > project > user で、どこか一段で deny されれば他のどの段も許可できない。permissions.disableBypassPermissionsMode を managed に置けば、危険な bypassPermissions モード自体を封じられる。現場が自律ダイヤルを回しても、最後の安全装置は管理者が握ったまま、という構図が作れる。

境界は三層に切り分けるのが要点だ。後戻り可能なタスク(調査・台帳・コミット・テスト、git は develop まで)は承認ゲート無しで自律。main 直 push・rm -rfDROP/TRUNCATE・本番DBパス変更・機密コミット・enforcement の AI 改変は、コードで強制された越えられない壁(R1–R10)。本番反映・実顧客データ・金銭・外部送信は人間承認が必須の絶対ゲート。最小権限と職務分掌(separation of duties)を、エージェントに対して具体化したものにほかならない。R7 が guard/settings への AI 編集を file_path で機械的に止めるのは、自己改変の禁止を性善説でなくコードで担保するためだ。許可リストの拡張(自律)と、独立した deny・フック・git層(防御)が直交している――この一点が、自律と安全を同時に引き上げる本設計の核心であり、新しいリポジトリへもそのまま移植できる一般解になる。