Claude Code Hooks for a Mac Admin's Repo: Guardrails the Agent Can't Skip

Your Mac admin repo is not a web app. It is a pile of bash scripts headed for Intune, a folder of .mobileconfig profiles, some Munki pkginfo, and a handful of Graph queries with tenant identifiers in them. Claude Code is genuinely good at working in a repo like that — it reads the whole tree, it writes the boring parts, it renames things consistently. The risk was never that it writes bad Swift. The risk is that a script with a hardcoded client secret, or a profile with one malformed key, lands in a repo whose whole purpose is to push to thousands of Macs.
The obvious first answer is permission rules, and they are worth having. But they are a static list matched against a command string, and Anthropic's own permissions reference is blunt about how far that gets you: a deny rule of Bash(rm *) does not match /bin/rm -rf build/, and it does not match bash -c 'rm -rf build/'. A permission rule also cannot look inside a file. It can tell you that Edit(secrets/**) is off limits; it cannot tell you that the file about to be written to an allowed path contains an API key. For that you need something that runs actual code against the actual payload. That is what hooks are.
Two kinds of hook, and you want both
If you already run a git pre-commit hook, you have half of this. Git's own documentation is clear on what that gives you: the hook runs before the commit message is obtained, a non-zero exit aborts the commit, and it can be bypassed with --no-verify. It also lives in .git/hooks, which is not cloned — so unless you point core.hooksPath at a committed directory, your teammate's clone has no guardrails at all.
Claude Code hooks sit one layer earlier and in a different place. They fire on the agent's tool calls, not on your commits, so a blocked write never becomes a file at all. And they are configured in .claude/settings.json, which is a normal file in the repo that you commit and review like any other. The distinction that matters for a fleet team: git hooks protect your history, Claude Code hooks protect the working tree, and only one of the two arrives with the clone.
The three events worth wiring first
There are a lot of hook events now — session lifecycle, compaction, subagents, model switches, file watches. For a scripts repo, three carry almost all the value.
PreToolUse runs before a tool call executes and can block it. Its matcher is a tool name, so Edit|Write catches file changes and Bash catches shell commands. This is where you stop the agent touching a path it has no business in.
PostToolUse runs after a tool call succeeds. It cannot un-write the file, but it sees the finished result on disk, which is exactly what a linter or a secret scanner wants. Its output goes back to Claude, so a failure reads as a correction rather than a wall.
SessionStart runs when a session begins or resumes, and its plain-text stdout is added to Claude's context. That is a cheap way to inject the things you would otherwise retype every morning — which branch is production, which directory is generated, which profile is signed.
Every hook gets a JSON object on stdin with session_id, cwd, hook_event_name and, for tool events, tool_name and tool_input. Your script parses that and decides.
Exit codes are the whole API
This is the part people get wrong, and it is worth stating plainly because it is the entire contract.
Exit 0 means success. If stdout is valid JSON, Claude Code honors the decision in it; on SessionStart and UserPromptSubmit, plain-text stdout becomes context Claude can read.
Exit 2 is the blocking error, and it blocks regardless of what the JSON says. The reason comes from your stderr, or from permissionDecisionReason if you emit the structured form. Write your explanation to stderr and Claude sees it.
Every other exit code — including 1 — is non-blocking. The action proceeds and the stderr shows up in the transcript as a failure. If you are used to pre-commit semantics, where any non-zero aborts the commit, this is the trap: a hook that exits 1 on failure looks like it is working and is in fact waving everything through. Use 2.
The structured alternative for PreToolUse is a JSON object with hookSpecificOutput.permissionDecision set to deny, allow or request, plus a permissionDecisionReason. It is more precise, and it lets a hook approve as well as refuse. Exit 2 is fine for a guard that only ever says no.
How the three guardrails compare
Permission deny rule | Claude Code PreToolUse hook | Git pre-commit hook | |
When it runs | Before the tool call | Before the tool call | At git commit |
What it can see | The command or path pattern you wrote | Tool name, file path and the full tool input | Every staged file |
Can the agent get past it | No - deny beats everything | No - exit 2 beats allow rules | Not applicable; it runs on your commits |
Arrives with a clone | Yes, in .claude/settings.json | Yes, in .claude/settings.json | No, unless core.hooksPath points at a committed directory |
Best used for | Whole categories you never want touched | Content checks on what is about to be written | The last check before history |
The precedence between them is documented and worth internalizing: a matching deny rule blocks a call no matter what a hook returns, and a hook that exits 2 stops a call even when an allow rule would have let it through. The blocking layer always wins, in both directions. That means you can put Bash on the allow list to stop the prompting, then use a PreToolUse hook to reject the specific commands you actually care about — which, for most admin repos, is a much shorter list than the one you would have to write as allow rules.
A check that actually blocks
Two files. First, the wiring in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/guard-paths.sh",
"timeout": 15 } ] }
],
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-file.sh",
"timeout": 30 } ] }
]
}
}
${CLAUDE_PROJECT_DIR} resolves to the project root the session started in and stays put even if Claude moves into a worktree, so hook paths keep working. Then the PostToolUse script:
#!/bin/bash
# .claude/hooks/check-file.sh — runs after every Edit or Write.
payload=$(cat)
file=$(printf '%s' "$payload" | /usr/bin/python3 -c \
'import json,sys; print(json.load(sys.stdin).get("tool_input", {}).get("file_path", ""))')
[ -f "$file" ] || exit 0
# Secrets. gitleaks scans a single path with the `dir` command and
# exits non-zero when it finds something.
if command -v gitleaks >/dev/null 2>&1; then
if ! gitleaks dir "$file" --redact >/dev/null 2>&1; then
echo "gitleaks flagged a secret in $file. Read it from the keychain at runtime instead." >&2
exit 2
fi
fi
# Profiles and plists. plutil -lint exits 0 on valid syntax, 1 on a parse error.
case "$file" in
*.mobileconfig|*.plist)
if ! /usr/bin/plutil -lint "$file" >/dev/null 2>&1; then
echo "$file is not valid property list syntax. Fix it before it reaches the MDM." >&2
exit 2
fi
;;
esac
exit 0
Two notes on that. gitleaks installs with brew install gitleaks; if you inherited a script using gitleaks protect or gitleaks detect, both were deprecated in v8.19.0 in favor of git, dir and stdin. And python3 on macOS comes from the Command Line Tools rather than the base OS — if git already works in your terminal, you have it.
For shell scripts, add a ShellCheck arm to the same case block rather than building a second hook. The review order I laid out in Reviewing Shell Scripts with AI Before They Hit 10,000 Macs still holds — the hook just makes the first layer of it automatic instead of remembered.
What changes at fleet scale
On one laptop this is a convenience. Across a team maintaining fleet automation, it is the difference between a standard and a suggestion. CLAUDE.md tells the model what you would like; the permissions reference says so outright — instructions in your prompt or CLAUDE.md shape what Claude tries to do, but they do not change what Claude Code allows. Hooks are enforcement, they are committed with the repo, and every engineer who clones it gets them without doing anything. If your organization wants them mandatory rather than merely present, the settings reference has allowManagedHooksOnly, which restricts hooks to the ones your org deploys and blocks user, project and plugin hooks.
The limits worth knowing before you trust this
Hooks execute with your user's credentials, so a malicious hook in a repo is a real problem — which is why project hooks require workspace trust before they run. Review .claude/settings.json in a pull request the way you would review a script, because it is one.
Command hooks default to a 600-second timeout, which is far longer than any lint should need; set timeout explicitly. Hooks run without a controlling terminal, so a script that expects to prompt will hang instead. And none of this constrains a human — git commit --no-verify still exists, and so does disableAllHooks. Hooks make the agent's path narrow. They do not make you honest.
The practical takeaway
Do all three, in this order. Put a PostToolUse hook on Edit|Write that runs gitleaks and plutil -lint and exits 2 on failure — that is twenty lines and it catches the two mistakes that actually cost you a morning. Add a PreToolUse path guard for the directories the agent should never touch, since a deny rule cannot reason about content and this can. Keep your git pre-commit hook as the backstop for the commits you make by hand, and point core.hooksPath at a committed directory so your teammates get it too.
The one thing not to do is write the hook that exits 1. It will look green for months.



Comments