朝、いつもどおり実行ログを開いたら、いちばん上に見慣れない行が乗っていました。
Ignoring 2 permissions.allow entries from .claude/settings.json: this workspace has not been trusted.
その日のジョブは失敗していません。成果物も出ています。ただ、リポジトリに commit してあるはずの許可設定が、2件とも読まれていませんでした。
私は個人でアプリと複数のサイトを保守していて、そのうちの定型作業を毎晩まっさらな実行環境に投げています。環境は毎回作り直され、リポジトリはそのつど clone されます。許可設定はリポジトリの .claude/settings.json に入れて、環境を選ばずに同じ挙動になるようにしていました。その前提が崩れていた、という話です。
結論から書きます。使い捨ての環境では、リポジトリ内の permissions.allow は「効いたら儲けもの」の扱いにするのが正しいと判断しました。止めたい操作は deny に置き、通したい操作は環境側の信頼登録で担保し、起動前にプリフライトで検出する。以下は、その判断に至るまでに Claude Code 2.1.246 で確かめたことです。
「this workspace has not been trusted」で落ちるのは、どのキーか
まず、どの設定が落ちてどれが残るのかを一つずつ確かめました。手順は単純です。
- 空のディレクトリを作り、
.claude/settings.json に検査したいキーを1つだけ書く
- 非対話モード(
-p)で1回だけ起動する
- 起動時に出た
Ignoring 行を数える
1キーずつ分けるのがこつです。まとめて書くと、どのキーが落ちたのかを警告文から読み分けることになり、単複の揺れで数え間違えます。
mkdir -p /tmp/probe/.claude && cd /tmp/probe
probe() {
printf '%s' "$2" > .claude/settings.json
echo "--- $1 ---"
claude -p "hi" < /dev/null 2>&1 | grep '^Ignoring ' | sed 's/ Run Claude Code.*//'
}
probe "allow" '{"permissions":{"allow":["Bash(git *)"]}}'
probe "deny" '{"permissions":{"deny":["Bash(rm *)"]}}'
probe "ask" '{"permissions":{"ask":["Bash(git push *)"]}}'
probe "additionalDirectories" '{"permissions":{"additionalDirectories":["/tmp/other"]}}'
probe "hooks" '{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo hi"}]}]}}'
< /dev/null は必ず付けてください。付けないと標準入力を数秒待ってから進むので、10回試すだけで待ち時間が積み上がります。
手元での結果です。
| 設定キー | 未信頼のとき | 起動時の表示 |
permissions.allow | 無視される | Ignoring N permissions.allow entries… |
permissions.additionalDirectories | 無視される | Ignoring N permissions.additionalDirectories entries… |
permissions.deny | そのまま | 表示なし |
permissions.ask | そのまま | 表示なし |
hooks / env / statusLine / model | そのまま | 表示なし |
落ちるのは、信頼していない相手が書いたときに危険側に倒れるキーだけです。allow は「これは確認なしで実行してよい」という宣言、additionalDirectories は「ここも触ってよい」という宣言なので、素性の分からないリポジトリから拾ったものをそのまま採用したら危ない。逆に deny と ask は、拾ったものを採用しても厳しくなるだけなので落とす理由がありません。
設計として筋は通っています。ただ、この非対称性を知らないまま「permissions は settings.json に書けば効く」と思っていると、緩める方向の設定だけが静かに消えている状態になります。
なお、複数のキーが同時に落ちた場合は行が分かれて出ます。1件なら entry、2件以上なら entries と単複が変わるので、行数で数えるほうが確実です。
放っておいても、いつかは信頼される、ということはありません
私が最初に想定していたのは「初回は警告が出るが、何度か走らせるうちに登録されるのだろう」でした。これは外れていました。
未信頼のまま非対話で1回走らせたあと、ユーザー設定を覗いてみます。
claude -p "hi" < /dev/null > /dev/null 2>&1
python3 -c "import json,os;print(json.load(open(os.path.expanduser('~/.claude.json'))).get('projects',{}))"
出力は {} でした。未信頼の非対話実行では、そのディレクトリの項目そのものが作られません。 信頼フラグが false で作られるのではなく、記録が残らないのです。
つまり、放置しても状態は前に進みません。毎晩の実行は、毎晩まったく同じところで許可設定を落とし続けます。しかも成果物は出るので、ログを1行目まで遡らない限り気づけません。
ここが、この件でいちばん厄介なところだと感じています。壊れているなら止まってくれるのですが、これは能力が一段落ちた状態で完走する類の失敗です。設定キーのタイポが何も言われずに無視される件と性質がよく似ています(settings.json のキー名を1文字間違えても、Claude Code は何も言わずに無視します)。違いは、こちらは1行だけ手がかりを残してくれることです。その1行を機械で拾えるかどうかが分かれ目になります。
パスの一致は、思っているより厳密です
信頼の記録は、ユーザー設定 ~/.claude.json の projects に、作業ディレクトリの絶対パスをキーとして入ります。ここで2つ、手を焼いた点があります。
1つ目の落とし穴は、末尾スラッシュが一致しないことです。projects["/tmp/probe/"] に信頼を書いても、/tmp/probe で起動したセッションは未信頼のままでした。キーは正規化されず、文字列としてそのまま比較されているように見えます。スクリプトから書き込むときは cd "$dir" && pwd -P を通して、末尾スラッシュとシンボリックリンクを落としてから使うのが安全です。
2つ目は、親を信頼しても子には及ばないことです。/tmp/probe を信頼済みにしたうえで /tmp/probe/sub に移動して起動すると、sub/.claude/settings.json の allow は落ちました。monorepo のサブパッケージ単位で作業ディレクトリを切り替えている場合、親だけ登録しても足りません。
| 試した組み合わせ | 起動したディレクトリ | 結果 |
projects["/tmp/probe"] = true | /tmp/probe | allow が効く |
projects["/tmp/probe/"] = true(末尾スラッシュ) | /tmp/probe | 落ちる |
projects["/tmp/probe"] = true | /tmp/probe/sub | 落ちる |
clone 先のパスが実行のたびに変わる構成では、この厳密さがそのまま運用コストになります。パスを固定するか、プリフライトで毎回登録するか、どちらかを選ぶことになります。
決めたこと: deny はリポジトリに、allow は環境に
ここまで分かったところで、置き場所を決めました。
止めたい操作はリポジトリ側に置きます。 deny と ask は信頼の有無に関わらず読まれるので、リポジトリに commit しておけば、どの環境に clone されても同じだけ厳しくなります。「このリポジトリでは本番の認証情報を読ませない」「push は必ず確認を挟む」といった、リポジトリ固有の約束事はここが定位置です。落ちない側にあるという性質は、そのまま信頼できるという意味になります。
通したい操作は環境側に置きます。 allow は、信頼された環境でだけ有効になる「速度のための設定」と割り切りました。ユーザースコープの設定に置くか、実行環境を用意するときに信頼登録とセットで流し込みます。リポジトリ側の allow も残してはありますが、それは開発機で手元から使うときのための保険で、無人実行が依存する対象からは外しました。
判断の軸を1行にすると、落ちたときに危険側に倒れる設定を、落ちうる場所に置かないということです。allow が落ちても危険にはなりません(確認が増えるだけです)。deny が落ちたら危険ですが、こちらは落ちない。だとすれば、リポジトリ側を安全側の宣言に寄せ、環境側を利便性の宣言に寄せるのが素直な配分になります。
許可ルールを積み上げるほど毎ターンの評価コストが増える件は別記事に書きましたが(許可ルールを積み上げたセッションが毎ターン重くなる)、置き場所を分けると副次的にここも軽くなります。無人実行の環境に持ち込む allow は、そのジョブが実際に使うコマンドだけで足りるからです。
起動前に止めるプリフライト
方針が決まったので、実行環境の準備側に小さなスクリプトを1本足しました。やることは2つだけです。作業ディレクトリを信頼登録し、実際に起動して Ignoring が出ないことを確かめる。出たら、そこで止めます。
#!/usr/bin/env bash
# trust-preflight.sh — 非対話実行の前に、プロジェクト設定が実際に読まれる状態かを確かめる
set -euo pipefail
REPO_DIR="$(cd "${1:?usage: trust-preflight.sh <repo-dir>}" && pwd -P)" # 末尾スラッシュとシンボリックリンクを落とす
CONFIG="${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json"
python3 - "$CONFIG" "$REPO_DIR" << 'PY'
import json, os, sys, tempfile
config_path, repo_dir = sys.argv[1], sys.argv[2]
data = {}
if os.path.exists(config_path):
with open(config_path) as f:
try:
data = json.load(f)
except json.JSONDecodeError:
# 壊れた設定に上書きすると復旧が面倒になるので、ここでは触らずに落とす
print(f"config is not valid JSON: {config_path}", file=sys.stderr)
sys.exit(2)
projects = data.setdefault("projects", {})
entry = projects.setdefault(repo_dir, {})
if entry.get("hasTrustDialogAccepted") is True:
print("already trusted")
sys.exit(0)
entry["hasTrustDialogAccepted"] = True
# 同一ディレクトリに一時ファイルを作ってから置換する。
# 書き込み中に落ちても、元の設定が半端な状態で残らないようにするため
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(config_path) or ".")
with os.fdopen(fd, "w") as f:
json.dump(data, f)
os.replace(tmp, config_path)
os.chmod(config_path, 0o600)
print("trust added")
PY
cd "$REPO_DIR"
DROPPED="$(claude -p "reply with: ok" < /dev/null 2>&1 | grep -c '^Ignoring ' || true)"
if [ "$DROPPED" -ne 0 ]; then
echo "PREFLIGHT FAILED: ${DROPPED} 件の設定が無視されたまま起動しました" >&2
exit 1
fi
echo "PREFLIGHT OK: $REPO_DIR"
手元で確かめた挙動です。未信頼のディレクトリに対して実行すると trust added のあと PREFLIGHT OK で終わり、2回目は already trusted を出して同じく成功します。引数に末尾スラッシュを付けて渡しても、登録されるキーはスラッシュなしの1つだけでした。信頼を消してから素で起動し直すと Ignoring が2件出ることも確認しています。失敗経路を通していない検査は、検査していないのと同じですので。
書き方でいくつか意図があります。
os.replace を使っているのは、設定ファイルが書き込み途中で壊れる事故を避けるためです。~/.claude.json には信頼以外の状態も入っているので、ここを壊すと後始末に時間を取られます。同じ理由で、JSON として読めなかった場合は上書きせず終了コード 2 で落としています。
chmod 600 を明示しているのは、tempfile.mkstemp で作ったファイルの権限がそのまま残るためです。元のファイルの権限を引き継いでくれるわけではないので、置換のたびに設定し直します。
grep -c を || true で受けているのは、set -e との組み合わせ対策です。grep は一致0件のときに終了コード1を返すので、これがないと期待どおり警告が出なかったときにスクリプトが落ちます。成功が失敗として扱われるのは、いちばん気づきにくい種類のバグです。
検出だけを常設する
信頼を書き込む権限が実行者にない環境や、そもそも自動で信頼させたくない環境もあります。この場合は、書き込みを諦めて検出だけを常設するのが現実的な回避策になります。落ちていることさえ分かれば、対処は人が決められます。
DROPPED="$(claude -p "reply with: ok" < /dev/null 2>&1 | grep -c '^Ignoring ' || true)"
[ "$DROPPED" -eq 0 ] || { echo "::warning::${DROPPED} settings entries were ignored"; exit 1; }
行頭の ^ を付けているのは、応答本文の中にたまたま Ignoring という語が現れたときに数え間違えないためです。判定に使う出力は、狭く取るほど寿命が延びます。
私はこれを、環境を用意する側の最後の1ステップに置きました。所要は1回の起動ぶんだけです。毎晩の実行が「許可設定なしの状態で完走する」ことに対して、これくらいのコストなら安いと感じています。
同じ発想で deny 側も点検しておくと、両側から挟めます。deny は信頼に関係なく効くと分かりましたが、効く範囲が想定どおりとは限りません。以前、許可フォルダの内側に deny が届いていなかったことがありました(Claude Code の read deny が許可フォルダの内側まで届いていなかった話)。読まれることと、思ったとおりに効くことは別の問題です。
まず、いま動いている無人ジョブのログの1行目を見てください
やることは1つだけです。定期実行しているジョブの直近のログを開いて、いちばん上に Ignoring で始まる行がないか確かめてください。あれば、その環境では許可設定が落ちたまま走り続けています。
私の場合、気づくまでにしばらくかかりました。出力は正しく、成果物も揃っていて、疑う理由がなかったからです。静かに一段能力が落ちた状態で走り続けるものを見つけるには、静かなうちに機械で見る仕掛けを置いておくしかないのだろうと思っています。ここは私自身まだ整えている途中ですが、同じところで足を止めた方の手がかりになれば嬉しいです。
(動作確認: Claude Code 2.1.246 / Linux / 非対話モード)