It said it was working
A stock shell from 2007, a sandbox profile that never parsed, and a tool built well enough to tell me it was broken.
We have an internal tool at work called cadence-claude, and I want to describe it properly before I spend 2,000 words on its bugs, because it’s the best piece of internal tooling I’ve worked on and the bugs are almost entirely my fault.
The pitch is that it lets you run Claude Code in --dangerously-skip-permissions mode without handing over the keys to your machine. Three independent layers, and all of them have to say yes. A set of PreToolUse hooks catches intent: they refuse ssh-family commands aimed at hosts you haven’t allowlisted, and they refuse reads of ~/.ssh, ~/.aws, keychains, browser profiles, shell history. A kernel egress filter catches bytes, on Linux by moving the session’s processes into a per-project cgroup filtered by nftables, so a forbidden destination is refused no matter how the connection got opened. And a per-project ssh-agent catches credentials: the key you’d need to authenticate isn’t even loaded unless you explicitly granted that environment.
That composition is the good idea. The hook layer is defeatable in principle, because it’s parsing shell, and shell is an adversarial input format with infinite surface. The kernel layer doesn’t care how clever your quoting was. The agent layer doesn’t care whether you reached the host. You have to beat all three, and they fail in uncorrelated ways.
The build quality around it is the part I didn’t expect. The binary installs root-owned and immutable, chattr +i on Linux and chflags uchg on macOS, so the thing governing enforcement can’t be edited by the agent it’s governing. The install is wrapped in an EXIT trap that re-seals on Ctrl-C or any failure path, so a half-finished upgrade can’t leave it writable. There’s a verify subcommand that walks the whole trusted computing base and asserts ownership and immutability and hook registration. There’s a SessionStart hook whose only job is to re-assert the tool’s own entries in settings.json in case something stripped them. Every rendered hook carries a version string so an upgrade can detect skew and re-render.
And it shipped with a bypass suite. 118 adversarial cases, written by someone actively trying to defeat their own hooks: quoted command names, bash -c wrappers, process substitution, GIT_SSH_COMMAND=, ext:: transports, -o KnownHostsCommand=. That suite is why this post exists at all. Hold that thought.
A coworker of mine built all of it, and built it entirely on Linux. cgroups, nftables, systemd units, a little Python daemon over a Unix socket. Then I got handed the job of making it work on a Mac, which I estimated at maybe a week, because how different can it be.
Reader, the very first line of the install died. install -g root fails on macOS with “install: unknown group root,” because the root user’s group is wheel and there is no root group at all. Six call sites, ten minutes. In hindsight it was the friendliest bug of the entire project, because at least it stopped.
91 of 118
I ran the bypass suite on my Mac and it reported 91 failures out of 118.
I want to be precise about what that number means, because it reads worse than it was. It doesn’t mean the tool was broken. On Linux, where it had run every day for months, it was green and it was correct. What that number means is that this was a platform the code had never once executed on, and the suite noticed inside of 30 seconds. Most internal tools would have let me ship a Mac port and find out some other way.
Four idioms in the hooks needed bash 4:
${var,,} # lowercasing
"${empty_array[@]}" # unbound-variable error under set -u
local -A seen # associative arrays
${dir/#$HOME/\~} # keeps the backslash literal on 3.2
Ordinary portability bugs. The part that kept me up was which direction they broke in.
${var,,} aborts extract_targets, so the segment splitter decides most ssh-family commands aren’t ssh commands and lets them through. The empty-array expansion trips set -u and skips the entire subshell-walk loop, the one that catches $(ssh …), backticked ssh, bash -c, nohup, timeout and process substitution. local -A is a parse error, so protect-secrets bails out of its content scan before it has looked at a single token. And the tilde substitution never matching meant cat ~/.ssh/id_rsa was simply allowed.
None of these crashed the hook. A helper bailed, the script carried on, and it reached the bottom and exited 0. In the hook protocol, exit 0 means allow.
The default answer of a broken security hook is yes.
My favorite one wasn’t even bash. The destructive-command hook used \b in a sed regex to catch sudo-prefixed payloads. \b is a GNU extension. BSD sed doesn’t error on it, doesn’t warn, just quietly matches nothing, so every sudo-wrapped bypass in the suite walked straight past. I replaced it with a POSIX character class and a backreference, and I still think about how many other regexes in how many other scripts are silently no-opping on somebody’s Mac right now.
There was also a genuinely funny one where the tests were wrong too. The harness built a project under $TEST_TMP, but on macOS /var/folders/… is a symlink to /private/var/folders/…, and the hook does its own cd && pwd -P before computing the project slug. So the two disagreed about which project they were in, and every case that was supposed to be allowed got denied. That one only surfaced after I’d fixed the real bugs, which means for a while my score went down as things got better.
209 of 209 after all of it, with no behavior change on bash 4 and 5.
The install printed a checkmark and did nothing
I bumped a version, re-ran ./install.sh against my existing install, and watched bash print “Permission denied” on every hook write, followed by ✓ installed (v0.5.0). On disk: still v0.4.0.
The chain, which I want to write out because each step is individually reasonable:
- The version check says the hook is stale, so the writer runs.
- We unlock the existing file.
- The file is still
root:wheel, mode 0755, from the previous install. - The writer is a plain
cat > targetrunning as me, not under sudo, so it EACCESes on the truncate. - The post-write sanity check is
[[ ! -s "$target" ]], and the file is not empty. It’s full of the old version. chown,chmodand the re-lock all succeed, because they’re operating on an unchanged file that already looks exactly like that.- Print success.
Every guard on that path was checking something true. None was checking the thing I cared about. And it had never fired before because my version bump was the first time anyone had exercised the upgrade path on an existing install. Its own small lesson about which code paths get tested by ordinary use.
The fix was to rm -f the prior file before writing (the directory was chowned to us a few lines earlier, so the unlink works regardless of who owns the file) and then re-run the version check afterward, because the version string embedded in the rendered hook is the only ground truth that new content actually landed.
The uninstall command I wrote later had the same disease from the other end. Its _safe_remove helper checked rm’s exit code, in the shape if ! sudo rm -f "$1" 2>/dev/null. On bash 3.2 that combination swallowed the non-zero exit even while rm was printing “Operation not permitted” to my terminal. So the helper returned 0, the caller printed “✓ removed,” and the function exited clean, which meant the EXIT trap saw rc 0 and short-circuited and never re-sealed anything. Net result of a “successful” uninstall: hook files still on disk with their immutable bit cleared. Not removed, and no longer protected.
I stopped asking rm whether it had worked and just checked [[ -e "$1" ]]. Whether the file is gone is the only outcome that matters, and that test doesn’t care what shell you’re running.
Verify was green and the sandbox was never on
On macOS the enforcement layer is sandbox-exec, engaged by a shim: a small script earlier on your $PATH than the real claude, which re-execs the real one inside a per-project profile.
Which works exactly as long as the shim really is first on $PATH. If Claude Code’s own installer has put ~/.local/bin ahead of it, every claude you type runs unwrapped. And there’s no signal. which claude returns a real path. claude --version works. verify returned green, because it checked that the shim file existed, which it did.
So I added a check that walks $PATH segment by segment, canonicalizes both the first reachable claude and the shim with readlink -f, and fails if they aren’t the same file. And then, because verify had never actually failed on anyone before, its failure footer turned out to have two bugs nobody had ever seen: the color escape variables sat inside a single-quoted printf format string and printed literally, and the repair hint interpolated dirname "$0", which resolves to /usr/local/bin, so it told you to run /usr/local/bin/install.sh, a file that has never existed on anybody’s machine. The error path had never run. I’d have bet money it worked.
The last twist there is my favorite bit of the whole project. Our install-time guidance told people to add the shim’s PATH line to the top of ~/.zshrc. Claude Code’s installer appends its own export PATH="$HOME/.local/bin:$PATH" near the bottom. So you follow the instructions precisely, open a new terminal, and get shadowed by the exact line we were trying to get in front of.
The profile that never parsed
This is the one I’d tell you about at a bar, and it took until April to find.
On Linux the egress filter allowlists by (destination IP, port) tuples in nftables, which is a strong property. The macOS profile generator mirrored that shape, emitting one allow clause per resolved endpoint:
; what the generator emitted, one per granted endpoint
(allow network-outbound (remote ip4 "10.1.2.3:22"))
; what Seatbelt will actually accept: the host must be * or localhost
(allow network-outbound (remote ip "*:22"))
Seatbelt cannot filter network traffic by remote IP. The host portion of a (remote …) address has to be * or localhost. You can gate a port. You cannot gate an address. So the profile didn’t fail to enforce, it failed to parse, and since the shim runs exec sandbox-exec -f <profile> claude, the moment a project had a granted host, claude wouldn’t launch in that directory at all. That’s how it got reported: as a crash.
The thing that landed for me is that the per-IP allowlist on macOS was never expressible. Not broken by a refactor, not regressed. There was no version of that file that was ever going to work, and the README had been describing per-IP enforcement on macOS the whole time. It stayed dormant only because a project with no profile on disk has nothing to fail to parse, so ungranted projects worked fine and granted ones were the only ones that ever loaded the file.
Now, here is where I think the architecture earns its keep, and I want to give it credit rather than just cataloging the wreck. A whole enforcement layer was inert on that platform, and the damage was still contained, because by April the hook layer had 200-odd adversarial tests behind it and was doing exactly what it was designed to do: refuse ssh to any host outside the project’s allowlist, regardless of what the kernel layer thought. A three-layer design isn’t there to be impressive. It’s there so that discovering one layer was never real on one platform is a bad week rather than an incident.
The fix is a downgrade and I wrote it down as one. Port-gate tcp:22 instead: blanket-deny it, re-open it to any host once an env grants at least one endpoint on 22, and let the ssh-scope hook decide which hosts are actually reachable. Weaker than Linux, genuinely, and ungranted projects keep a real blanket deny, worth something on its own. I corrected the README and the model-facing rule docs rather than leave a claim in there I couldn’t back.
But the fix I actually care about is a render-profile subcommand and a suite group that renders the profile and pipes it through sandbox-exec -f. Not “does it enforce.” Just “does it parse.” A few lines of test that would have caught this on day one, and whose absence is the entire reason it shipped.
The one that failed closed
I should include the bug that went the other way, because it’s instructive about how much easier that direction is, and because it was mine.
I reshaped a regex into a top-level alternation and dropped the parens around it. Every mutator check in the hook concatenates that regex as a suffix onto a prefix that itself contains alternation:
# the suffix, as I reshaped it
_claude_protected_re='A|B|C'
# the composed check
(>+|&>>?|\|&>)[ \t]*[^ \t|;&<]*$_claude_protected_re
# how precedence actually reads it
((>+|&>>?|\|&>)[ \t]*[^ \t|;&<]*A) | B | C
The prefix binds to the first branch only, so a bare mention of B or C anywhere in a command trips the deny with no redirect, no tee, no mutator of any kind. In practice cat, grep, head, less and diff on a hook file were all refused as mutations, with the message “command appears to mutate,” about as helpful as it sounds. Nine checks broken by one missing pair of parentheses.
I found it in about four minutes, because it happened to me, immediately, the first time I tried to read a file. Every fail-open bug in this post took weeks and usually a report from someone else. That asymmetry is the whole thing, honestly. A tool that’s too strict is annoying and self-reporting. A tool that’s too permissive is indistinguishable from a tool that’s working, which is precisely why the bypass suite matters more than any individual hook in it.
The part I’m still not sure about
The last chunk of work was softening a rule, and I go back and forth on it.
~/.claude/settings.json was blanket-denied for Edit and Write, correct and also miserable. Adding an MCP server, flipping a model preference, registering one of your own hooks: all of it meant dropping out of the session. So I replaced the blanket deny with a content validator. The hook computes the proposed post-edit content (for Write that’s just tool_input.content; for Edit it’s jq --rawfile plus a literal split/join on the old and new strings) and runs one jq filter asserting that every required entry survives. Matchers are compared by set inclusion, so you can broaden one but not narrow it. Anything that doesn’t touch the enforcement surface passes through.
I wrote 18 tests for it and I’m still not certain. A blanket deny has no bugs. A validator has a surface, and the surface is “did I enumerate everything that matters,” which is exactly the kind of question I’ve spent this whole post being wrong about. Project-level .claude/settings*.json stayed blanket-denied, because it carries the per-project agent socket and that’s a different threat model that deserves its own pass I haven’t done yet.
Ask me in six months, I guess.
Replace a claim with a check
25 commits, about 1,100 lines of a 7,000-line bash file, six weeks of evenings. Exactly one added something a user would call a feature, and that was uninstall. The rest was making a good tool do on a second platform what it already did correctly on the first one.
What I keep coming back to is that security tooling has a worse relationship with success messages than anything else I work on. A build that silently fails, you notice, because the site doesn’t change. A firewall that silently fails looks precisely like a firewall that’s working, and it will go on looking that way right up until the one day it matters. Which is the reason a tool like this lives or dies on its test suite rather than its hooks, and the reason I got to write a tidy post about 11 bugs instead of a much worse post about one incident. The suite went from 118 cases to 242 over the port, and every group I added is scar tissue from something in here.
Every fix was some version of the same move: replace a claim with a check. Don’t check what rm said, check whether the file is gone. Don’t check the file’s size, check its version string. Don’t check that the shim exists, check that it’s the one $PATH finds. Don’t check that the profile got written, check that sandbox-exec will load it. None of that is clever, and I’m not sure I learned a generalizable lesson beyond “be more suspicious of your own green ticks,” which everyone already knows and nobody, including me, actually does.
Usual caveats. This is one tool, on one platform, in bash, a language that will punish you for a space, so a decent chunk of this is bash-specific misery rather than anything deep. And I introduced roughly as many of these as I found.
But it runs on my Mac now, it’s the thing I actually use every day, and I trust it more for having taken it apart than I did when it just worked. If you maintain something whose entire job is to say no, it might be worth sitting down this week and asking what it does when it can’t tell. Ours said yes, once, on a platform nobody had tried it on. Now there’s a test for that 🙃