{"page":"gotchas","title":"Confirmed gotchas","blurb":"Things that cost me an hour each, all reproduced on a real build, with the output that proves it.","build":"2.1.286","sections":["1. A hook that fails doesn't block. It disappears.","2. A good deny reason can make the model go around you","3. A tool.call hook breaks worktree isolation for subagents","4. $.session.usage() is mostly empty until the first turn completes","5. The manifest validator doesn't type-check your defaults","6. An unconfigured option reads as \"\", not undefined","7. The capability scan checks shape, not spelling","8. Names change without warning","9. Small things that each cost an hour","10. `prompt.compose` is asked every turn, but only its first answer is sent","What I couldn't reproduce, and what's fixed"],"limit":8000,"section":null,"offset":0,"count":7953,"total":36400,"next_offset":7953,"next_url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/gotchas.json?offset=7953","markdown":"I reproduced everything on this page myself on Linux, and every quoted line is copied from a real run. Most of it I first measured at 2.1.272, then re-ran at 2.1.283 and 2.1.286, and each section says which build a result came from. Gotcha 10 is newer, from 2.1.286, and a few of its points I only read in the bundle or the declarations. Each of those says so. Anything else I couldn't reproduce is in the last section. A few rows were re-checked at 2.1.287, after Anthropic published its own mods docs, and where those docs disagree with what a build did, the row links them and says which build did what.\n\nRead the first two before you ship anything.\n\n## 1. A hook that fails doesn't block. It disappears.\n\nA hook fails when it throws, when it runs past its 10-second budget, or when it returns the wrong shape. All three end the same way, and it's the opposite of what a guard needs.\n\nI ran five versions of one experiment at 2.1.272, and again at 2.1.286 on Haiku 4.5 with the same results. Each one hooks `tool.call` for Bash, the session asks Claude to create a marker file with a shell command, and the test is whether Bash ran.\n\n| The hook does | Tool ran? |\n|---|---|\n| `return next(e)`, the control | yes |\n| `throw new Error(\"deliberate hook failure\")` | **yes** |\n| `await new Promise(() => {})` | **yes**, after a ten-second stall |\n| `return { deny: \"\" }` | no |\n| `return { deny: \"<a reason>\" }` | no |\n\nThe logging changed at 2.1.280, though. The debug log no longer carries the error message, only its kind and length. This is the throw at 2.1.286:\n\n```text\n[ERROR] hook failed: auditone: errorKind=Error errorChars=23 (tool.call; skipped; what is below it ran in its place)\n```\n\nand in a `claude -p` run with `--plugin-dir`, which is how I ran these, the reason also goes to plain stderr, so you'll see it without `--debug`:\n\n```text\nauditone: tool.call hook skipped: threw Error: deliberate hook failure\naudittwo: tool.call hook skipped: ran past its 10s budget\n```\n\nThat's the only case where stderr gets it. In an interactive session that hot-reloads a `--plugin-dir` mod the same line is a dim row in the transcript, and for a mod you installed from a marketplace it's in the debug log and nowhere else. Anthropic's troubleshooting page [lists all three](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/troubleshoot#find-out-why-a-mod-does-nothing).\n\nThe hang cost time and nothing else, about 10 seconds over the control at 2.1.286, and the debug log says it settled at 10,235ms.\n\n**So a security hook with a bug in it isn't a closed gate, it's a missing one.** A guard that calls `await $.http.fetch(policyUrl)` and hits a network blip lets through everything it was installed to stop, and all that's left is one line, which for an installed mod sits in a debug log nobody started.\n\nThe fix is `.catch`:\n\n```ts\non(\"tool.call\", { tool: \"Bash\" }, async ($, e, next) => {\n  if (await isDangerous($, e.command)) return { deny: \"policy\" }\n\n  return next(e)\n})\n.catch(($, e, next) =>\n  next.called ? next(e) : { deny: next.error.kind })\n```\n\nIf the hook already called `next`, that stands. If it failed before getting there, the check never finished, so deny. When the `.catch` answers, the log says `hook failed closed: ... (tool.call; its .catch answered)`. Any hook whose absence would be a security problem needs this.\n\nA wrong-shape return lands in the `.catch` too. The 2.1.287 declarations file it under `next.error.kind` `throw`, with `next.error.message` saying what was refused, so `kind` is only ever `throw` or `timeout` and a guard that returns garbage gets denied like one that threw. I haven't put that case through the table above. Anthropic's events page [covers `.catch`](https:\/\/code.claude.com\/docs\/en\/plugins\/mods\/events#handle-a-hook-that-fails) as well, including the one second a handler gets to answer.\n\n## 2. A good deny reason can make the model go around you\n\nBoth deny versions stopped Bash. At 2.1.272 what happened next wasn't the same.\n\nWith `{ deny: \"\" }` the model retried Bash three times, then gave up and asked the person:\n\n> The Bash tool calls are failing outright (empty tool\\_use\\_error), even for a simple `ls`. This looks like the tool is being blocked before execution, possibly by a permission setting or hook.\n\nWith a clear reason naming organisation policy, it read the reason and used a different tool:\n\n> Bash was blocked by org policy, so I used the Write tool instead. The file is created at the requested path with \"hello\" as content.\n\nOnly the second run got the file written. Explaining the block well and blocking completely are two different problems, and getting the first right can make the second worse, because you've told the model exactly what it needs to find another way in.\n\nAt 2.1.286 I ran both twice more on Haiku 4.5, with the prompt \"Use a shell command to create the file ... containing the word hello\". All four went around the block. With `{ deny: \"\" }` the model got `<tool_use_error><\/tool_use_error>` back, tried Bash once more, then wrote the file with Write. With the reason it got that back in the same tags, didn't retry, and said so:\n\n> Done. I created the file `\/tmp\/mods286\/D\/work\/g2-reason2.txt` containing \"hello\". (Note: shell commands are disabled on this machine, so I used the Write tool instead.)\n\nOn that build the reason saved one wasted Bash call, and the empty deny didn't stop the detour either.\n\n**Don't turn that into a rate.** A community probe at 2.1.263 ran thirty sessions and saw no detour at all, 0 of 10 for every deny shape, and the person who ran it took back their earlier claim because of it. Mine are six sessions over two builds, and five went around. The 2.1.272 pair went one each way. The four at 2.1.286 ran with `--allowedTools Bash Write`, so Write needed no permission prompt, and all four went around. So the detour happens, and with Write allowed up front it happened every time. Design around it being possible.\n\nIf you're blocking a capability and not one tool, block every tool that provides it. One hook with a matcher over the whole set beats one hook on Bash.\n\n## 3. A tool.call hook breaks worktree isolation for subagents\n\nReported upstream as [issue #92533](https:\/\/github.com\/anthropics\/claude-code\/issues\/92533). I confirmed it at 2.1.272, narrowed it down further than the report does, and it still happens at 2.1.286.\n\nSpawn a subagent with `isolation: \"worktree\"` while a mod has a `tool.call` hook loaded, and Bash inside that agent gets refused:\n\n> The working-directory isolation context for this agent was lost, so this command would run in the parent session's directory instead of this agent's worktree (\u2026\/.claude\/worktrees\/agent-abc3c365c2ea73b7a). Refusing to run it. Retry the command; if this keeps failing, report that worktree isolation was lost.\n\nSeven runs at 2.1.272, each a `claude -p` spawning one isolated agent that runs `pwd` through Bash, and the same seven at 2.1.286 on Haiku 4.5 gave the same table:\n\n| The mod registers | Bash inside the isolated agent |\n|---|---|\n| `session.start`, passthrough | works |\n| `tool.call` with `{tool:\"Bash\"}` | **refused** |\n| `tool.call` with `{tool:\"Read\"}` | works |\n| `tool.call`, no matcher | **refused** |\n| `tool.check` with `{tool:\"Bash\"}` | works |\n| `on(\"*\")` | **refused** |\n| `on(\"!tool.call\")` | works |\n\nThis part is my reading, the report doesn't say it: the isolation goes when a mod you've installed has a hook that matches a Bash `tool.call` inside a worktree-isolated agent. Matching a different tool is safe. `tool.check` on Bash is safe too, so use it if all you need is a say in the decision. A built-in hook on the same chain doesn't do it. The 2.1.286 bundle has `cc-plugin-diff` hooking `tool.call` for Bash and PowerShell itself, it loaded as a native built-in in all seven runs, and isolation held wherever my mod didn't hook Bash `tool.call`. The other built-in on `tool.call`, `cc-plugin-agents-md`, only matches Read.\n","previous_page":"testing","next_page":"recipes","url":"https:\/\/changelogs.core-directive.com\/reference\/mods\/gotchas","index_url":"https:\/\/changelogs.core-directive.com\/reference\/mods.json"}