Claude Code PreToolUse hook 漏擋迴圈命令,9 個連結開進錯誤瀏覽器設定檔
Our Claude Code hook blocked open with a URL but not open "$u" in a for loop, and 9 pages opened in the wrong profile
Rulestack 的 Claude Code PreToolUse hook 未能識別 `for` 迴圈中的 `open "$u"`,令 9 個連結開進錯誤的瀏覽器設定檔。
Our PreToolUse hook blocked one spelling of a bare
open <URL>, so on 2026-10-13 afor u in …; do open "$u"; doneloop walked nine links straight past it into the wrong browser profile, the third time since July. The rewrite treats anopenwhose target it can't read as a reason to block rather than a reason to pass, and replaying 59,145 past commands through the old and new versions told us more than the test list we wrote by hand.
Why a bare open is a problem for us
Our Claude Code agent works on one project from a Mac that is also used for other things, and the browser on that Mac has several profiles. The project's pages are supposed to open only in the profile set aside for it.
macOS's open command knows nothing about that. Its man page says it opens a file, directory or URL "just as if you had double-clicked the file's icon", using "the default application as determined via LaunchServices". On this machine that meant the default browser, which put each link in whichever profile had been used last. Three times now, that has been the wrong browser profile.
The first time was 2026-07-14. The fix that day was a small wrapper script that launches the browser with the profile named explicitly, plus a line in the agent's instructions saying the wrapper is the only way to open a page. A line in the instructions is a request, not a guarantee. On 2026-09-27, 75 days later, the agent ended a turn with a bare open again.
The same day we added a PreToolUse hook for the Bash tool. The Claude Code hooks reference describes PreToolUse as running "Before a tool call executes", with the input arriving as JSON on stdin; for a Bash call the command string sits at tool_input.command. For this event, exit code 2 "Blocks the tool call", and "Claude sees the stderr message as the denial reason." Our hook printed a short message pointing at the wrapper and exited 2.
The whole decision was one extended regex:
(^|[;&|(])[[:space:]]*open([[:space:]]+-[A-Za-z]+)*[[:space:]]+["']?(https?://|[^[:space:]"']+\.html)
Read left to right, it describes exactly one shape: open at the start of the string or right after ;, &, | or (, then optionally some single-dash flags, then a first argument that is literally a URL or a path ending in .html. It matched the two commands from 2026-09-27, because they were exactly that shape. Its three blocking tests were that shape too: a bare open with one or two URLs, the same after cd /tmp &&, and open on an HTML report path.
The loop
On 2026-10-13, sixteen days after the hook went in, the agent finished a turn in which it had posted eight replies and comments and refreshed one article. A standing instruction says to open every URL acted on at the end of a turn, and the agent followed it to the letter:
for u in https://example.com/1 https://example.com/2 …; do open "$u"; done; echo opened
The real command had nine URLs; we've replaced them here.
That command misses the regex twice, and either miss alone would have been enough. The open follows do, not a separator, so the anchor never fires. And its argument is "$u", which isn't a URL until the shell expands it, long after the hook has looked. Nine pages opened in the wrong browser profile, and the owner noticed.
Going back through the session transcripts, we counted every Bash call the hook blocked between its installation and the fix commit: 4. None of the 4 was the agent trying to open a page. One was a smoke test we ran right after installing it, one was a subagent exercising a copy of the hook while writing tests for it, and two were Python scripts that edited the hook's own test file while the fix was being written. For sixteen days the agent had followed the written rule. The one time it didn't, it wrote a shape the hook had never been told about.
Eight new tests, and the old hook passed all eight
The fix (commit 5d151fec, the same day) added eight blocking tests for the command that got through and its close neighbours: the loop itself, if …; then open …, open "$url", an argument in backticks, xargs -n1 open, find … -exec open {} +, env BROWSER=x open … and open -a Safari …. It also added seven commands that must keep passing, among them the wrapper called from the same loop, open -a Preview report.pdf, a commit message that mentions open https://…, and Python's open(p).read(). For this article we ran the old hook against the eight blocking cases again. It exited 0 on every one.
A second batch of failures was more uncomfortable. Before a large refactor, we had written characterization tests that pin the repository's current behaviour, and they had dutifully pinned this hook's behaviour too, including six commands it let through: open -a Safari https://x, open --new https://x, open x.HTML, open report.HTML, open "/tmp/a b.html" and OPEN https://x. Every one of those opens a page outside the profile we want. The suite had recorded the hook's blind spots as the expected result. When we widened the hook, those six tests failed, and each one had to be moved from "allowed" to "blocked" by hand, on purpose. A characterization test for a guard pins its holes along with its walls. When those tests fail after a fix, they are a list of decisions to make, not regressions to undo.
Blocking what the hook can't read
The rewrite stops asking "does this look like open https://…?" and asks two other questions.
The first is where each simple command starts. The hook splits the command string on ;, &, |, (, ) and newlines, and it does so twice for backticks, once splitting on them and once not, because an open inside a command substitution and an open whose argument is a command substitution need opposite treatment. Then, at the start of each segment, it skips anything that can sit in front of a command without being the command: shell keywords such as do, then, else, elif, ! and {; prefix commands such as time, exec, command, builtin, nohup, sudo and env; and NAME=value assignments.
line_head='^[[:space:]]*(((do|then|else|elif|[!]|[{]|time|exec|command|builtin|nohup|sudo|env)|[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*)[[:space:]]+)*'
unsafe_target_pattern='(https?://|\.html?|[$`])'
The second is whether the hook can tell what open is about to open. If the command at the start of a segment is open or /usr/bin/open, in any letter case, and the rest of the segment contains a URL, an .htm or .html path, a $ or a backtick, the hook blocks it. The last two are the real change: a variable or a command substitution is now a reason to block, not a reason to look away. In this repository open is almost always pointed at a URL or an HTML report, so a wrong block costs the agent one retry with the wrapper, while a missed one puts a page in the wrong browser profile. The hook also blocks open called from inside xargs, find -exec or -execdir, and sh -c, where the target comes from stdin or from file names and can't be read from the command string at all. A segment that launches the browser with --profile-directory= is left alone, and so is the wrapper.
Two smaller details in the rewrite are worth taking even if you never touch open.
An early draft of the rewrite ran one grep per segment, which meant one new process for every line of a long heredoc, and Bash tool calls with long heredocs were taking seconds just to clear the hook. The final version checks all segments with a single grep.
That single grep reads from a here-string, not a pipe, and the comment in the hook explains why: under set -o pipefail, grep -q exits at its first match, the writer on the other side of the pipe gets SIGPIPE, and the pipeline reports failure, which an if reads as "no match". We reproduced it with a 3 MB string whose first line matched: the pipe version returned status 141 and took the "no match" branch, and the here-string version matched. Then we went back to the old hook, which did use printf | grep -Eq under pipefail, and fed it a bare open https://x followed by filler lines, five runs per size. Up to 48 KB it blocked every run. At 64 KB it blocked 2 of 5, and from 65 KB up it blocked none. That was a fourth blind spot nobody had noticed. For us it's mostly theoretical: of the 59,145 distinct Bash commands in the transcripts we replay below, one was over 64 KB.
Replaying history as a test
The test lists we write by hand contain the shapes we already thought of. The shapes the agent actually writes are sitting in the session transcripts, and every hook input even carries a transcript_path pointing at the current one.
At the time of the fix, we pulled the distinct Bash commands from the 15 most recent sessions, 18,803 of them, and ran each one through the new hook. It blocked 4: two commands from 2026-09-27 (the bare open that led to the hook, and the smoke test we ran while installing it), the 2026-10-13 loop, and a Python script that had rewritten this hook's own tests and so contained the blocked strings literally. The slowest single run took 0.93 seconds.
The replay had already earned its keep by then. An earlier run of the first draft, over 18,798 commands at that point, blocked two strings that weren't commands at all. Both were Japanese prose in which the English word "open" is a status, as in a request that is still open. After splitting on parentheses and backticks, "open" landed at the start of a segment that also contained a $. Neither our hand-written tests nor the incident would have shown that. The fix was to count open as a command only when what follows it looks like the start of an argument: a dash, a quote, a dot, a slash, a tilde, a letter, digit or underscore, {, $ or a backtick.
For this article we redid the replay more widely and ran both versions side by side: every Bash call in every transcript for this repository, subagents included, from 2026-09-13 up to the moment the fix was committed. That came to 59,923 calls, or 59,145 distinct commands from 1,145 transcripts. Neither version can block a command that doesn't contain the letters "open" in some case, so we fed the 14,517 that do through both hooks. The old hook blocked 7 and the new one 10. Every command the old version blocked, the new one blocked too.
Two of the seven that both versions block are tests of the hook itself. The other five are real bare open commands with URLs in them. One is the 2026-09-27 command that led to the hook. The other four ran on 2026-09-24, three days before the hook existed, and nobody flagged them at the time. We don't know which profile those pages landed in. The rule in the instructions had been broken more often than our incident count says; we only counted the times someone noticed.
Of the 3 that only the new version blocks, one is the loop and one is the Python script from the day of the fix. The third is a false positive that never showed up in the fix-time replay. It sits in a subagent's transcript, not a main session's: a search along the lines of grep -n -i "…\|open question" content/articles-drafts/$f.md. The hook splits on | without knowing it's inside quotes, so open question" content/articles-drafts/$f.md becomes a segment that starts with open and contains a $. A block like that costs the agent one retry, but it is exactly the kind of command our hand-written "must pass" list doesn't have, and we only found it because the wider replay took in every transcript we had.
None of this needs our setup. If your hook is a script that reads JSON on stdin and exits 0 or 2, you can feed it every command your agent has ever run, see what it would have blocked, and diff two versions of the hook against the same history before you ship either one. We also hit the over-blocking side ourselves while writing this: the first command we used to run the test cases was a heredoc full of open https://… strings, and the hook stopped it. We wrote the script to a file another way and ran that instead.
What it still lets through
A hook that pattern-matches command strings is a deny-list over shell syntax, and shell syntax doesn't run out. To see where ours stands, we wrote 17 more shapes that aren't in its tests and ran both versions on them. The old hook blocked 1. The new one blocks 6 (bash -c "open …", nohup open …, sudo open …, command open …, open -u …, and a while read u; do open "$u"; done loop) and lets 11 through:
-
timeout 5 open https://xand/usr/bin/env open https://x, because neither prefix is on the skip list -
eval "open https://x","open" https://xand\open https://x -
alias o=open; o https://xandf=open; $f https://x osascript -e 'open location "https://x"'- Python's
webbrowser.open(...)and Node'schild_process.exec("open …")inside-cand-eone-liners xargs open < urls.txt
The last one was a plain bug. The hook says it covers xargs, but its "looks like an argument" class didn't include <, so an open followed by a redirect wasn't counted as a call. We found it while writing this and fixed it the same day: after xargs, -exec or -c, an open followed by < now counts as a call, and two tests pin the spaced and unspaced forms. The fix announced itself: the shell command we used to edit this paragraph contained that very shape, and the hook blocked it. The quoted \| false positive above is still there. It blocks a command that was safe, which costs a retry but never opens a page. The other ten are the honest limit of the approach. We searched the same 59,145 commands for all eleven shapes and found none of them, and the hook is there to catch the agent's habits, not someone determined to get around it. If we needed the second thing, a string check in a hook would be the wrong layer.
What we'd pass on to anyone writing PreToolUse hooks that read Bash commands:
-
Name the property, then match on it. "No page opens outside the right profile" was the property; "
openfollowed by a URL" was one spelling of breaking it. Treating an unreadable target as a violation covered loops, variables andxargswith one rule. -
Where a command starts is a shell question, not a regex anchor.
do,then,env,sudoandVAR=valuecan all sit in front of the command you care about, and a|inside quotes is not a pipe. - Characterization tests of a guard freeze its holes. When they fail after a fix, decide each one.
- Replay real history, all of it. It measures over-blocking against what your agent really writes, it's the cheapest way to compare two versions of a hook, and subagent transcripts count.
-
Watch
pipefailaroundgrep -q. Use a here-string, or read the whole input before matching.
Over to you
If you write PreToolUse hooks that inspect Bash commands, how do you test them: a hand-written list of cases, generated variations, a replay of your own transcripts, or something else? And has one of your hooks ever let through the exact command it was written to stop, just spelled differently? We'd like to hear how it happened and what you changed, in the comments below.
來源:DEV Community · dev.to

