<?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>How to know when Claude Code is done</title>
      <dc:creator>Agent Island Pro</dc:creator>
      <pubDate>Wed, 09 Sep 2026 02:23:04 +0000</pubDate>
      <link>https://dev.to/agentislandpro/how-to-know-when-claude-code-is-done-492</link>
      <guid>https://dev.to/agentislandpro/how-to-know-when-claude-code-is-done-492</guid>
      <description>&lt;p&gt;You start a long task, switch to something else, and come back nine minutes later to find it finished eight minutes ago — or worse, that it stopped to ask you a question and has been sitting there since.&lt;/p&gt;

&lt;p&gt;There is a built-in notification for this. It is off in most terminals, and it only fires when Claude Code believes you are away.&lt;/p&gt;

&lt;p&gt;Settings below are from &lt;a href="https://code.claude.com/docs/en/terminal-config" rel="noopener noreferrer"&gt;Claude Code's own documentation&lt;/a&gt;, September 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is already built in
&lt;/h2&gt;

&lt;p&gt;Claude Code fires a notification when it finishes a task or pauses for a permission prompt — with a condition that explains most of the "it never notifies me" reports: &lt;strong&gt;it only fires when you appear to be away from the terminal.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you are sitting in the window watching it work, there is nothing to interrupt you about, so nothing happens. Testing it by staring at the terminal is therefore a test designed to fail.&lt;/p&gt;

&lt;p&gt;The second condition is the terminal itself. A desktop notification is sent by default in &lt;strong&gt;Ghostty, Kitty and iTerm2&lt;/strong&gt;, and nowhere else. In any other terminal — Warp, the VS Code integrated terminal, Apple Terminal, Alacritty — nothing arrives until you ask for the bell instead:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;~/.claude/settings.json&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;"preferredNotifChannel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"terminal_bell"&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;Three further things swallow the notification even once it is enabled, and each looks like the feature being broken:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;If&lt;/th&gt;
&lt;th&gt;Then&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;You use iTerm2&lt;/td&gt;
&lt;td&gt;Forwarding is not on by default. Settings → Profiles → Terminal, enable Notification Center alerts, then under Filter Alerts allow escape-sequence-generated ones.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You run inside tmux&lt;/td&gt;
&lt;td&gt;tmux swallows it. &lt;code&gt;set -g allow-passthrough on&lt;/code&gt; in &lt;code&gt;~/.tmux.conf&lt;/code&gt;, then &lt;code&gt;tmux source-file ~/.tmux.conf&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nothing appears anywhere&lt;/td&gt;
&lt;td&gt;Check the terminal application itself has notification permission in your OS settings — the notification is delivered by the terminal, not by Claude Code.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you would rather have a sound you choose, a &lt;code&gt;Notification&lt;/code&gt; hook runs alongside the built-in one rather than replacing it:&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;"Notification"&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;"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;"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;"afplay /System/Library/Sounds/Glass.aiff"&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 the whole of the built-in answer, and for a single session it is usually enough. The rest of this is about why it stops being enough, and it starts with a definition problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Done" is three states, not one
&lt;/h2&gt;

&lt;p&gt;If you build anything on top of this — a notifier, a status line, a dashboard — the first thing you discover is that "is it done?" has no single answer. There are three states, they mean different things to you, and only one is urgent:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;th&gt;Does it need you?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Running&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Working. Tools are being called.&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Waiting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stopped mid-task on a permission prompt, a question, or a plan to approve.&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes, and it is blocked until you answer&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Idle&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The turn ended. It said its piece and is waiting for your next instruction.&lt;/td&gt;
&lt;td&gt;Only when you want it to&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The events map onto those states cleanly enough once you know which is which:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;UserPromptSubmit&lt;/code&gt; and &lt;code&gt;PostToolUse&lt;/code&gt; → running. Each is also proof of life, which matters later.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Notification&lt;/code&gt; → waiting. The message says whether it is a permission request or a question.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Stop&lt;/code&gt; → idle. &lt;strong&gt;Not "the work is finished"&lt;/strong&gt; — it means &lt;em&gt;this turn&lt;/em&gt; ended, which is a much smaller claim.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SubagentStop&lt;/code&gt; → nothing, as far as the parent is concerned. A subagent finishing says nothing about whether the session that spawned it needs you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The distinction that matters most is Waiting versus Idle&lt;/strong&gt;, because they feel identical from the outside — in both cases the terminal has gone quiet — and they could not be more different. Idle means you can look whenever you like. Waiting means it stopped mid-task and &lt;em&gt;nothing will happen until you answer&lt;/em&gt;, which is the case where a nine-minute delay is nine minutes wasted.&lt;/p&gt;

&lt;p&gt;A notifier that treats "quiet" as one state will either nag you about every finished turn or let you sit on a blocked prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: a status nobody is going to correct
&lt;/h2&gt;

&lt;p&gt;Here is the failure I shipped, and it is the one worth stealing the fix for.&lt;/p&gt;

&lt;p&gt;On 21 August a session sat showing &lt;em&gt;waiting for your input&lt;/em&gt; for over an hour. The user had answered in the terminal within seconds. Nothing was wrong with the answer — the problem was that the hooks had been connected &lt;em&gt;after&lt;/em&gt; that session started, so the event that would have cleared the waiting state was never sent. The state was correct when it was written and quietly became a lie afterwards.&lt;/p&gt;

&lt;p&gt;This is not a niche case. Any of these produce the same shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The session started before your hooks were installed&lt;/li&gt;
&lt;li&gt;The process was killed, so no &lt;code&gt;Stop&lt;/code&gt; and no &lt;code&gt;SessionEnd&lt;/code&gt; ever arrives&lt;/li&gt;
&lt;li&gt;The machine slept mid-task&lt;/li&gt;
&lt;li&gt;Your hook script broke for ten minutes and you fixed it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An event-driven state machine only knows what it was told. It cannot distinguish "still waiting" from "was waiting, and the update went missing", because both look like silence. So it needs one rule that does not depend on receiving anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// After this long with no event at all, a non-idle status&lt;/span&gt;
&lt;span class="c1"&gt;// is no longer evidence of anything.&lt;/span&gt;
&lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;staleAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;TimeInterval&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;

&lt;span class="kd"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;isStale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;Bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idle&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeIntervalSince&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lastEventAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;staleAfter&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten minutes of total silence and we stop asserting the state. Note the &lt;code&gt;status != .idle&lt;/code&gt; half: idle is the one state that &lt;em&gt;should&lt;/em&gt; persist, because a session that ended its turn an hour ago is still, correctly, a session that ended its turn. Only the active claims expire.&lt;/p&gt;

&lt;p&gt;The principle generalises past this codebase: &lt;strong&gt;a status you cannot stand behind must not be presented as current&lt;/strong&gt;, and stale evidence should decay on its own rather than wait for a correction that may never come.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two hooks, opposite designs
&lt;/h2&gt;

&lt;p&gt;One last thing worth copying if you write your own. Status events and permission prompts want opposite designs, and using one design for both is how you end up with a notifier that makes your agent feel slow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Status events&lt;/th&gt;
&lt;th&gt;Permission prompt&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Job&lt;/td&gt;
&lt;td&gt;Report what happened&lt;/td&gt;
&lt;td&gt;Answer a question, or decline to&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design&lt;/td&gt;
&lt;td&gt;Fire and forget&lt;/td&gt;
&lt;td&gt;Blocking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;My timeout&lt;/td&gt;
&lt;td&gt;&lt;code&gt;curl -m 0.3&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;curl -m 52&lt;/code&gt;, inside a configured &lt;code&gt;"timeout": 55&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On failure&lt;/td&gt;
&lt;td&gt;Give up silently, exit 0&lt;/td&gt;
&lt;td&gt;Print &lt;code&gt;{}&lt;/code&gt; — no opinion — and exit 0 (exit 2 would block)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A status hook must never make the CLI wait: nothing depends on its answer, so a third of a second is generous and anything longer is a tax on every single tool call.&lt;/p&gt;

&lt;p&gt;A permission hook is the opposite — something &lt;em&gt;is&lt;/em&gt; waiting on its answer — but it still has to return inside whatever &lt;code&gt;timeout&lt;/code&gt; it was given. The default for a command hook is 600 seconds, which is much longer than you want a blocking hook to hold a tool call, so set it deliberately.&lt;/p&gt;

&lt;p&gt;Both exit 0 on an ordinary failure, and deliberately so, because exit 2 would block the call. There is more on that in &lt;a href="https://agentislandapp.github.io/hooks.html" rel="noopener noreferrer"&gt;why your Claude Code hook isn't running&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://agentislandapp.github.io/done.html" rel="noopener noreferrer"&gt;agentislandapp.github.io/done.html&lt;/a&gt;. I write these while building AgentIsland, a macOS app that puts this state in the notch: a dot per session while things are running, and the actual permission prompt the moment one is waiting.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claude</category>
      <category>ai</category>
      <category>devtools</category>
      <category>productivity</category>
    </item>
    <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>
