.claude/settings.json wires a PostToolUse hook on matcher Write|Edit to bare mix format. With no argument mix format formats everything matched by :inputs in .formatter.exs — the whole project, not the file that was edited.
Editing one file therefore rewrites every other file in the tree that differs from the formatter's canonical form, and in git status that collateral change is indistinguishable from the intended one. A reviewer told to change nothing becomes a writing agent without knowing it.
Observed across seven independent working trees here: each carried a byte-identical stray diff in test/mix/tasks/phoenix_kit_doctor_test.exs that no agent had made (md5 of the diff identical in all seven), and three agents independently reported that file as a change they had not intended.
Reproducing it needs a built tree
On a cold tree (deps not fetched) the hook does not corrupt anything silently — it fails loudly:
(Mix) Unknown dependency :ecto_sql given to :import_deps ...
Unchecked dependencies for environment dev
The silent rewriting only happens on a warm tree, which is where real work happens. A fresh clone shows the noisy failure, not the defect.
exclude: in .formatter.exs is a dead key
.formatter.exs carries exclude: ["priv/templates/**/*.*"] under a comment saying it excludes the EEx-bearing template files. Mix.Tasks.Format never reads :exclude — the key appears nowhere in Elixir 1.18.4's lib/mix/lib/mix/tasks/format.ex (verified against the installed source, zero occurrences).
Those templates are safe today by accident: priv/templates matches none of the :inputs patterns. The protection that is written down does not exist; the one that works was not intended as one.
This matters for any fix, because an explicit mix format <path> bypasses :inputs and :exclude entirely. A hook that simply forwards the edited path would format a template the first time one is edited — a worse regression than the original defect.
A fix here is two files, and git add -A takes only one
.gitignore line 22 is /.claude. A new file under .claude/ is invisible to git status and is not picked up by git add -A — git check-ignore -v answers .gitignore:22:/.claude for a new path there. The two files currently tracked under .claude/ were force-added.
So a fix that adds a hook script and edits settings.json will, applied normally, commit only the settings change, leaving the hook pointing at a script that does not exist and failing on every edit. The script needs git add -f.
The fix we are running, and the conditions it depends on
The PostToolUse command points at a script that resolves the edited path and formats only that file. Three conditions in it are load-bearing and should not be removed:
-
The path is parsed with python3, not jq. jq is not guaranteed to be present; block-dangerous-git.sh already depends on it, with the failure mode described in the separate issue.
-
priv/templates is skipped explicitly. Because an explicit path argument bypasses :inputs/:exclude, the dead config key provides nothing here; the skip has to live in the hook.
-
The file is checked to be inside $CLAUDE_PROJECT_DIR. An explicit path argument also removes the repo-relative character of :inputs, so without this check the hook can rewrite any .ex/.exs/.heex anywhere on disk using this project's formatter settings — a capability bare mix format never had. Containment is os.path.commonpath over os.path.realpath of both sides, not a string-prefix comparison: realpath canonicalizes .., relative segments and symlinks, and commonpath compares path components, so a sibling directory that merely shares a name prefix does not pass.
Consequence worth knowing: if $CLAUDE_PROJECT_DIR is absent the hook formats nothing at all rather than falling back to whole-project behaviour.
mix compile, the second command in the same hook, is unchanged: it is already incremental (a no-op run on a fully built tree recompiled zero modules in 4.7s) and Mix has no per-file compile mode, so there is nothing to narrow.
Verified in both directions on the live hook
- a file inside the project is formatted — tested with absolute and relative paths;
- an unrelated file inside the project is untouched (hash unchanged);
- a file outside the project is untouched;
- a sibling directory whose name shares the project's prefix is untouched;
- a symlink inside the project pointing outside leaves its target untouched;
priv/templates is untouched.
The reverse direction matters as much as the forward one: a hook that quietly stopped formatting would pass "no collateral changes" perfectly.
We can send this as a PR if you would like it — noting the git add -f requirement above.
.claude/settings.jsonwires a PostToolUse hook on matcherWrite|Editto baremix format. With no argumentmix formatformats everything matched by:inputsin.formatter.exs— the whole project, not the file that was edited.Editing one file therefore rewrites every other file in the tree that differs from the formatter's canonical form, and in
git statusthat collateral change is indistinguishable from the intended one. A reviewer told to change nothing becomes a writing agent without knowing it.Observed across seven independent working trees here: each carried a byte-identical stray diff in
test/mix/tasks/phoenix_kit_doctor_test.exsthat no agent had made (md5 of the diff identical in all seven), and three agents independently reported that file as a change they had not intended.Reproducing it needs a built tree
On a cold tree (deps not fetched) the hook does not corrupt anything silently — it fails loudly:
The silent rewriting only happens on a warm tree, which is where real work happens. A fresh clone shows the noisy failure, not the defect.
exclude:in.formatter.exsis a dead key.formatter.exscarriesexclude: ["priv/templates/**/*.*"]under a comment saying it excludes the EEx-bearing template files.Mix.Tasks.Formatnever reads:exclude— the key appears nowhere in Elixir 1.18.4'slib/mix/lib/mix/tasks/format.ex(verified against the installed source, zero occurrences).Those templates are safe today by accident:
priv/templatesmatches none of the:inputspatterns. The protection that is written down does not exist; the one that works was not intended as one.This matters for any fix, because an explicit
mix format <path>bypasses:inputsand:excludeentirely. A hook that simply forwards the edited path would format a template the first time one is edited — a worse regression than the original defect.A fix here is two files, and
git add -Atakes only one.gitignoreline 22 is/.claude. A new file under.claude/is invisible togit statusand is not picked up bygit add -A—git check-ignore -vanswers.gitignore:22:/.claudefor a new path there. The two files currently tracked under.claude/were force-added.So a fix that adds a hook script and edits
settings.jsonwill, applied normally, commit only the settings change, leaving the hook pointing at a script that does not exist and failing on every edit. The script needsgit add -f.The fix we are running, and the conditions it depends on
The PostToolUse command points at a script that resolves the edited path and formats only that file. Three conditions in it are load-bearing and should not be removed:
The path is parsed with
python3, notjq.jqis not guaranteed to be present;block-dangerous-git.shalready depends on it, with the failure mode described in the separate issue.priv/templatesis skipped explicitly. Because an explicit path argument bypasses:inputs/:exclude, the dead config key provides nothing here; the skip has to live in the hook.The file is checked to be inside
$CLAUDE_PROJECT_DIR. An explicit path argument also removes the repo-relative character of:inputs, so without this check the hook can rewrite any.ex/.exs/.heexanywhere on disk using this project's formatter settings — a capability baremix formatnever had. Containment isos.path.commonpathoveros.path.realpathof both sides, not a string-prefix comparison:realpathcanonicalizes.., relative segments and symlinks, andcommonpathcompares path components, so a sibling directory that merely shares a name prefix does not pass.Consequence worth knowing: if
$CLAUDE_PROJECT_DIRis absent the hook formats nothing at all rather than falling back to whole-project behaviour.mix compile, the second command in the same hook, is unchanged: it is already incremental (a no-op run on a fully built tree recompiled zero modules in 4.7s) and Mix has no per-file compile mode, so there is nothing to narrow.Verified in both directions on the live hook
priv/templatesis untouched.The reverse direction matters as much as the forward one: a hook that quietly stopped formatting would pass "no collateral changes" perfectly.
We can send this as a PR if you would like it — noting the
git add -frequirement above.