夜のうちに走らせていたアセット変換が、朝には一行も進んでいませんでした。
個人開発で運用している壁紙アプリでは、追加した画像を決まった解像度へ落として命名規則を揃える作業を、コーディングエージェントに任せております。手順そのものは単純ですので、承認の線引きだけ policy に書いて、あとは放っておく——そういう組み方をしていたのです。
止まっていた理由を探すつもりでログを開いたのですが、見つかったのはもっと居心地の悪い事実でした。止めたかったコマンドを止めるはずのルールが、そもそも一度も一致していなかったのです。止まっていたのは別のルールが原因で、私が半年ほど「効いている」と思い込んでいた行は、ずっと空振りしていました。
その日のうちに、手元の policy ファイルを頭から点検して回りました。出てきたものを書き残します。
先に結論として、当たり判定のほうから数え直しました
policy のルールは、書いた文字列がそのままコマンドに当たるわけではありません。照合の対象も、読み込まれる場所も、実行のモードによって変わります。私が踏んだ四つは、どれも「書き方が間違っている」のではなく、書いたものが評価されるまでに届いていないという性質のものでした。
| 症状 | 実際に起きていたこと | 気づく手がかり |
| deny のルールが効かない | 正規表現が JSON 文字列の先頭に当たっていた | 拒否メッセージが一度も出ない |
| プロジェクトに置いたルールが無視される | その階層は現在読み込まれない | ユーザー階層へ移すと急に効く |
| 無人実行が確認待ちにならず止まる | ask_user が deny として扱われた | 対話で流すと通る |
| リダイレクトの確認が出ない | 緩いモードで降格処理が飛ばされる | 既定モードでは確認が出る |
点検は次の順で回しました。上にあるものほど、見つかったときの取り返しがつきません。
- 置き場所が読み込まれる階層かどうかを確かめます
- deny のルールが実際に一度でも発火したかを確かめます
- 無人で走らせる経路と対話の経路を分けているかを確かめます
- 書き込み先を条件に入れているかを確かめます
この順番をお勧めします。置き場所が外れていると、下の三つをいくら丁寧に書いても評価されないためです。
順に書いてまいります。
commandRegex の ^ は、コマンドではなく JSON の先頭に当たります
私が最初に書いたのは、破壊的な削除を止めるつもりの一行でした。
[[rule]]
toolName = "run_shell_command"
commandRegex = "^rm -rf"
decision = "deny"
priority = 900
正規表現としては何もおかしなところがありません。ところが公式のリファレンスを読み直すと、commandRegex は引数を安定した JSON 文字列に変換したものに対して評価される、と書かれておりました。つまり照合される相手はコマンド本体ではなく、{"command":"rm -rf ./build"} という一続きの文字列です。
手元で確かめました。
import json
import re
args = {"command": "rm -rf ./build"}
subject = json.dumps(args, separators=(",", ":"))
print("照合対象:", subject)
for pattern in ("^rm -rf", "rm -rf", '"command":"rm -rf'):
print(f" {pattern!r:<22} -> {bool(re.search(pattern, subject))}")
出力はこうなりました。
照合対象: {"command":"rm -rf ./build"}
'^rm -rf' -> False
'rm -rf' -> True
'"command":"rm -rf' -> True
^ を置いた瞬間に、そのルールは一致しなくなります。しかも一致しなかったことは何も報せてくれません。deny のルールが空振りしても、画面には何も出ないのです。拒否のルールは、実際に拒否された場面を一度も見ていないなら、効いている証拠がありません。
先頭に固定したいのであれば commandPrefix を使うほうが安全です。あちらは「コマンド引数がこの文字列で始まる」という意味に素直に対応します。正規表現でなければ書けない条件だけを commandRegex に残し、アンカーは使わないようにしております。この二つを守るだけで、私の空振りは消えました。
プロジェクトに置いた policies は、いま読まれていません
次に出てきたのがこれです。私は受託の案件ごとにルールを変えたくて、プロジェクト直下の .gemini/policies/ に TOML を置いておりました。案件の性質が違えば触ってよい範囲も違いますので、リポジトリと一緒に持ち歩けるほうが筋がよいと思ったのです。
公式のリファレンスには、ワークスペース階層のポリシーは現在機能していない、という注意書きが入っておりました。階層としては Default / Extension / Workspace / User / Admin と定義されているのに、そのうち Workspace だけが読み込まれない状態が続いております。
ここで厄介なのは、置き場所を間違えても警告が出ないことです。ファイルは正しい TOML ですし、書式にも誤りはありません。ただ誰も読みに来ないだけです。私は案件ごとの線引きを丁寧に書き分けたつもりで、実際にはユーザー階層に置いた古い一枚だけが全案件に効いておりました。
いまは案件ごとの差分をユーザー階層のファイル名で分け、リポジトリ側には「この案件はどの線引きで動かすか」を記した読み物だけを置いております。読まれない場所に設定を置くより、読まれる場所に置いたうえで由来をメモに残すほうが、後から自分を助けてくれます。
ask_user は、無人で走らせた瞬間に deny になります
三つめが、朝に何も進んでいなかった直接の原因でした。
決定は allow / deny / ask_user の三つです。このうち ask_user について、リファレンスには一行だけ添えられております——非対話モードでは deny として扱われる、と。
対話で使っているぶんには、ask_user はいちばん安全な選択に見えます。危ないかもしれないので、そのつど自分の目で見ておきたい——そういう構えです。ところが同じルールのまま無人の経路に持っていくと、確認が出ないのですから、待たずに拒否されます。エラーらしいエラーも出ません。エージェントは拒否された旨をモデルに返して、別の手を探しはじめます。
私の夜間実行が一行も進んでいなかったのは、これでした。write_file に ask_user を置いたまま、無人で走らせていたのです。確認を挟むという判断は、確認できる場所にいるときだけ有効です。
いまは経路ごとに interactive を明示して分けております。
# 手元で対話しながら走らせるとき: そのつど見ます
[[rule]]
toolName = "write_file"
decision = "ask_user"
interactive = true
priority = 120
# 無人で走らせるとき: 触ってよい場所だけを許し、それ以外は拒否します
[[rule]]
toolName = "write_file"
argsPattern = '"file_path":"[^"]*/assets/generated/'
decision = "allow"
interactive = false
priority = 130
[[rule]]
toolName = "write_file"
decision = "deny"
denyMessage = "無人実行では assets/generated 以下のみ書き込めます"
interactive = false
priority = 110
拒否するときは denyMessage を必ず添えるようにしました。返る文言が「拒否されました」だけだと、モデルは条件を変えて何度でも試します。どの条件なら通るのかを一行書いておくと、余計な往復が減ります。
リダイレクトの確認は、緩いモードでは飛びます
四つめは、私が踏んだのではなく、点検の途中で知って肝が冷えたほうです。
リファレンスには allowRedirection という項目があり、既定ではリダイレクト(> や >> など)を検出した時点で、ルールが一致していても確認を求める、と書かれております。読み書きの向き先が変わる操作は別扱いにする、という設計です。理にかなっていると思いました。
ところが 2026 年 9 月 13 日に起票された不具合報告によると、AUTO_EDIT と YOLO のモードでは、この降格処理そのものが早期 return で飛ばされております。報告に引用されている再現条件は approvalMode=YOLO と command="echo x > /outside/ws" という素朴なもので、解析できなかったコマンドも拒否ではなく許可側に倒れる、と指摘されております。起票時点で area/security と priority/p1 が付いたまま open です。
つまり、リファレンスが「既定で確認する」と書いている挙動は、自動承認モードでは当てになりません。私は「許可リストに載せたコマンドしか動かないのだから、あとは任せてよい」と考えておりました。許可リストが見ているのはコマンドの名前で、実際に副作用の大きさを決めるのは引数と出力先です。ここを取り違えていました。
対処として、緩いモードで許可する write 系のルールには、リダイレクトの扱いを必ず明示することにしました。書かないという選択肢を残さない、というだけの話ですが、書いてあれば後から読み返せます。
MCP サーバー名のアンダースコアは、ルールを静かに外します
もう一つ、点検しなければ一生気づかなかったであろうものがあります。
MCP のツールは内部で mcp_サーバー名_ツール名 という完全修飾名に組み立てられます。そして解析側は、mcp_ に続く最初のアンダースコアで分割します。サーバー名にアンダースコアを入れていると、分割位置がずれます。
def split_fqn(fqn: str):
rest = fqn[len("mcp_"):]
server, _, tool = rest.partition("_")
return server, tool
for fqn in ("mcp_asset-tools_resize", "mcp_asset_tools_resize"):
server, tool = split_fqn(fqn)
print(f" {fqn:<26} -> server={server!r} tool={tool!r}")
mcp_asset-tools_resize -> server='asset-tools' tool='resize'
mcp_asset_tools_resize -> server='asset' tool='tools_resize'
下の行のように解釈されてしまうと、asset_tools を対象に書いたルールはどれも当たりません。リファレンス自身が、この場合ワイルドカードやセキュリティのルールが黙って失敗しうると注意しております。サーバー名の区切りはハイフンにしておく、と覚えてしまうのがいちばん確実です。
階層をまたいで priority の数値で勝とうとしないことにしました
優先度の項も一度は読み解こうとしたのですが、途中でやめました。
リファレンスの表では階層の基数が Default 1 / Extension 2 / Workspace 3 / User 4 / Admin 5 と定義されております。ところが同じ節に置かれた計算例のほうは、User の priority: 100 が 3.100、Admin の priority: 20 が 4.020 になる、と書かれております。表と例で階層の番号がずれているのです。おそらく Extension が加わる前の数え方が例に残ったのだと思いますが、どちらが現行なのかは、この文面だけでは決められません。
ただし、迷う必要のない部分もあります。相対的な順序、つまり Admin が User より強く、User が Default より強いことは、どちらの数え方でも変わりません。ですから私は、数値の絶対値で勝とうとするのをやめました。階層をまたいだ制御は階層に任せ、TOML の priority は同じファイルの中での並び順を決めるためだけに使っております。
範囲だけは覚えておく価値があります。priority は 0 から 999 です。うっかり 1000 と書くと、意図した順序になりません。
点検を一本のスクリプトに落としました
ここまでの五つは、どれも目で読んでも見つけにくいものばかりでした。ですので手元で回せる形にしました。ユーザー階層と、念のためプロジェクト階層の両方を渡して、当たらないルールと危うい書き方を並べて出します。
#!/usr/bin/env python3
"""Gemini CLI の policy TOML を読み、書いたのに効かないルールを洗い出します。
実行例:
python3 policy_audit.py ~/.gemini/policies ./project/.gemini/policies
"""
from __future__ import annotations
import sys
from pathlib import Path
try: # Python 3.11 以降は標準ライブラリに入っています
import tomllib
except ModuleNotFoundError: # 3.10 以前は pip install tomli で補います
import tomli as tomllib # type: ignore[no-redef]
WRITE_TOOLS = {"run_shell_command", "write_file", "replace"}
PERMISSIVE_MODES = {"yolo", "autoEdit"}
ADMIN_DIRS = {
Path("/etc/gemini-cli/policies"),
Path("/Library/Application Support/GeminiCli/policies"),
}
def is_workspace_dir(d: Path) -> bool:
"""読み込まれる場所(ユーザー/管理者)以外の .gemini/policies を真とします。"""
d = d.resolve()
if d == (Path.home() / ".gemini" / "policies").resolve():
return False
if d in ADMIN_DIRS:
return False
return d.parts[-2:] == (".gemini", "policies")
def load_rules(path: Path):
"""TOML を1ファイルずつ読み、(ファイル名, 通し番号, ルール) で返します。"""
out = []
for f in sorted(path.glob("*.toml")):
try:
data = tomllib.loads(f.read_text(encoding="utf-8"))
except tomllib.TOMLDecodeError as e:
# 読めないファイルは丸ごと無視されます。黙らせずに報せます。
out.append((f.name, None, {"__parse_error__": str(e)}))
continue
for i, rule in enumerate(data.get("rule", []), start=1):
out.append((f.name, i, rule))
return out
def audit(rule: dict) -> list[tuple[str, str]]:
"""1つのルールを見て (深刻度, 指摘) の一覧を返します。"""
found = []
names = rule.get("toolName")
names = [names] if isinstance(names, str) else list(names or [])
modes = set(rule.get("modes") or [])
decision = rule.get("decision")
creg = rule.get("commandRegex")
if isinstance(creg, str) and creg.startswith("^"):
found.append(("BLOCK", f"commandRegex が ^ で始まっています({creg!r})。"
"照合対象は JSON 文字列のため一致しません"))
mcp = rule.get("mcpName")
if isinstance(mcp, str) and "_" in mcp and mcp != "*":
found.append(("BLOCK", f"mcpName にアンダースコアが含まれています({mcp!r})。"
"ハイフンに変えてください"))
if decision == "ask_user" and rule.get("interactive") is not False:
found.append(("WARN", "decision=ask_user は非対話実行では deny になります。"
"無人の経路なら allow か deny を明示してください"))
if decision == "allow" and (modes & PERMISSIVE_MODES) and set(names) & WRITE_TOOLS:
if "allowRedirection" not in rule:
found.append(("WARN", f"modes={sorted(modes & PERMISSIVE_MODES)} で write 系を allow しています。"
"リダイレクトの扱いを allowRedirection で明示してください"))
if decision == "allow" and "*" in names and not rule.get("argsPattern"):
found.append(("BLOCK", "toolName=* を無条件に allow しています。"
"argsPattern か commandPrefix で条件を足してください"))
pri = rule.get("priority")
if isinstance(pri, int) and not (0 <= pri <= 999):
found.append(("BLOCK", f"priority={pri} は 0〜999 の範囲外です"))
return found
def main(argv: list[str]) -> int:
if len(argv) < 2:
print(__doc__)
return 2
worst = 0
for raw in argv[1:]:
d = Path(raw).expanduser()
if not d.is_dir():
print(f"-- {d}: ディレクトリがありません")
continue
if is_workspace_dir(d):
print(f"-- {d}")
print(" BLOCK ワークスペース階層のポリシーは現在読み込まれません。"
"ユーザー階層か管理者ディレクトリへ移してください")
worst = max(worst, 2)
continue
print(f"-- {d}")
rules = load_rules(d)
if not rules:
print(" (ルールがありません)")
continue
for fname, idx, rule in rules:
if "__parse_error__" in rule:
print(f" BLOCK {fname}: TOML を読めません — {rule['__parse_error__']}")
worst = max(worst, 2)
continue
for level, msg in audit(rule):
print(f" {level:<6} {fname} [[rule]] #{idx}: {msg}")
worst = max(worst, 2 if level == "BLOCK" else 1)
print("判定:", {0: "OK", 1: "WARN あり", 2: "BLOCK あり"}[worst])
return 1 if worst == 2 else 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
点検前の私のファイルに近い状態を渡すと、こう出ます。
-- /home/dolice/.gemini/policies
BLOCK my-rules.toml [[rule]] #1: commandRegex が ^ で始まっています('^rm -rf')。照合対象は JSON 文字列のため一致しません
WARN my-rules.toml [[rule]] #2: modes=['autoEdit', 'yolo'] で write 系を allow しています。リダイレクトの扱いを allowRedirection で明示してください
BLOCK my-rules.toml [[rule]] #3: mcpName にアンダースコアが含まれています('asset_tools')。ハイフンに変えてください
WARN my-rules.toml [[rule]] #4: decision=ask_user は非対話実行では deny になります。無人の経路なら allow か deny を明示してください
BLOCK my-rules.toml [[rule]] #5: toolName=* を無条件に allow しています。argsPattern か commandPrefix で条件を足してください
BLOCK my-rules.toml [[rule]] #5: priority=1000 は 0〜999 の範囲外です
-- /home/dolice/work/client-site/.gemini/policies
BLOCK ワークスペース階層のポリシーは現在読み込まれません。ユーザー階層か管理者ディレクトリへ移してください
判定: BLOCK あり
BLOCK があれば終了コード 1 を返しますので、無人の経路の入口で走らせて、通らなければ本体を起動しないという使い方ができます。設定を読み込むより前に、設定そのものを検査しておきます。順番としてはこちらが先だと、いまは考えております。
書き損じを一つ白状しておきます。最初の版では、ワークスペース階層の判定を「ホームディレクトリの下にあるかどうか」で書いておりました。検証用の偽ホームを渡したところ、ユーザー階層のファイルまで BLOCK として弾かれました。判定の基準は「ホームの内側か」ではなく「読み込まれる場所そのものと一致するか」です——一文字違いのようでいて、逆の答えを出します。
いま置いている線引き
半年ぶんの空振りから引いた線は、ひと言に縮めるとこうなりました。
許可は名前ではなく行き先に、確認は人がいる経路にだけ。
許可リストにコマンド名を並べるのは気持ちがよいのですが、あとで効いてくるのは、どこへ書き込むのかという一点です。そして確認という判断は、答えられる人が画面の前にいるあいだだけ意味を持ちます。無人の経路には、確認ではなく答えを置いておきます。
エージェントに任せる範囲を広げるほど、任せなかった部分の輪郭がはっきりしてきます。監査の考え方そのものは、セキュリティ特化モデルを個人開発の監査に組み込む — 抽出層と、再提出されない指摘の指紋設計で書いたことと地続きでした。失敗したときの立て直し方については、Gemini Computer Use の自己修復アーキテクチャのほうに寄せてあります。
次にやること
まずは手元の policy ディレクトリを一度だけ渡して、上のスクリプトを走らせてみてください。何も出なければそれで構いませんし、一行でも出たなら、そのルールはおそらく皆さまが思っている仕事をしておりません。
私も点検するまで、自分のファイルがそういう状態だとは思っておりませんでした。同じ落とし穴を先に潰しておければ幸いです。お読みいただきありがとうございました。