<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Agent Island Pro</title>
    <description>The latest articles on DEV Community by Agent Island Pro (@agentislandpro).</description>
    <link>https://dev.to/agentislandpro</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4113819%2Fb7a111c4-55eb-4f07-8de7-0d2c58e43567.png</url>
      <title>DEV Community: Agent Island Pro</title>
      <link>https://dev.to/agentislandpro</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/agentislandpro"/>
    <language>en</language>
    <item>
      <title>Why your Claude Code hook isn't running</title>
      <dc:creator>Agent Island Pro</dc:creator>
      <pubDate>Mon, 07 Sep 2026 22:07:53 +0000</pubDate>
      <link>https://dev.to/agentislandpro/why-your-claude-code-hook-isnt-running-2i76</link>
      <guid>https://dev.to/agentislandpro/why-your-claude-code-hook-isnt-running-2i76</guid>
      <description>&lt;p&gt;A hook that fails does not announce it. It just never seems to fire, and you are left guessing whether the problem is your script, your matcher, or your JSON.&lt;/p&gt;

&lt;p&gt;There is a flag that answers that in one line, and then five failure modes that account for nearly all of the rest.&lt;/p&gt;

&lt;p&gt;Flags checked against the CLI directly; behaviour against the &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks documentation&lt;/a&gt;, September 2026. Where a number below comes from my own config rather than a default, it says so — an earlier version of this post did not, and got the timeout wrong as a result.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: find out whether it ran at all
&lt;/h2&gt;

&lt;p&gt;Almost every hook debugging session starts in the wrong place — reading the script — when the actual question is whether Claude Code ever invoked it. Ask directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;--debug&lt;/span&gt; hooks
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--debug&lt;/code&gt; takes an optional category filter, so this starts a normal session with hook activity logged and the rest of the noise left out. You will see which hooks are considered for each tool call and which are actually executed.&lt;/p&gt;

&lt;p&gt;That single line splits the problem in half: &lt;strong&gt;if your hook never appears, the fault is in your configuration&lt;/strong&gt; and nothing in your script can fix it. If it appears and runs, the fault is in what it printed or how long it took.&lt;/p&gt;

&lt;p&gt;Two companions worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;claude --debug-file /tmp/cc.log&lt;/code&gt; writes the debug log to a file instead of into your session, which is much easier to read after the fact than a scrolled-away terminal. It enables debug mode on its own, so you do not need both flags.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;claude --bare&lt;/code&gt; starts a session that &lt;strong&gt;skips hooks entirely&lt;/strong&gt; (along with LSP and plugins). This is the isolation test: if the misbehaviour you are chasing also happens under &lt;code&gt;--bare&lt;/code&gt;, your hook was never the cause.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the configuration actually looks like
&lt;/h2&gt;

&lt;p&gt;Hooks live in &lt;code&gt;settings.json&lt;/code&gt; under a &lt;code&gt;hooks&lt;/code&gt; key, and the shape is three levels deep, which is where a lot of hand-written config goes wrong. Event name, then a list of &lt;em&gt;matcher groups&lt;/em&gt;, then a list of hooks in each group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"PreToolUse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"matcher"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash|Write|Edit|NotebookEdit|WebFetch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;/Users/you/Library/Application Support/YourApp/hook.sh&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"timeout"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a real installed hook, reformatted only for width. The events you can hang a hook on are &lt;code&gt;PreToolUse&lt;/code&gt;, &lt;code&gt;PostToolUse&lt;/code&gt;, &lt;code&gt;Notification&lt;/code&gt;, &lt;code&gt;UserPromptSubmit&lt;/code&gt;, &lt;code&gt;Stop&lt;/code&gt;, &lt;code&gt;SubagentStop&lt;/code&gt;, &lt;code&gt;PreCompact&lt;/code&gt;, &lt;code&gt;SessionStart&lt;/code&gt; and &lt;code&gt;SessionEnd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The matcher is optional, and omitting it is usually what you want&lt;/strong&gt; for the non-tool events. A group with no &lt;code&gt;matcher&lt;/code&gt; key fires for every occurrence of that event.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things that silently stop a hook
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A path with a space in it, unquoted
&lt;/h3&gt;

&lt;p&gt;The most common one on macOS, and entirely self-inflicted, because the natural place to put a helper script is &lt;code&gt;~/Library/Application Support/…&lt;/code&gt; — a path with a space in the middle of it. The &lt;code&gt;command&lt;/code&gt; string is handed to a shell, so an unquoted path splits into two arguments and the shell reports a file that does not exist, to nobody.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;❌  /Users/you/Library/Application Support/YourApp/hook.sh
✅  "/Users/you/Library/Application Support/YourApp/hook.sh"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside JSON those quotes have to be escaped, which is how you end up with the &lt;code&gt;\"&lt;/code&gt; soup in the example above. It looks wrong and it is correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A matcher that does not match
&lt;/h3&gt;

&lt;p&gt;The matcher is tested against the tool name, so it has to be the name Claude Code uses — &lt;code&gt;Bash&lt;/code&gt;, &lt;code&gt;Write&lt;/code&gt;, &lt;code&gt;Edit&lt;/code&gt;, &lt;code&gt;NotebookEdit&lt;/code&gt;, &lt;code&gt;WebFetch&lt;/code&gt; — not the command you are running and not a lowercase version.&lt;/p&gt;

&lt;p&gt;A matcher of &lt;code&gt;bash&lt;/code&gt; will not match &lt;code&gt;Bash&lt;/code&gt;. A matcher of &lt;code&gt;npm&lt;/code&gt; will never match anything, because no tool is called that; the command is &lt;em&gt;input&lt;/em&gt; to the &lt;code&gt;Bash&lt;/code&gt; tool, not a tool of its own. If &lt;code&gt;--debug hooks&lt;/code&gt; shows your hook being considered but never run, this is almost always why.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Misreading what the exit code does
&lt;/h3&gt;

&lt;p&gt;A hook has two channels, and conflating them causes trouble in both directions.&lt;/p&gt;

&lt;p&gt;What it prints on stdout can carry a decision — for a &lt;code&gt;PreToolUse&lt;/code&gt; hook, JSON becomes the decision, and printing &lt;code&gt;{}&lt;/code&gt; means &lt;em&gt;no opinion&lt;/em&gt;, so the normal permission flow happens exactly as if the hook were not installed.&lt;/p&gt;

&lt;p&gt;But the exit code is not merely advisory:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Exit 2 blocks, and it wins.&lt;/strong&gt; On events that can block, exit 2 stops the tool call whether or not you printed JSON — it overrides even a JSON &lt;code&gt;permissionDecision&lt;/code&gt; of &lt;code&gt;allow&lt;/code&gt;, and it stops the call before permission rules are evaluated, so it beats an allow rule too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That makes it a deliberate tool, and also a hazard: a script that dies with status 2 for an unrelated reason blocks real work.&lt;/p&gt;

&lt;p&gt;So the rule is not "never exit non-zero" — it is that &lt;strong&gt;an ordinary failure must exit 0&lt;/strong&gt;. Reserve a non-zero status for a block you actually mean.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail open, never closed.&lt;/strong&gt; If your hook cannot reach whatever it consults, exit 0 and let the normal flow happen. A hook that fails closed converts one bug in a shell script into a session that cannot run anything, and the failure looks like the agent being broken, not like your hook being broken.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The timeout you did not set
&lt;/h3&gt;

&lt;p&gt;The default for a &lt;code&gt;command&lt;/code&gt; hook is &lt;strong&gt;600 seconds&lt;/strong&gt; on most events — lower on a few, such as 30 for &lt;code&gt;UserPromptSubmit&lt;/code&gt; and 10 for &lt;code&gt;MessageDisplay&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Ten minutes is far longer than you want a blocking hook to hold a tool call, so set &lt;code&gt;timeout&lt;/code&gt; explicitly in the config rather than inheriting it. Then give the work inside the script a slightly shorter deadline than the timeout you set, so it always answers rather than being killed mid-thought.&lt;/p&gt;

&lt;p&gt;Mine waits on a person clicking a button: &lt;code&gt;"timeout": 55&lt;/code&gt; in the config, &lt;code&gt;curl -m 52&lt;/code&gt; in the script, three seconds of headroom. Those are my numbers, not defaults.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. A PATH that is not your shell's
&lt;/h3&gt;

&lt;p&gt;Your hook is not launched from your interactive shell, so it does not inherit what your shell profile sets up. A hook that calls &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;uv&lt;/code&gt; or anything else installed by a version manager can work perfectly when you run it by hand and find nothing when Claude Code runs it.&lt;/p&gt;

&lt;p&gt;Use absolute paths for interpreters and helpers, or resolve them explicitly at the top of the script. This is the failure mode that most reliably survives an afternoon of debugging, because every manual test of the script passes.&lt;/p&gt;

&lt;h2&gt;
  
  
  A checklist, in the order that finds it fastest
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it rules out&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude --debug hooks&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Config problem or script problem — do this first, always&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ls -l&lt;/code&gt; the script&lt;/td&gt;
&lt;td&gt;Not executable. It needs the executable bit and a shebang&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run it by hand, echoing its output&lt;/td&gt;
&lt;td&gt;Invalid JSON, or output on stderr instead of stdout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quote the path in &lt;code&gt;command&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The space in &lt;code&gt;Application Support&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compare the matcher to the real tool name&lt;/td&gt;
&lt;td&gt;Case, and matching the command instead of the tool&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude --bare&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Whether your hook was ever involved in the symptom at all&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  One thing a hook is not
&lt;/h2&gt;

&lt;p&gt;Hooks and permission rules are separate gates, and a hook does not override the rules: Claude Code evaluates deny and ask regardless of what the hook returned. A matching deny still blocks and a matching ask still prompts, even if the hook said allow.&lt;/p&gt;

&lt;p&gt;The ordering of those gates, and why an allow rule stops applying the moment one part of a chained command is not covered, is &lt;a href="https://agentislandapp.github.io/permissions.html" rel="noopener noreferrer"&gt;a separate piece&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agentislandapp.github.io/hooks.html" rel="noopener noreferrer"&gt;agentislandapp.github.io/hooks.html&lt;/a&gt;. I write these while building AgentIsland, a macOS app that installs exactly the hooks described above and puts Claude Code's permission prompts in the notch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>devtools</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Why your Claude Code allow rules keep asking anyway</title>
      <dc:creator>Agent Island Pro</dc:creator>
      <pubDate>Mon, 07 Sep 2026 11:39:00 +0000</pubDate>
      <link>https://dev.to/agentislandpro/why-your-claude-code-allow-rules-keep-asking-anyway-3m10</link>
      <guid>https://dev.to/agentislandpro/why-your-claude-code-allow-rules-keep-asking-anyway-3m10</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Correction, 7 September 2026.&lt;/strong&gt; The first version of this post claimed an allow rule is a prefix match against the whole command string, and that &lt;code&gt;cd frontend &amp;amp;&amp;amp; npm test&lt;/code&gt; therefore fails because the string starts with &lt;code&gt;cd&lt;/code&gt;. That is wrong. Claude Code is aware of shell operators and checks each subcommand independently. I had verified the behaviour against my own app's matcher and written it up as though it were Claude Code's — which is exactly the mistake this post now warns about. Corrected throughout, and the real explanation is more useful than the wrong one was. Sources linked below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You allowed &lt;code&gt;npm test&lt;/code&gt;. It is still asking.&lt;/p&gt;

&lt;p&gt;The reason is almost never the rule you wrote. It is that Claude Code splits a compound command and &lt;strong&gt;every subcommand has to match a rule of its own&lt;/strong&gt; — and that several shapes of command can never be auto-approved by a prefix rule at all.&lt;/p&gt;

&lt;p&gt;Checked against Claude Code's &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;permissions&lt;/a&gt; and &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;hooks&lt;/a&gt; documentation, September 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order the rules are evaluated in
&lt;/h2&gt;

&lt;p&gt;There are three kinds of permission rule, not two, and the third is the one people forget they set. They are evaluated in a fixed order and &lt;strong&gt;the first match wins&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Effect&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;deny&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Blocks the call.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;ask&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Prompts you, even in modes where nothing would otherwise prompt.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;allow&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Runs with no prompt.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Specificity does not change that order, which produces two results worth internalising:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A broad deny cannot carry exceptions.&lt;/strong&gt; &lt;code&gt;Bash(aws *)&lt;/code&gt; in deny blocks &lt;code&gt;aws s3 ls&lt;/code&gt; even if you also allow exactly that. If you need exceptions, do not write the deny.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An ask rule beats your allow rule.&lt;/strong&gt; If something still prompts and you cannot see why, look for an &lt;code&gt;ask&lt;/code&gt; entry before you touch the allow list. A matching ask prompts even when a more specific allow rule also matches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Each subcommand is matched on its own
&lt;/h2&gt;

&lt;p&gt;This is the actual answer to the question in the title. Claude Code recognises &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt;, &lt;code&gt;||&lt;/code&gt;, &lt;code&gt;;&lt;/code&gt;, &lt;code&gt;|&lt;/code&gt;, &lt;code&gt;|&amp;amp;&lt;/code&gt;, &lt;code&gt;&amp;amp;&lt;/code&gt; and newlines as command separators, splits on them, and requires that &lt;strong&gt;every&lt;/strong&gt; resulting subcommand match a rule. A chain is allowed only if all of its parts are.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;packages/api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;ls&lt;/span&gt;        ✅ &lt;span class="nb"&gt;cd &lt;/span&gt;into your working directory is read-only,
                              and &lt;span class="nb"&gt;ls &lt;/span&gt;qualifies on its own

&lt;span class="nb"&gt;cd &lt;/span&gt;packages/api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;test&lt;/span&gt;  ✅ with Bash&lt;span class="o"&gt;(&lt;/span&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; — the &lt;span class="nb"&gt;cd &lt;/span&gt;half is
                              read-only, the npm half matches your rule

git status &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm publish    ❌ npm publish is not covered by anything,
                              so the whole chain prompts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One uncovered part is enough to make the whole line prompt. That is why adding rules one at a time feels like whack-a-mole: you are covering the part that prompted, and the next chain has a different uncovered part.&lt;/p&gt;

&lt;p&gt;The same splitting works in your favour for deny and ask, which apply when &lt;em&gt;any&lt;/em&gt; subcommand matches — including one nested in a subshell, a command substitution, or a loop body. An ask rule of &lt;code&gt;Bash(git clean *)&lt;/code&gt; still prompts for &lt;code&gt;cd /tmp &amp;amp;&amp;amp; git clean -f&lt;/code&gt; and for &lt;code&gt;echo "$(git clean -f)"&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern syntax, and its three sharp edges
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Matches&lt;/th&gt;
&lt;th&gt;Does not match&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bash(npm test *)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;npm test&lt;/code&gt;, &lt;code&gt;npm test --watch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;npm run build&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bash(npm test:*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the same thing — &lt;code&gt;:*&lt;/code&gt; is an equivalent way to write a trailing wildcard&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bash(ls *)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ls&lt;/code&gt;, &lt;code&gt;ls -la&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lsof&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Bash(ls*)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ls -la&lt;/code&gt; and &lt;strong&gt;&lt;code&gt;lsof&lt;/code&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The space before a trailing &lt;code&gt;*&lt;/code&gt; is part of the rule.&lt;/strong&gt; &lt;code&gt;Bash(ls *)&lt;/code&gt; requires a space after &lt;code&gt;ls&lt;/code&gt;; &lt;code&gt;Bash(ls*)&lt;/code&gt; does not, so it also matches &lt;code&gt;lsof&lt;/code&gt; and anything else beginning with those two letters. This is the most common way an allow rule ends up broader than intended.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the &lt;code&gt;*&lt;/code&gt; after the subcommand.&lt;/strong&gt; In &lt;code&gt;Bash(git * main)&lt;/code&gt; the wildcard stands in for the subcommand itself, so it matches every git subcommand — including &lt;code&gt;-c&lt;/code&gt;, which makes git run a program you name. Claude Code warns at startup about a wildcard before the subcommand, and the warning is worth reading rather than dismissing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;:*&lt;/code&gt; is only recognised at the end.&lt;/strong&gt; In &lt;code&gt;Bash(git:* push)&lt;/code&gt; the colon is a literal character and the rule matches nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Environment assignments
&lt;/h3&gt;

&lt;p&gt;A leading assignment of a &lt;em&gt;known-safe&lt;/em&gt; environment variable is stripped before an allow rule is matched, so &lt;code&gt;Bash(npm test *)&lt;/code&gt; does match &lt;code&gt;NODE_ENV=test npm test&lt;/code&gt;. An allow rule will not match past an assignment of any other variable. Deny and ask match past any leading assignment, so &lt;code&gt;Bash(rm *)&lt;/code&gt; in deny still catches &lt;code&gt;FOO=bar rm -rf tmp/&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shapes that a prefix rule can never approve
&lt;/h2&gt;

&lt;p&gt;If a command keeps prompting no matter what you add to the allow list, it is probably one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exec wrappers&lt;/strong&gt; — &lt;code&gt;watch&lt;/code&gt;, &lt;code&gt;setsid&lt;/code&gt;, &lt;code&gt;ionice&lt;/code&gt;, &lt;code&gt;flock&lt;/code&gt;. &lt;code&gt;Bash(watch *)&lt;/code&gt; does not auto-approve them, because the wrapper's job is to run something else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;find&lt;/code&gt; with &lt;code&gt;-exec&lt;/code&gt; or &lt;code&gt;-delete&lt;/code&gt;&lt;/strong&gt;, for the same reason. A &lt;code&gt;Bash(find *)&lt;/code&gt; rule does not cover those forms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An unparseable command.&lt;/strong&gt; A trailing &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; with nothing after it, as in &lt;code&gt;npm test &amp;amp;&amp;amp;&lt;/code&gt;, cannot be split into subcommands, so even &lt;code&gt;Bash(npm *)&lt;/code&gt; will not approve it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an exec wrapper you actually want, the only option is an exact-match rule for the full command string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rules are actually read from
&lt;/h2&gt;

&lt;p&gt;Allow, ask and deny rules are merged from more places than most people expect, and a rule in one file will not show up when you look in another:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;~/.claude/settings.json&lt;/code&gt; — your own, applies everywhere&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;project&amp;gt;/.claude/settings.json&lt;/code&gt; — checked into the repo, applies to everyone working on it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;&amp;lt;project&amp;gt;/.claude/settings.local.json&lt;/code&gt; — yours, for this repo, not checked in&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;~/.claude.json&lt;/code&gt; — &lt;strong&gt;the one people miss.&lt;/strong&gt; Session approvals ("don't ask again") land here under &lt;code&gt;projects → &amp;lt;path&amp;gt; → allowedTools&lt;/code&gt;, not in any settings file&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last file explains a specific confusion: you clicked "don't ask again" once, months ago, and now cannot find the rule anywhere in your settings. It is in &lt;code&gt;~/.claude.json&lt;/code&gt;, keyed by the project's absolute path — so it also silently stops applying if you move or rename the directory, or reach it through a different symlink.&lt;/p&gt;

&lt;p&gt;Worth knowing what that click actually writes: approving a compound command saves &lt;strong&gt;a separate rule for each subcommand that needed approval&lt;/strong&gt;, up to five, rather than one rule for the whole line. Approving &lt;code&gt;git status &amp;amp;&amp;amp; npm test&lt;/code&gt; saves a rule for &lt;code&gt;npm test&lt;/code&gt;, which is then recognised regardless of what precedes it next time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hooks are a separate gate, and they do not override the rules
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;PreToolUse&lt;/code&gt; hook runs before the permission prompt, and its output can deny the call, force a prompt, or let it through. Two things about it are commonly got backwards — I had one of them backwards myself:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A hook does not bypass deny or ask.&lt;/strong&gt; Claude Code evaluates those rules regardless of what the hook returned; a matching deny still blocks and a matching ask still prompts, even if the hook said allow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exit code 2 blocks, on its own.&lt;/strong&gt; On events that can block, exit 2 stops the call whether or not you printed JSON — it even overrides a JSON &lt;code&gt;permissionDecision&lt;/code&gt; of &lt;code&gt;allow&lt;/code&gt;, and it takes precedence over allow rules because it stops the call before the rules are evaluated. So exit status is &lt;em&gt;not&lt;/em&gt; merely advisory, and a hook that exits non-zero by accident will block work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an ordinary "no opinion" outcome you want exit 0, with either no output or a JSON decision. &lt;strong&gt;Fail open, never closed:&lt;/strong&gt; if your hook cannot reach whatever it consults, exit 0 and let the normal flow happen.&lt;/p&gt;

&lt;p&gt;On timeouts, be careful with numbers you read anywhere including here: the default for a &lt;code&gt;command&lt;/code&gt; hook is &lt;strong&gt;600 seconds&lt;/strong&gt; on most events, lower on a few (30 for &lt;code&gt;UserPromptSubmit&lt;/code&gt;, 10 for &lt;code&gt;MessageDisplay&lt;/code&gt;). A hook that waits on a human should set its own &lt;code&gt;timeout&lt;/code&gt; in the config and give its own work a slightly shorter deadline than that. Ours sets &lt;code&gt;"timeout": 55&lt;/code&gt; and uses &lt;code&gt;curl -m 52&lt;/code&gt; inside it — those are our numbers, not Claude Code's defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  A worked example: making a destructive command impossible
&lt;/h2&gt;

&lt;p&gt;Deny is evaluated first and applies when any subcommand matches, so this holds in auto mode and inside chains and subshells:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(sudo date *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(sudo systemsetup *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(rm -rf / *)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth knowing the limit of this, because it is easy to over-trust: deny rules constrain &lt;em&gt;the agent's own tool calls&lt;/em&gt;. They do not constrain a script the agent wrote and then ran. If the agent writes a Python file that reads your &lt;code&gt;.env&lt;/code&gt; and connects to production, that is Python reading a file it is allowed to read, and no rule here sees it. The boundary that holds in that case is what the credential itself is permitted to do — a read-only database user, a reader-role service principal — not what the agent is permitted to type.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agentislandapp.github.io/permissions.html" rel="noopener noreferrer"&gt;agentislandapp.github.io/permissions.html&lt;/a&gt;. I write these while building AgentIsland, a macOS app that puts Claude Code's permission prompts in the notch so you answer them without hunting for the right terminal window.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
