Guardrail Hooks
The optional Claude Code hooks /saikit-workflows can install, block force-push, block .env edits, block direct push to main. Opt-in per project.
/saikit-workflows finishes with one optional question: do you want to install guardrail hooks? They're three Claude Code PreToolUse hooks that intercept Bash and Edit/Write calls and refuse the ones you've chosen to block.
They're opt-in per project. The kit never installs them silently.
//What They Are
A hook is a shell command Claude Code runs at a specific point in its lifecycle. PreToolUse hooks run before a tool call and can block it by exiting with code 2. The error message you write goes back to Claude, the model sees the rejection, learns what you didn't want, and adjusts.
The kit's three hooks all share the same shape: a small Python or Bash one-liner that inspects the tool input from stdin and exits 2 if it matches a forbidden pattern.
//Why Hooks, Not Prompts
You could ask Claude in your prompt to "never run git push --force" and it'll usually comply. But "usually" isn't good enough for the destructive operations. Hooks are deterministic, they don't depend on the model's judgment, they don't drift, and they survive prompt-injection from tool outputs.
The kit treats hooks as a hard guarantee for three actions where guesswork is unacceptable.
//The Three Hooks
Each shows up in the multi-select after the dev-cycle playbook is written. Pick zero, one, or all three.
Block git push --force
| Field | Value |
| --- | --- |
| Event | PreToolUse |
| Matcher | Bash |
| Behavior | Exits 2 if the command contains git push --force or git push -f. |
Force-pushing rewrites shared history. The hook applies to every remote, not just main, it doesn't matter if you're pushing to a personal feature branch, the hook still fires. If you genuinely need to force-push, you do it yourself, in a terminal, where the consequences are obvious.
Block .env Edits
| Field | Value |
| --- | --- |
| Event | PreToolUse |
| Matcher | Edit\|Write |
| Behavior | Exits 2 if the target path is .env, .env.local, .env.production, etc. Allows .env.example. |
Secrets belong in keychains, secret managers, and CI variable stores, not in committed files Claude has rewritten. The hook prevents the accidental case where Claude reads a fragment of your config and "tidies" it back into .env.
.env.example is allowed precisely because that's where you do want Claude to land changes (e.g. adding a new required variable to the example file).
Block Direct Push to main
| Field | Value |
| --- | --- |
| Event | PreToolUse |
| Matcher | Bash |
| Behavior | Exits 2 if the command pushes to main (or master) directly without going through a PR. |
Forces every change through a pull request. Pairs with the /ship cross-tool command, that command opens a PR; this hook makes sure the AI doesn't get clever and skip it. If your team uses a different default branch name, edit the hook after install.
//How the Kit Installs Them
The hook ask only fires if Claude Code is one of your selected targets. Cursor and Codex have incompatible extensibility models, the kit skips rather than misconfigures.
After you confirm which hooks to install, the kit reads .claude/settings.json (creating it if needed) and merges new hook entries under hooks.PreToolUse. Existing hooks are preserved.
Resulting .claude/settings.json snippet:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,sys; d=json.load(sys.stdin); c=d.get('tool_input',{}).get('command',''); sys.exit(2 if 'git push --force' in c or 'git push -f' in c else 0)\""
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,sys; d=json.load(sys.stdin); p=d.get('tool_input',{}).get('file_path',''); sys.exit(2 if (p.endswith('.env') or '.env.local' in p or '.env.production' in p) and not p.endswith('.env.example') else 0)\""
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "python3 -c \"import json,sys,re; d=json.load(sys.stdin); c=d.get('tool_input',{}).get('command',''); sys.exit(2 if re.search(r'git push (origin )?(main|master)\\b', c) else 0)\""
}
]
}
]
}
}
The kit always appends; it never removes. If you want to roll back, edit the file by hand.
//Why Claude Code Only
Hooks are a Claude Code surface today. Cursor's extensibility happens through MDC rules and its own commands; Codex extensibility happens through prompts and skills. Translating a PreToolUse shell hook into either of those would require either:
- A different mechanism (e.g. a Cursor MDC rule that just asks the AI not to do the thing, which is the prompt-not-hook problem the guardrails exist to solve), or
- A wrapper script around the AI CLI's binary.
Neither gives you the same guarantee. So the kit skips the hooks step on Cursor- and Codex-only setups rather than ship a misleading approximation.
//Managing By Hand
.claude/settings.json is a normal config file, open and edit. The hook configurations are well-documented in the Claude Code Hooks blog post.
To remove one of the kit's hooks:
- Open
.claude/settings.json. - Find the entry under
hooks.PreToolUsewhosecommandfield includes the relevant pattern (git push --force,.env, orgit push origin main). - Remove that entry from the array.
Re-running /saikit-workflows after removing a hook will offer to re-install it. The kit treats hook configs as additive, it never silently re-adds something you've explicitly removed mid-flow.