Exit 0 means allow
A shell from 2006, a sandbox profile that never parsed, and an adversarial test suite doing all of the talking.
The first line of our installer died on my Mac with install: unknown group root. Root’s group on a Mac is called wheel and there is no group called root at all, so install -g root has nothing to do but quit. It turned out to be the friendliest bug of the entire project, because it stopped.
Nearly everything else I found over the next couple months kept right on going, and most of it printed a green checkmark on the way out.
The tool is called cadence-claude and it lets you run Claude Code in --dangerously-skip-permissions mode, the one where it stops asking before it does things, without handing over your whole machine. At a high level it’s three separate layers, and all three have to say yes before anything happens.
The first layer reads the command before it runs. If Claude tries to ssh somewhere you haven’t approved for this specific project, it’s refused, and the same goes for reading ~/.ssh, your AWS credentials, your keychain, your browser profile, your shell history. The second layer is the operating system, which drops the connection somewhere no amount of shell quoting can reach. (On Linux that’s cgroups and nftables, two things I could not have told you the first thing about at the start of this year.) The third layer is the credentials, where the ssh key you’d need to log into that server isn’t even loaded unless you’ve granted it for the project you’re sitting in.
That composition is the good idea. The first layer is defeatable in principle, since it’s reading shell commands and shell is a language with roughly infinite ways to say the same thing. But the second layer doesn’t care what your quoting looked like, and the third doesn’t care whether you reached the host at all. You have to beat all three. They don’t break for the same reasons.
The build quality around it caught me off guard. The tool installs itself owned by root and locked (chattr +i on Linux, chflags uchg on macOS) so the thing enforcing the rules can’t be edited by the thing it’s enforcing them on. If you ctrl+c halfway through an install it re-locks everything on the way out instead of leaving a writable hole behind. There’s a verify command that walks every file it cares about and checks each one is still root-owned, locked and registered. There’s a hook that runs at the start of every session whose only job is to put the tool’s own settings back if something stripped them. Every generated file carries a version string so an upgrade can tell when it’s looking at an old one.
And it comes with a bypass suite, 118 adversarial cases written by somebody actively trying to defeat their own work. Quoting the command name so a naive match misses it, hiding it inside bash -c, smuggling it through GIT_SSH_COMMAND, and a bunch of other tricks I would never have thought to try. That suite is the reason this post exists at all.
A coworker of mine built every bit of that on Linux. Then it came to me to make it work on a Mac. My whole job is the front end, so my kernel knowledge is roughly “there is one,” and I looked at a tool that already worked and estimated a week. How different could it be.
91 of 118
I ran the bypass suite on my Mac and it reported 91 failures out of 118.
It looks worse than it is. On Linux, where the thing had run every day for months, it was green and it was correct. 91 meant this was a platform the code had never once executed on, and the suite noticed inside of 30 seconds. Most internal tools I’ve worked with would have let me release the port and find out some other way, on somebody else’s machine, later.
Most of the damage came from four constructions the hooks leaned on that the Mac’s shell has never heard of. macOS still comes with bash 3.2, which came out in 2006 and stayed there because bash 4 went GPLv3 and Apple’s lawyers said no. I know, I know, brew install bash. Sure, on my machine. The hooks run against whatever #!/bin/bash resolves to on a machine where nobody did that, which is most of them.
# lowercase a variable
${var,,}
# expand an array that might be empty
"${empty_array[@]}"
# basically a dictionary
local -A seen
# swap a home path back to a ~
${dir/#$HOME/\~}
Ordinary bugs, every one of them, the kind you’d fix without thinking twice. The direction they broke in matters.
The lowercasing one aborts the function that pulls hostnames out of a command, so the code deciding “is this an ssh command” mostly decided no. The empty array one trips the script’s strict mode and skips the entire loop that looks inside other commands, which is the loop that catches $(ssh …), backticked ssh, bash -c, nohup and timeout. The dictionary one isn’t a runtime error at all, it’s a syntax error, so the hook that scans for secrets gave up before it had read a single character. And the ~ swap never matched anything, which meant cat ~/.ssh/id_rsa was simply allowed.
Not one of them crashed the hook. A helper gave up, the script carried on, reached the bottom and exited 0. In the hook protocol, exit 0 means allow.
The best one wasn’t even bash. One of the hooks used \b in a sed pattern to catch commands wrapped in sudo, the word boundary escape, the same one you’d write in any regex. It turns out to be a GNU extension instead of something every sed has, and BSD sed, which is the one on your Mac, doesn’t error on it and doesn’t warn either. It matches nothing at all, so every sudo-wrapped bypass in the suite walked straight past it. I rewrote the pattern in syntax both versions understand. I can’t help but wonder how many other regexes in how many other scripts are silently matching nothing on somebody’s Mac right now.
There was also a genuinely funny one where the tests were wrong too. The harness builds a fake project in a temp directory, and on macOS /var/folders/... is a symlink to /private/var/folders/..., and the hook resolves symlinks before working out which project it’s in. So the test and the hook disagreed about where they were standing, and every case that was supposed to be allowed got denied instead. That one only surfaced after I’d fixed the real bugs, which meant my score went down for a while as things got better.
The suite passed 209 of 209 by the time the bash work was done, with no change in behavior on a newer shell. It had grown to 209 because every fix came with cases for the thing I’d just found.
Everything succeeded
I bumped a version, re-ran ./install.sh over my existing install, and watched bash print “Permission denied” on every single hook write, followed by ✓ installed (v0.5.0). On disk: still v0.4.0. 🤔
Every step on that path is individually reasonable, which is why it’s worth walking through.
- The version check says the hook is stale, so the writer runs.
- We unlock the existing file.
- The file is still owned by root from the previous install.
- The writer is a plain
cat > targetrunning as me instead of as root, so it can’t write to a root-owned file. Permission denied. - The check afterward asks whether the file is empty. It is not empty. It’s full of the old version.
- Fixing the ownership and re-locking both succeed, because they’re operating on a file that already looks exactly like that.
- Print success.
Every guard on that path was checking something true and not one of them was checking the thing I cared about. It had never fired before because my version bump was the first time anybody had run the upgrade path over an existing install, which is its own small lesson about which code paths get exercised by normal use.
The fix was to delete the old file before writing the new one, then reread the version string out of whatever actually landed on disk, because that string is the only real evidence that new content got there.
The uninstall command I wrote later had the same disease from the other end. Its remove helper asked rm whether it had worked, in the shape if ! sudo rm -f "$1" 2>/dev/null. On bash 3.2 that combination swallows the failure even while rm is printing “Operation not permitted” to my terminal. So the helper reported success, the caller printed “✓ removed,” and the cleanup that was supposed to re-lock everything on the way out saw a clean exit and skipped itself. The net result of a successful uninstall was hook files still sitting on disk with their locks taken off. Not deleted, and no longer protected.
I stopped asking rm whether it had worked and checked whether the file was still there. Whether the file is gone is the only outcome that matters, and that test doesn’t care what shell you’re running.
First past the post
The enforcement layer works differently on a Mac. There’s a small script sitting earlier on your $PATH than the real claude, and its whole job is to relaunch the real one inside a sandbox.
Which works exactly as long as it really is first. If Claude Code’s own installer has put ~/.local/bin ahead of it, then every claude you type runs unwrapped with no sandbox at all, and nothing anywhere says so. which claude returns a real path, claude --version works fine, and verify came back green because it was checking that the wrapper script existed, which it did.
So I added a check that walks $PATH one entry at a time, resolves both the first claude it finds and the wrapper down to real paths, and fails if they aren’t the same file. And then, because verify had never actually failed on anybody before, its failure message was carrying two bugs nobody had ever seen. The color codes printed as literal escape text, and the here’s-how-to-fix-it hint pointed at a file that has never existed on any machine. The error path had never once run and I’d have bet money it worked, which is the same bet I talked myself out of making a couple summers ago.
The last twist there is my favorite part of the whole project. Our install instructions told people to add the wrapper’s $PATH line to the top of ~/.zshrc. Claude Code’s own installer appends its export PATH="$HOME/.local/bin:$PATH" near the bottom. So you follow our instructions exactly, open a new terminal, and get overruled by the exact line we were trying to get in front of.
Any port in a storm
On Linux the network layer allows traffic by address and port together. This IP, on port 22, nothing else. The Mac version was written to mirror that, generating one rule per approved server.
; what we were generating, one line per approved server
(allow network-outbound (remote ip4 "10.1.2.3:22"))
; what macOS will actually accept, where the host has to be * or localhost
(allow network-outbound (remote ip "*:22"))
The macOS sandbox cannot filter traffic by address. You can restrict a port, you cannot restrict who’s on the other end of it. So the profile didn’t fail to enforce, it failed to parse, and because the wrapper launches Claude inside that profile, the moment a project had an approved server on it claude wouldn’t start in that directory at all. That’s how it eventually reached me, as a crash report.
It landed for me that the per-address version was never expressible on a Mac. It wasn’t broken by a refactor and it wasn’t a regression, it was never possible, there was no version of that file that was ever going to work, and the README had been describing it as a feature the whole time. Which is, idk, a bad look on a security tool. It stayed hidden only because a project with no profile on disk has nothing to fail to parse, so unapproved projects worked fine and the approved ones were the only ones that ever loaded the file.
This is where I think the architecture earns its keep. An entire enforcement layer was inert on that platform and the damage was still contained, because the first layer had 209 adversarial cases behind it by then and was doing exactly what it was built to do, refusing ssh to anything outside the project’s approved list no matter what the layer underneath it thought. A three-layer design isn’t there to be impressive. It’s there so that finding out one layer was never real on some platform is a bad week instead of an incident.
The fix is kind of a downgrade. You restrict the port instead. Block 22 outright, open it to any host once a project has approved at least one server, and let the first layer decide which hosts are actually reachable. That’s genuinely weaker than Linux, though projects with nothing approved still get a real blanket block, which is worth something on its own. I corrected the README instead of leaving a claim in there we couldn’t back up.
But the fix I actually care about is a render-profile command and a group of tests that generate the profile and then just try to load it. Not whether it enforces anything, only whether it loads. A few lines of test that would have caught this on the first day.
My turn
One of them went the other way and it was mine, which makes it worth putting next to all of the above.
I reshaped a pattern into a list of alternatives and dropped the parentheses from around it. Every check in that hook glues that pattern onto the end of another one that also has alternatives in it, and the same precedence trap you’d hit in JavaScript applies.
# my refactor
_claude_protected_re='A|B|C'
# the check it gets added to
(>+|&>>?|\|&>)[ \t]*[^ \t|;&<]*$_claude_protected_re
# how regex actually reads that
((>+|&>>?|\|&>)[ \t]*[^ \t|;&<]*A) | B | C
The | splits at the top level, so the “is this writing to a file” part only applied to the first branch, and a bare mention of B or C anywhere in a command tripped the deny with no redirect and no writing of any kind. In practice cat, grep, head, less and diff on a hook file were all refused as attempts to modify it, with the message “command appears to mutate,” which is about as clear as it sounds. Nine checks broken by one missing pair of parentheses.
I found it in a few minutes because it happened to me the moment I tried to read a file. Every fail-open bug in this post took weeks and usually a report from somebody else. That asymmetry is the whole thing, really. A tool that’s too strict gets in your way and tells you it’s there. A tool that’s too permissive is indistinguishable from a tool that’s working, which is 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. I’ve been going back and forth with myself about it ever since.
~/.claude/settings.json was blanket-denied for edit and write, which is correct and also miserable to live with. Adding an MCP server, changing a model preference, registering a hook of your own, all of it meant dropping out of the session, editing by hand and starting again. So I replaced the blanket deny with a check. The hook works out what the file would look like after the edit, then asserts that every entry the tool needs is still in there afterward. You can broaden a rule, you can’t narrow one, and anything that doesn’t touch enforcement passes straight through.
I wrote 18 tests for it and I’m still not sure. A blanket deny has no bugs, where a check has to be right about every case, and whether I listed all of them is exactly the kind of question I’ve spent this whole post being wrong about. Project-level settings stayed blanket-denied, because those carry more than preferences and deserve their own pass I haven’t done yet. I’ll know more in a few months.
Replace a claim with a check
25 commits, about 1100 lines of a 7000-line bash file, a couple months of testing. Exactly one of them added something a person 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.
Security tooling has a worse relationship with success messages than anything else I work on. I keep coming back to that. 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. It’ll go on looking that way right up until the one day it matters. Which is why a tool like this lives or dies on its test suite instead of its hooks, and why I got to write a tidy post about a dozen bugs instead of a much worse post about an incident. The suite went from 118 cases to 242 over the port and every group I added is downstream of something in here.
Every fix was a version of the same move, which is to 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 wrapper exists, check that it’s the one $PATH finds first. Don’t check that the profile got written, check that macOS will load it. Every one of those is dull as dishwater. It’s also the exact lesson in the .gitignore line that was correct and had never done anything, which I apparently needed to learn twice.
Of course this is one tool on one platform in bash, a persnickety language that will punish you for a space, so a decent chunk of it is bash-specific misery instead of anything deep. And I introduced roughly as many of these as I found, which I’d rather say out loud than have somebody work out from the commit log.
But it runs on my Mac now, it’s something I use every day, and I trust it more for having taken it apart than I did back when it just worked. The same thing keeps turning up in the instructions I write for Claude at work, where the lines that matter are the ones telling it to stop. If you maintain something whose entire job is to say no, it might be worth sitting down this week and asking what yours does when it can’t tell. Ours said yes, quietly, for a couple months, on a platform nobody had tried it on. There’s a test for that now.