Exit 0 means allow
Porting a security tool to the Mac meant a shell from 2006 and a sandbox profile that never parsed, with an adversarial test suite doing all of the talking.
The first line of our installer died on my Mac with install: unknown group root. On a Mac, root’s group is called wheel and there’s 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 whole project because it stopped.
Nearly everything else I found over the next six weeks kept right on going. Most of it printed a green checkmark on the way out.
The tool is one we built in-house. 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. The same goes for reading ~/.ssh, your AWS credentials, your keychain, your browser profile and 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 couldn’t have told you the first thing about at the start of this year.) The third layer is the credentials. 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.
I think 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 and 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 relocks 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 that 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. The cases quote the command name so a naive match misses it, hide it inside bash -c, smuggle it through GIT_SSH_COMMAND, and try a bunch of other tricks I’d never have thought of. This post exists at all because of that suite.
A coworker of mine built every bit of that on Linux. Then it fell 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.” 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 it was already in daily use, it was green and it was correct. The 91 meant this was a platform the code had never once run 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 later, on somebody else’s machine.
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. It 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/\~}
They’re all ordinary bugs, the kind you’d fix without thinking twice. But 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 whole loop that looks inside other commands. That’s 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 and 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 was meant to catch commands wrapped in sudo with a sed pattern using \b, the same word boundary escape you’d write in any regex. It turns out \b is a GNU extension, not something every sed has.
The BSD sed 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. 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. 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 and reran ./install.sh over my existing install. Then I watched bash print “Permission denied” on every single hook write, followed by ✓ installed (v0.5.0). On disk it was still v0.4.0 🤔
Every step on that path is reasonable on its own, 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 relocking 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 come up before because my version bump was the first time anybody had run the upgrade path over an existing install. That’s its own small lesson about which code paths normal use actually exercises.
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. 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 relock 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 started checking whether the file was still there. Whether it’s gone is the only outcome that matters. The check 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. 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. Nothing anywhere says so.
which claude returns a real path and claude --version works fine. 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 because verify had never actually failed on anybody before, its failure message had two bugs nobody had ever seen. The color codes printed as literal escape text. 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 last summer.
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 and generated 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 can’t filter traffic by address. You can restrict a port but you can’t 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, claude wouldn’t start at all in any project with an approved server. That’s how it eventually reached me, as a crash report.
Then it sank in 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, and there was no version of that file that was ever going to work.
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. By then the first layer had 209 adversarial cases behind it and was doing exactly what it was built to do. It refused 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. Projects with nothing approved do still get a real blanket block, and that’s worth something on its own. I corrected the README instead of leaving a claim in there we couldn’t back up.
But I care more about the other fix, a render-profile command and a group of tests that generate the profile and then just try to load it. They don’t check whether it enforces anything, only whether it loads. A few lines of test 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. 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. 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. The message said “command appears to mutate,” which is about as clear as it sounds. One missing pair of parentheses broke nine checks.
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. But 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 and asserts that every entry the tool needs is still in there. You can broaden a rule but you can’t narrow one. 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, but a check has to be right about every case. Did I list all of them? That’s 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, which I haven’t done yet. I’ll know more in a few months.
Replace a claim with a check
It came to 25 commits, about 1100 lines of a 7000-line bash file and 6 weeks of testing. Exactly one of those commits 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 gets noticed 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.
That’s why a tool like this lives or dies on its test suite instead of its hooks. It’s also 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 fussy language that will punish you for a stray space. So a decent chunk of it comes down to bash-specific gotchas 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 and I use it every day. 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. Our tool quietly said yes whenever it couldn’t tell, on a platform nobody had tried it on. There’s a test for that now. 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.