From 31d9c02a3666c5c8f2eb204b0cc86455594ef2b4 Mon Sep 17 00:00:00 2001 From: WilcoLouwerse Date: Thu, 1 Oct 2026 10:55:14 +0200 Subject: [PATCH] fix(global-settings): ignore the model and effort keys Claude Code writes in settings.json (v2.7.1) The v2.7.0 integrity check skipped only `model` when comparing the installed settings.json with the canonical one. Under relock option (B) Claude Code also writes the effort level per model (`modelSettings..effortLevel`, from /effort and the effort picker) and a few other model-choice keys into that file, so every effort change raised a "FILES OUT OF SYNC" notice and the repair threw the user's choice away. The check now leaves out every top-level key the client writes for a model or effort choice: model, modelSettings, effortLevel, fastMode, advisorModel and switchModelsOnFlag. Hooks, permissions and every other key still count as drift. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/claude/global-claude-settings.md | 2 +- global-settings/README.md | 13 +++++++++- global-settings/VERSION | 2 +- global-settings/check-settings-version.sh | 25 ++++++++++++++----- .../tests/test-check-settings-version.sh | 24 ++++++++++++++++-- 5 files changed, 55 insertions(+), 11 deletions(-) diff --git a/docs/claude/global-claude-settings.md b/docs/claude/global-claude-settings.md index be815a535..6fcdc3425 100644 --- a/docs/claude/global-claude-settings.md +++ b/docs/claude/global-claude-settings.md @@ -363,7 +363,7 @@ The phrase must be in the last message the human typed — plain text, text sent - Reads the local branch version from `$REPO_DIR/global-settings/VERSION` (if a local repo is configured). - Compares all versions using semver and prints a colored status panel to stderr (visible in the terminal/CLI). - **Derives the managed file list** (since v2.7.0) from the hook scripts the canonical `settings.json` registers, fetched from the same source as `VERSION`; the update notice emits one command block per registered hook, plus `settings.json` first and `VERSION` last. Hook names are validated before they are placed in a command. If the canonical `settings.json` cannot be fetched, the list falls back to the installed one and a warning says so. -- **Verifies the installed files** (since v2.7.0) when the versions match: every registered hook against its canonical copy (`sha256`, trailing newlines ignored) and `settings.json` as parsed JSON without `model`. A missing or changed file produces the "files out of sync" notice with repair blocks for those files only; a canonical copy that could not be fetched is reported as not verified. The panel gains a `Files` line (`7/7 verified ✓`, or the missing/changed counts). +- **Verifies the installed files** (since v2.7.0) when the versions match: every registered hook against its canonical copy (`sha256`, trailing newlines ignored) and `settings.json` as parsed JSON without the keys Claude Code writes when you pick a model or an effort level (`model`, `modelSettings`, `effortLevel`, `fastMode`, `advisorModel`, `switchModelsOnFlag`; since v2.7.1, before that only `model`). A missing or changed file produces the "files out of sync" notice with repair blocks for those files only; a canonical copy that could not be fetched is reported as not verified. The panel gains a `Files` line (`7/7 verified ✓`, or the missing/changed counts). - Always injects a session-start message into Claude's context via stdout — "up to date", "update required", "files out of sync", or "configuration error" — which Claude relays at the top of its first response. - Never silently skips: configuration issues (missing config files, unreachable remote, `curl` not installed) are shown in the panel and forwarded to Claude. diff --git a/global-settings/README.md b/global-settings/README.md index b55bcdfb0..e3510e58d 100644 --- a/global-settings/README.md +++ b/global-settings/README.md @@ -250,7 +250,18 @@ The file blocks in the notice are not a hand-maintained list. `check-settings-ve When the installed version already matches the online one, the hook also compares every managed file with its canonical copy: - hook scripts by `sha256`, ignoring trailing newlines (the `printf '%s\n'` install writes exactly one, the `git show` install copies the bytes verbatim; neither is drift); -- `settings.json` as parsed JSON, without the `model` key. The VSCode model picker writes `{"model": …}` into it under relock option (B), and a model choice is a preference, not policy. Every other difference in that file counts, and the notice names the top-level keys that differ. +- `settings.json` as parsed JSON, without the top-level keys Claude Code itself writes there when you pick a model or an effort level (v2.7.1; v2.7.0 skipped only `model`). Under relock option (B) these writes land in the file, and a model choice is a preference, not policy: + + | Key | Written by | + | --- | --- | + | `model` | the VSCode model picker and `/model` | + | `modelSettings` | `/effort` and the effort picker, saved per model as `modelSettings..effortLevel` | + | `effortLevel` | the same choice when it cannot be keyed by model | + | `fastMode` | `/fast` | + | `advisorModel` | `/advisor` | + | `switchModelsOnFlag` | the VSCode toggle that switches model when a safeguard flags a message | + + Every other difference in that file counts, and the notice names the top-level keys that differ. A missing or changed file turns the session-start message into **FILES OUT OF SYNC**. It names the files, the phrase for step 3 is **"repair my global settings"**, and the unlock, contract and relock steps are the same as for an update. Only the affected files are rewritten; `VERSION` is left alone. On the GitHub path the check costs two extra fetches per session (`settings.json`, then every registered hook in one `curl` call); on the git-fetch path it costs none. A canonical file that could not be fetched is reported as not verified, never as fine. diff --git a/global-settings/VERSION b/global-settings/VERSION index 24ba9a38d..860487ca1 100644 --- a/global-settings/VERSION +++ b/global-settings/VERSION @@ -1 +1 @@ -2.7.0 +2.7.1 diff --git a/global-settings/check-settings-version.sh b/global-settings/check-settings-version.sh index d622c9a0f..5bad6a145 100644 --- a/global-settings/check-settings-version.sh +++ b/global-settings/check-settings-version.sh @@ -311,16 +311,29 @@ norm_sha() { fi } +# Top-level keys Claude Code itself writes into ~/.claude/settings.json when the +# user picks a model or an effort level, which is exactly what relock option (B) +# allows. They are preferences, not policy (README → Updating, step 4): +# model the model picker and /model +# modelSettings /effort and the effort picker, saved per model as +# modelSettings..effortLevel +# effortLevel the same choice when it cannot be keyed by model +# fastMode /fast +# advisorModel /advisor +# switchModelsOnFlag the VSCode toggle that switches model when a safeguard +# flags a message +# Everything else in that file is policy. +CLIENT_MODEL_KEYS='["model","modelSettings","effortLevel","fastMode","advisorModel","switchModelsOnFlag"]' + # settings_diff — empty when both settings.json files are # the same document, otherwise the top-level keys that differ. Compared as parsed -# JSON (formatting is not drift) and without `model`: the VSCode extension -# writes {"model": …} into ~/.claude/settings.json on every model switch, which -# is exactly what relock option (B) allows, and a model choice is a preference, -# not policy (README → Updating, step 4). Everything else in that file is. +# JSON (formatting is not drift) and without CLIENT_MODEL_KEYS. settings_diff() { local a b keys - a=$(jq -S 'del(.model)' "$1" 2>/dev/null) || { echo "not valid JSON"; return; } - b=$(jq -S 'del(.model)' "$2" 2>/dev/null) || { echo "canonical copy not valid JSON"; return; } + a=$(jq -S --argjson k "$CLIENT_MODEL_KEYS" 'delpaths($k | map([.]))' "$1" 2>/dev/null) \ + || { echo "not valid JSON"; return; } + b=$(jq -S --argjson k "$CLIENT_MODEL_KEYS" 'delpaths($k | map([.]))' "$2" 2>/dev/null) \ + || { echo "canonical copy not valid JSON"; return; } [ "$a" = "$b" ] && return keys=$(jq -n -r --argjson a "$a" --argjson b "$b" \ '[($a | keys[]), ($b | keys[])] | unique | map(select($a[.] != $b[.])) | join(", ")' 2>/dev/null) diff --git a/global-settings/tests/test-check-settings-version.sh b/global-settings/tests/test-check-settings-version.sh index 11dffc55c..47a1f6787 100644 --- a/global-settings/tests/test-check-settings-version.sh +++ b/global-settings/tests/test-check-settings-version.sh @@ -14,8 +14,9 @@ # - a matching install is reported up to date, with every file verified # - a missing or changed hook, or a changed settings.json, is reported # FILES OUT OF SYNC with repair blocks for those files only -# - a settings.json that only gained `model` (the VSCode picker's write under -# relock option B) and a trailing-newline difference are not drift +# - a settings.json that only gained `model`, `modelSettings` or another +# model-choice key (the picker's and /effort's writes under relock +# option B) and a trailing-newline difference are not drift # # Usage: # ./tests/test-check-settings-version.sh # run all @@ -210,6 +211,25 @@ run_hook lacks 'OUT OF SYNC'; check "model: settings.json with only model added is not drift" $? has 'Settings are up to date'; check "model: reported up to date" $? +# ── 7b. the effort and other model-choice keys are not drift either ───────── +install_all +jq '.model = "claude-opus-5-5[1m]" + | .modelSettings = {"claude-opus-5-5": {"effortLevel": "low"}} + | .effortLevel = "high" | .fastMode = true + | .advisorModel = "opus" | .switchModelsOnFlag = false' \ + "$CANON_GS/settings.json" > "$CLAUDE_DIR/settings.json" +run_hook +lacks 'OUT OF SYNC'; check "effort: model-choice keys are not drift" $? +has 'Settings are up to date'; check "effort: reported up to date" $? + +# ── 7c. a model-choice key next to a policy change still reports the policy ─ +install_all +jq '.modelSettings = {"claude-opus-5-5": {"effortLevel": "low"}} + | .permissions.allow += ["Bash(rm -rf /)"]' \ + "$CANON_GS/settings.json" > "$CLAUDE_DIR/settings.json" +run_hook +has 'changed : settings.json (top-level keys: permissions)'; check "effort: only the policy key is named" $? + # ── 8. settings.json with a permission change is drift, and names the key ──── install_all jq '.permissions.allow += ["Bash(rm -rf /)"]' "$CANON_GS/settings.json" > "$CLAUDE_DIR/settings.json"