A hook that defers any production-touching kubectl call:
`bash
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" production)'; then
jq -n --arg cmd "$COMMAND" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "defer",
permissionDecisionReason: ("Production cluster access requires manual approval. Command: " + $cmd)
}
}'
exit 0
fi
exit 0
`
In a headless run, defer blocks the agent and surfaces the request to whatever orchestrator started the session. In a typical CI pattern, that orchestrator is a small script that:
• Reads the deferred call payload.
• Posts it to a Slack channel or a policy API.
• Waits for an answer.
• Resumes the Claude session with claude --resume and either lets the call through, returns a denial reason, or modifies the command.
What this gets you: human-in-the-loop without parking the entire job. The agent does not idle. The runner does, but the cost of a paused runner is much smaller than the cost of letting an agent run a destructive kubectl unsupervised.
The PermissionDenied retry pattern
When an internal classifier denies a tool call, Claude Code emits a PermissionDenied event. The hook can return:
`json
{ "retry": true }
`
This signals the agent to try again. Combine it with a hint via additionalContext and you turn a hard failure into a soft retry with guidance.
Example: an Edit call gets denied because the path is outside the workspace. The hook detects the pattern and returns:
`bash
#!/bin/bash
INPUT=$(cat)
PATH_ATTEMPTED=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$PATH_ATTEMPTED" == /etc/ || "$PATH_ATTEMPTED" == /var/ ]]; then
jq -n '{
retry: true,
hookSpecificOutput: {
hookEventName: "PermissionDenied",
additionalContext: "System paths are blocked in CI. Stay inside the repository workspace under $GITHUB_WORKSPACE."
}
}'
exit 0
fi
exit 0
`
The result is one extra turn instead of a failed run. The additionalContext is the actual mechanism — it teaches the agent the constraint mid-flight without you having to put it in CLAUDE.md and hope it gets followed.
!Hook decision flow in CI
A working GitHub Actions example
Putting it together. A workflow that runs Claude Code with hook-controlled policy:
`yaml
name: Claude Code with Policy Hooks
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
jobs:
claude:
if: contains(github.event.comment.body, '@claude')
runs-on: ubuntu-latest
steps:
• uses: actions/checkout@v4
• name: Make hook scripts executable
run: chmod +x .claude/hooks/.sh
• uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--max-turns 15
--model claude-sonnet-4-6
--settings .claude/settings.json
`
The two important lines are chmod +x on the hook scripts (GitHub Actions does not preserve execute bits on every checkout) and --settings .claude/settings.json to point the run at the hook configuration.
Everything else is identical to a normal Claude Code Action workflow. The hooks do their work invisibly — your audit log shows exactly which tool calls the agent made and exactly which the policy denied or deferred.
Try It Yourself
A copy-paste setup you can drop into any repository this afternoon.
Create the hooks directory
`bash
mkdir -p .claude/hooks
`
Add a deny script
.claude/hooks/deny-destructive.sh:
`bash
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" /var/lib)'; then
jq -n --arg cmd "$COMMAND" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: ("Blocked: " + $cmd)
}
}'
fi
exit 0
`
`bash
chmod +x .claude/hooks/deny-destructive.sh
`
Add a defer script for production calls
.claude/hooks/defer-prod.sh:
`bash
#!/bin/bash
set -euo pipefail
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$COMMAND" --namespace=prod)'; then
jq -n --arg cmd "$COMMAND" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "defer",
permissionDecisionReason: ("Needs human approval: " + $cmd)
}
}'
fi
exit 0
`
`bash
chmod +x .claude/hooks/defer-prod.sh
`
Register both hooks
.claude/settings.json:
`json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": ".claude/hooks/deny-destructive.sh" },
{ "type": "command", "command": ".claude/hooks/defer-prod.sh" }
]
}
]
}
}
`
Test the deny path locally
Pipe a sample payload to the script and check the output:
`bash
echo '{"tool_input":{"command":"rm -rf /tmp/foo"}}' | .claude/hooks/deny-destructive.sh
`
You should see a JSON payload with "permissionDecision": "deny". If you do, the script is shaped correctly.
Wire the GitHub Actions workflow
.github/workflows/claude.yml:
`yaml
name: Claude Code
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
jobs:
claude:
if: contains(github.event.comment.body, '@claude')
runs-on: ubuntu-latest
steps:
• uses: actions/checkout@v4
• name: Make hooks executable
run: chmod +x .claude/hooks/.sh
• uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
claude_args: |
--max-turns 15
--settings .claude/settings.json
`
Commit, push, comment @claude on a PR. Watch the action logs. You will see the hook scripts invoked on every Bash call. The denies will appear with the reason text the agent saw.
!Try it yourself: hook setup in a CI run
What this does not solve
A hook only sees the tool call about to happen. It cannot reason about the intent of the agent's plan. If the agent asks "should I delete the staging database?" and the user says yes, no hook fires — the agent just calls the tool and the tool gets denied (or not). Policy still has to live somewhere upstream of the call, in CLAUDE.md, in the prompt, in the system prompt for the action.
Hooks also cannot stop a misconfigured allowlist. If --allowedTools "" is set, the hook still runs, but the agent could be doing anything inside the box. The combination — narrow allowlist plus deny/defer hooks — is stronger than either alone.
And defer` only makes sense if you have an orchestrator on the other side. In an interactive terminal, defer becomes ask. In headless CI without a resume mechanism, defer becomes a stuck job. The flow is only worth setting up if you actually have a place to send the deferred decision.
The shift
The pattern that emerges is this. Agents in CI used to be all-or-nothing — either you gave the agent broad permissions and crossed your fingers, or you constrained the allowlist so tightly that the agent could not do useful work.
Hooks split the difference. Permissions stay broad inside the box. The box itself is policed by scripts you wrote, that live in your repo, and that fire on every tool call. Decisions about what is safe move from the model to the pipeline.
This is not a new agent capability. It is the same hook system, used the way it was always going to need to be used in production. The teams that ship agentic CI/CD for real work are converging on it because the alternatives — broader allowlists, longer review queues, more brittle prompt engineering — do not survive contact with a real codebase.
The four phases of an agent in CI are: trigger, plan, act, review. The act phase is where most things go wrong, because that is where the agent leaves the safe text-only sandbox and starts touching the world. PreToolUse hooks are the seam between act and the world. Owning that seam is what makes agentic CI safe enough to ship.
Sources:
• Hooks reference - Claude Code Docs
• Claude Code GitHub Actions - Claude Code Docs
• anthropics/claude-code-action