learn-claude-code s03 実践:ツール実行前に「3ゲートの権限パイプライン」で Agent の安全性を実現する
本文は learn-claude-code の第 3 章(s03: Permission)の実装解説であり、Agent がツールを実行する前に「どの操作を禁止し、どの操作にユーザー承認を求め、どの操作をそのまま通すか」をコードが判断する仕組みを扱う。読むことで、硬拒否リスト(deny list)、ルールマッチング、ユーザー承認という 3 ゲートから成るパーミッションパイプラインの設計思想と、その agent loop への 1 行埋め込みの実装パターンを、実装ソース まで対応させながら理解できる。
背景:s02 のツールディスパッチに残っていた穴
s02 では Agent が 5 つのツール(bash / read_file / write_file / edit_file / glob)を持ち、TOOL_HANDLERS ディスパッチ表で一元管理されるようになった。ファイル系ツールは safe_path() によりワークスペース外へのアクセスが弾かれる(s02 の実装 を参照)。
しかし bash は制限なしだ。「プロジェクトを掃除して」と頼めば、モデルが rm -rf / を発行しかねない。s02 では run_bash 内に ["rm -rf /", "sudo", ...] といった簡易チェックが埋め込まれていたが、これはツール実装内に安全ロジックが混入した状态で、拡張性にも説明性にも欠ける。
s03 が打ち出す方針は明確だ:安全性はモデルを信頼することではなく、コードに頼る。判断はツール実行「前」に、ループとツール実装の両方から分離された独立したゲートとして挟まれる。
解決策:チェックはループに 1 行、ツールは不変
s02 のループ構造(LLM 呼び出し → stop_reason 判定 → ツール実行 → tool_result 追加)は完全に維持される。唯一の変更は、ツール実行の直前に check_permission() を挿入することである。各ツール呼び出しは固定順序で 3 つのゲートを通過する:ハード拒否が最優先 → ルールマッチング → 該当すればユーザー承認。どのゲートにも命中しなければ、そのまま実行される(日常の操作の大半はこの経路を通る)。
| ゲート | 役割 | 一致時 |
|---|---|---|
| 1. 拒否リスト | 常に禁止される操作(rm -rf /、sudo) |
即座に拒否、実行しない |
| 2. ルールマッチング | コンテキスト依存の操作(作業ディレクトリ外への読み書き、rm 文件) |
ゲート 3 へ |
| 3. ユーザー承認 | ゲート 2 が一致した場合、ユーザー確認を待機 | ユーザーが許可または拒否を決定 |
この「3 段の判断を分離する」構成には設計上の狙いがある。Web 教材の設計判断メモ にある通り、ハード拒否・ルール一致・ユーザー確認を分けることで、「そのコマンドは絶対に禁止だったのか、リスクがあるのか、単に確認待ちなのか」を区別でき、ポリシーが説明可能になる。一方、単一の allow/deny 関数にすると短くなる代わりに、コマンドが止まった理由が隠れてしまう。
ゲート 1:ハード拒否リスト(DENY LIST)
最初に確認されるのが硬拒否表で、一致すればブロックメッセージを返し、以降のゲートには進まない。
DENY_LIST = [
"rm -rf /", "sudo", "shutdown", "reboot",
"mkfs", "dd if=", "> /dev/sda",
]
def check_deny_list(command: str) -> str | None:
for pattern in DENY_LIST:
if pattern in command:
return f"Blocked: '{pattern}' is on the deny list"
return None
注意すべきは、このリストが単純な文字列部分一致(pattern in command)である点だ。原文書も明記しているが、これは「権限ゲートをループのどこに置くか」を示すための最小実装であり、完全なセキュリティ境界ではない。実際の 実装 でも同一の 7 パターンがそのまま使われており、"sudo" のように部分一致で弾く粒度である(したがって sudo を含む無害なコマンドもブロックされる可能性がある、という許容される粗さがある)。本番の完全な防御としては、シェルパーサによる AST 級マッチングや sandbox 執行が必要になる、という理解で読み替えるべきだ。
ゲート 2:ルールマッチング(PERMISSION_RULES)
2 番目のゲートは「いつユーザーに聞くべきか」を記述する。各ルールは対象ツール(tools)とチェック条件(check lambda)を指定し、条件を満たしたツール呼び出しに対しては理由メッセージを返す。
PERMISSION_RULES = [
{
"tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Access outside workspace",
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command",
},
]
def check_rules(tool_name: str, args: dict) -> str | None:
for rule in PERMISSION_RULES:
if tool_name in rule["tools"] and rule"check":
return rule["message"]
return None
2 つのルールの要点:
- ワークスペース外アクセスルール:ファイル系ツールの
pathをWORKDIR / pathで結合してから.resolve()し、結果がWORKDIRの相対パスに収まるか(is_relative_to(WORKDIR))を判定する。../やシンボリックリンクによるエスケープもresolve()により正規化されるため、/etc/somethingへの書き込みや../../etc/hostsへの参照がここで捕获される。 - 破壊的 bash コマンドルール:コマンド文字列に
"rm "(末尾の空白を含む)、"> /etc/"、"chmod 777"のいずれかが含まれれば「Potentially destructive command」としてユーザー確認へ回す。
check_rules は「どのツールがどのルールに該当するか」をループ内で逐次照合する。ツールごとにチェックを書き散らすのではなく、ルール表にデータとして記述している点が、後述する拡張性の鍵になる。
ゲート 3:ユーザー承認(ask_user)
ルールが一致した呼び出しはここで停止し、ユーザー入力(y / yes のみ許可)を待機する。
def ask_user(tool_name: str, args: dict, reason: str) -> str:
print(f"\n⚠ {reason}")
print(f" Tool: {tool_name}({args})")
choice = input(" Allow? [y/N] ").strip().lower()
return "allow" if choice in ("y", "yes") else "deny"
設計上の重要な既定動作は default deny である:strip().lower() した入力が ("y", "yes") でなければ deny を返す。つまり Enter のみで無应答にすると拒否され、曖昧な入力は安全側へ倒す。この関数は同期の input() でループを停止させるため、「承認前にツールが実行されることはない」ことを実行順で保証している。
3 ゲートを直列に接続する:check_permission とループへの挿入
3 つのゲートを 1 関数に束ねる:
def check_permission(block) -> bool:
# ゲート 1: ハード拒否
if block.name == "bash":
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n⛔ {reason}")
return False
# ゲート 2 + 3: ルールマッチング → ユーザー承認
reason = check_rules(block.name, block.input)
if reason:
decision = ask_user(block.name, block.input, reason)
if decision == "deny":
return False
return True
そして s02 の agent loop に1 行だけ足す:
for block in response.content:
if block.type == "tool_use":
if not check_permission(block): # ← 新規
results.append({... "content": "Permission denied."})
continue
output = TOOL_HANDLERSblock.name # s02 既存
results.append(...)
ここには 1 つ、見落とされやすいが重要な設計がある。ブロックされた呼び出しも必ず tool_result を返すことだ。実装 では、check_permission が False を返すと {"type": "tool_result", "tool_use_id": block.id, "content": "Permission denied."} が messages に追加され、continue によって次へ進む。Anthropic API のツール呼び出しプロトコル上、発行された tool_use には対応する tool_result が必須であり、黙ってスキップすると会話状態が壊れる。また、モデル自身に「なぜ実行されなかったか」をフィードバックすることで、同じ危険リクエストを繰り返さず別の手段を選ぶことができる。設計判断メモ の 3 項目("Blocked Calls Still Produce Loop State")がまさにこの点を指しており、静かに skip するとモデルが同じ不安全リクエストを繰り返しうる、と説明されている。
ソースコードによる実装確認:ドキュメント例との差分と補足
上記コード例は s03_permission/README.ja.md の要約版であり、実際の s03_permission/code.py を読むと、さらにいくつかの実装事実が確認できる。
1. セキュリティチェックがツール実装から抜けている。s02 の run_bash は関数内に dangerous リストを埋め込んでいたが、s03 の run_bash にはそのチェックが残っていない(subprocess.run の 120 秒タイムアウトと 50000 文字への出力截断のみ)。同様にファイル系ツールも s02 の safe_path() を使わず、run_read / run_write / run_edit は (WORKDIR / path).resolve() を直接利用する。つまり s03 では「パス境界・破壊的操作の判定」がツール実装から完全に外され、ゲート 1/2 が唯一の判定箇所になった。ポリシーは 1 か所で管理できるが、これはゲートを通さずにツールを直接呼ぶ実装は安全を失う、という構造上の依存を意味する。
2. SYSTEM プロンプトの修正。s03 の システムプロンプト は s02 の "Act, don't explain" から、"You are a coding agent at {WORKDIR}. All destructive operations require user approval." に変わっている。つまり「安全はコードが保証する」一方で、モデル側に「破壊的操作は承認フローがある」という事実も伝え、モデルが承認を前提に計画を立てやすくしている。
3. 実装でのメッセージ文言。実際の PERMISSION_RULES の 1 件目の message は "Writing outside workspace"(source)、ask_user / check_permission は ⚠ ⛔ の絵文字ではなく ANSI カラーコード(\033[33m 黄 / \033[31m 赤)で [permission] / [blocked] を表示する。ターミナル上の視認性への配慮である。
4. ループの残りは s02 と同一。agent_loop は max_tokens=8000 でメッセージを送り、stop_reason != "tool_use" なら終了、それ以外なら response.content の各 tool_use block を原本の順序で 1 つずつ「権限チェック → TOOL_HANDLERS.get(block.name) でハンドラ取得 → 実行 → tool_result 収集」し、最後に一括して user メッセージとして追加する。s02 で確立した「1 ツール追加 = TOOLS 1 エントリ + HANDLERS 1 行」の構造は一切変更されていない。
s02 との変更点まとめ
| コンポーネント | 変更前 (s02) | 変更後 (s03) |
|---|---|---|
| セキュリティモデル | 一部ツール内チェックのみ(safe_path、bash 内蔵リスト。モデルを信頼する部分大) |
3 ゲート権限パイプラインが唯一の判定層 |
| 新規関数 | — | check_deny_list, check_rules, ask_user, check_permission |
| ループ | すべてのツールを直接実行 | 実行前に check_permission() を挿入 |
| 拒否された呼び出し | 該当なし | tool_result: "Permission denied." でループ状態を維持 |
試してみる:実行環境と検証プロンプト
requirements.txt によれば必要な依存は anthropic>=0.25.0 と python-dotenv>=1.0.0 のみ。.env に ANTHROPIC_API_KEY と MODEL_ID(任意で ANTHROPIC_BASE_URL を設定すれば、AUTH_TOKEN が自動的に pop されプロキシベースの接続に切り替わる、という 初期化処理 がある)を用意する:
pip install anthropic python-dotenv
cd learn-claude-code
python s03_permission/code.py
起動後は s03 >> プロンプトで対話し、q で終了する。以下の 4 プロンプトで 3 パス(自動通過 / 確認 / 即拒否)を観察するのが推奨される:
Create a file called test.txt in the current directory— ワークスペース内の書き込みでルールに命中しないはず → そのまま通過Delete the file test.txt— bash +rmがルール 2 に命中 → ゲート 2 が発動し y/N 確認What files are in the current directory?— 読み取り系のみ → すべて通過Try to write a file to /etc/something— ワークスペース外への書き込み → ゲート 2 が発動し y/N 確認
観察の焦点は:どの操作がそのまま通過したか、どこで確認が求められたか、そして(意図的に sudo ... を試すと)どこが即座に拒否されたか、の 3 分類を自分の目で確認することである。
動作シナリオの追跡
Web 教材の s03 シナリオ定義 が、このパイプラインの完全な 1 サイクルを可視化している。ユーザーが "Delete the temporary build directory." と要求し、モデルが bash: rm -rf /tmp/build-cache を発行すると:
- ゲート 1(deny list)→ 該当なし、通過;
- ゲート 2(rules)→
"rm "に一致、「destructive command → ask user」; - ゲート 3 → 実行前に停止しユーザー承認を待つ。承認されると通常通り handler に渡り、
tool_result: (no output)が返る。
このシナリオは「モデルはアクションを要求できるが、実行を許可するのは harness である」という s03 の核心を端的に示している。
次のステップ:s04 Hooks への移行
s03 の check_permission() はループ内にハードコードされている。これ以上「bash の各呼び出しをログにしたい」「write 後に git add を自動実行したい」のような拡張を足し始めると、ループは直ぐに膨張し illegible になる。learn-claude-code の次の章である s04 Hooks では、ツール実行の前後にフックを吊るす機構を導入し、拡張ロジックをループの外へ出すことで、ループ自体を安定したコアとして保つ設計へ移行する。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00