Claude Code 2.1.237 に、キー名を1文字だけ書き間違えた settings.json を読ませてみました。claude doctor の出力を、正しく書いたものと diff にかけます。
差分はゼロでした。警告もエラーもありません。設定ファイルは読み込まれた顔をして、書き間違えた行の中身だけが静かに消えています。
個人開発で複数のサイトを無人のタスクに任せていると、設定ファイルを触る機会は思っていたより多くなります。その最中に、これが一番こわい形の失敗だと感じました。壊れていれば気づけます。黙って無視されると、効いているつもりのまま何日も過ぎてしまいます。
以下は Claude Code 2.1.237(linux-x64)で実際に測った結果です。
正しいキーと書き間違えたキーで、出力は1バイトも変わりません
用意したのは2つのディレクトリです。片方には、よくやる書き間違いを4種類まとめて入れました。
// A: .claude/settings.json(書き間違い版)
{
"outputStile": "Concise",
"cleanupPeriodDay": 3,
"permissionmode": "plan",
"permissions": { "denny": ["Bash(rm -rf *)"] }
}// B: .claude/settings.json(正しい版)
{
"outputStyle": "Concise",
"cleanupPeriodDays": 3,
"permissions": { "deny": ["Bash(rm -rf *)"] }
}outputStile は y と i の取り違え、cleanupPeriodDay は複数形の s 落ち、permissionmode は先頭以外の大文字を忘れた形、permissions.denny は n の重ね過ぎです。いずれも JSON としては完全に妥当で、人間の目にも通り過ぎやすい間違いです。
それぞれのディレクトリで claude doctor を走らせ、出力を比べました。doctor は、そのディレクトリの設定ファイルを信頼確認なしで読んでくれるサブコマンドです。
cd A && claude doctor < /dev/null > ../doctorA.txt 2>&1
cd ../B && claude doctor < /dev/null > ../doctorB.txt 2>&1
diff ../doctorA.txt ../doctorB.txt && echo "差分なし"結果は「差分なし」です。denny と書いた拒否ルールは、どこにも登場しないまま無かったことになります。危険なコマンドを止めるつもりで書いた行が、何の合図もなく外れている状態です。
doctor が反応するのは「型」だけでした
キー名は素通りするとして、では何なら拾ってくれるのか。書き間違いの種類を変えて5通り測りました。
| 書き間違いの種類 | 実例 | doctor の反応 | 終了コード |
|---|---|---|---|
| JSON の構文エラー | 末尾のカンマ | Invalid settings / Invalid or malformed JSON | 0 |
| 既知キーの型違反 | "cleanupPeriodDays": "three" | Expected number, but received string | 0 |
| 既知キーの型違反(入れ子) | "permissions": { "deny": "Bash(rm -rf *)" } | Expected array, but received string | 0 |
| 既知キーに存在しない値 | "permissionMode": "planz" | 沈黙 | 0 |
| キー名そのものの間違い | outputStile ほか3件 | 沈黙(正しい版と出力が完全一致) | 0 |
検証されているのは値の型です。数値のところに文字列を書けば Expected number, but received string と場所つきで指摘されますし、配列のところに文字列を書いた場合も同じように出ます。ここは信頼できます。
一方、permissionMode に planz という存在しない値を入れても何も言われませんでした。型としては文字列なので通ってしまう、という理屈でしょうか。列挙値の妥当性まで見てくれるわけではない、と読むのが安全です。
そして肝心のキー名は、どう間違えても無反応です。5通りすべてで終了コードは 0 でした。この点は後で効いてきます。
.mcp.json は同じ書き間違いを教えてくれます
黙るのが Claude Code 全体の性格なのかというと、そうではありませんでした。MCP サーバーの設定で同じ間違いをすると、はっきり警告が出ます。
// .mcp.json — command を commnad と書き間違えた
{ "mcpServers": { "demo": { "commnad": "node", "args": ["server.js"] } } }$ claude mcp list
No MCP servers configured. Use `claude mcp add` to add a server.
MCP config diagnostics ⚠
[Contains warnings] Project config (shared via .mcp.json)
└ [Warning] [demo] mcpServers.demo: Skipped — invalid MCP server config for "demo":
command: expected string, received undefined
commnad という未知のキーそのものを名指ししてはいませんが、必須の command が欠けたことを検出して、そのサーバーを丸ごと Skipped にしたうえで知らせてくれます。必須項目があるスキーマなら、キー名の間違いは「欠落」として自然に浮かび上がる、ということです。
settings.json にはそもそも必須のキーがありません。全部が任意項目です。だから何を書いても、あるいは何を書き損じても、成立してしまいます。設計として理解はできるのですが、運用する側は自分で網を張るしかありません。
キー名の書き間違いを自分で落とす
公式が持っているキーの一覧を機械的に取り出す手段は見当たりませんでした。そこで、自分が実際に使っているキーだけを書いた小さなホワイトリストを持ち、それと突き合わせる方法にしました。全キーを網羅しようとすると更新が続かないので、意識的に狭く保っています。
# known_keys.txt — 自分が使っているキーだけを書く。増やすときは意識的に足す
outputStyle
permissionMode
cleanupPeriodDays
permissions
permissions.deny
permissions.allow
permissions.ask
env
model
検査スクリプトはこれだけです。
#!/usr/bin/env python3
"""settings.json のキー名の書き間違いを検出する。
claude doctor は未知キーを警告しないため、既知キーの一覧と突き合わせて自分で落とす。
使い方: python3 check_settings_keys.py <settings.json> <known_keys.txt>
"""
import difflib, json, sys, re
def flatten(obj, prefix=""):
"""ネストしたキーを permissions.deny のようなドット記法に潰す。
検査対象はキー名だけなので、配列やフックの中身までは降りない。"""
out = []
if isinstance(obj, dict):
for k, v in obj.items():
path = f"{prefix}{k}"
out.append(path)
if isinstance(v, dict):
out.extend(flatten(v, path + "."))
return out
def normalize(k):
"""大文字小文字と区切り記号の違いを潰す。permissionmode と permissionMode を同一視する。"""
return re.sub(r"[^a-z0-9]", "", k.lower())
def main(settings_path, known_path):
with open(known_path, encoding="utf-8") as f:
known = [l.strip() for l in f if l.strip() and not l.startswith("#")]
known_norm = {normalize(k): k for k in known}
try:
with open(settings_path, encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"NG {settings_path}: JSON として読めません ({e})")
return 1
typos, unknowns = [], []
for key in flatten(data):
if key in known:
continue
hit = known_norm.get(normalize(key))
if hit is None:
# 綴りが1〜2文字違うだけのキーも書き間違いとして拾う
near = difflib.get_close_matches(normalize(key), known_norm.keys(), n=1, cutoff=0.8)
hit = known_norm[near[0]] if near else None
if hit:
typos.append((key, hit)) # 綴りだけ違う = ほぼ確実に書き間違い
else:
unknowns.append(key) # 知らないキー = 新しい設定かもしれないので区別する
for wrong, right in typos:
print(f"NG {settings_path}: '{wrong}' は '{right}' の書き間違いです")
for key in unknowns:
print(f"?? {settings_path}: '{key}' は一覧にありません(新しい設定なら一覧へ追加してください)")
if not typos and not unknowns:
print(f"OK {settings_path}")
return 1 if typos else 0 # 書き間違いのみ失敗にする。未知キーは通す
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2]))最初に書いた版には difflib の3行がありませんでした。大文字小文字と区切り記号を潰すだけの正規化だったので、先ほどの4件のうち permissionmode しか捕まえられません。outputStile も cleanupPeriodDay も permissions.denny も、正規化した文字列としては別物だからです。
1文字の差を拾うには、近さで判定する必要がありました。difflib.get_close_matches を cutoff=0.8 で足したところ、4件すべてが書き間違いとして名指しされます。
$ python3 check_settings_keys.py A/.claude/settings.json known_keys.txt
NG A/.claude/settings.json: 'outputStile' は 'outputStyle' の書き間違いです
NG A/.claude/settings.json: 'cleanupPeriodDay' は 'cleanupPeriodDays' の書き間違いです
NG A/.claude/settings.json: 'permissionmode' は 'permissionMode' の書き間違いです
NG A/.claude/settings.json: 'permissions.denny' は 'permissions.deny' の書き間違いです
$ echo $?
1
気になるのは誤検出です。一覧に載せていない正当なキーを足したときに落ちてしまうと、この検査はすぐ邪魔者になります。statusLine と enableAllProjectMcpServers を追加して試しました。
$ python3 check_settings_keys.py H/.claude/settings.json known_keys.txt
?? H/.claude/settings.json: 'statusLine' は一覧にありません(新しい設定なら一覧へ追加してください)
?? H/.claude/settings.json: 'statusLine.type' は一覧にありません(新しい設定なら一覧へ追加してください)
?? H/.claude/settings.json: 'enableAllProjectMcpServers' は一覧にありません(新しい設定なら一覧へ追加してください)
$ echo $?
0
?? として見えるところまでは同じですが、終了コードは 0 のままです。書き間違いだけを失敗にして、知らないキーは通す。この線引きにしておくと、新しい設定を試した日に検査が止まる、という煩わしさを避けられます。
doctor の終了コードは 0 のままです
もうひとつ、自動化に組み込むときの落とし穴があります。先ほどの表のとおり、claude doctor は設定が壊れていても終了コード 0 を返します。構文エラーで設定が丸ごと読まれていない状態でも 0 です。
#!/usr/bin/env bash
# claude doctor は設定が壊れていても終了コード 0 を返す。出力を読んで自分で落とす。
set -uo pipefail
out="$(claude doctor < /dev/null 2>&1)"
echo "$out"
if grep -q 'Invalid settings' <<< "$out"; then
echo "NG: settings が読み込まれていません(上の Invalid settings を参照)" >&2
exit 1
fi
exit 0正しい設定・末尾カンマの構文エラー・型違反の3つで試したところ、それぞれ 0 / 1 / 1 になりました。doctor の判断力はそのまま使えるので、足りないのは失敗として扱う部分だけです。
この2つを組み合わせると、settings.json に対する検査は次の形に落ち着きます。型と構文は doctor に任せ、キー名は自分の一覧で見る。どちらも終了コードで判定できるので、コミット前のフックにも無人タスクの前処理にも置けます。
設定ファイルが壊れて Claude Code そのものが立ち上がらなくなったときの切り分けは、壊れた settings.json で Claude Code が起動しなくなったとき に分けて書いています。今回の話はその手前、起動はするのに設定だけが効いていない状態への備えです。
最後は挙動で確かめる
正直なところ、この検査で守れるのは自分が知っているキーだけです。一覧に無いキーは ?? として素通りしますし、値の妥当性までは見ていません。permissionMode に planz と書いた設定は、この検査も doctor も通ります。
ですから、私はこの検査と併せて、新しい設定を足した日には1回だけ挙動で確かめる習慣を持つようにしました。拒否ルールを書いたのなら、実際にそのコマンドが止まるかを一度試す。出力スタイルを変えたのなら、次の応答の形が変わったかを見る。数十秒で済みますし、この確認だけが「書いた設定が本当に届いた」という証拠になります。
黙って落ちるものを見つける方法は、突き詰めると毎回同じでした。期待した件数・期待した反応を先に決めておいて、実際の数と突き合わせる。同じ考え方で一括処理の取りこぼしを止めた話は、空白ひとつで、80件の検査が0件になる で扱っています。
まず手元の settings.json を1つ選び、キーを書き出して眺めてみてください。複数形の s と、途中の大文字が残っているか。それだけでも、消えていた設定が1つ見つかるかもしれません。
私自身このスクリプトは、書き間違いを1つ見つけたところから育て始めたものです。お読みいただきありがとうございました。