The build phase used hooks as guardrails, allowing or blocking actions with no human involved (Stage 3: Build). A hook can also ask, pausing the action until a specific person approves, which is what release gating needs.
The play sits in Stage 5: Deploy because the release gate is the clearest case, but hooks are not deploy-specific: they run wherever Claude acts. For example, hooks can block edits to migrations and infra without a change ticket during Stage 3: Build, and stop the agent editing test files during a fix task in Stage 4: Test.
Getting started
- Prerequisites: None.
- Infrastructure: A written list of the approvals the change process requires.
How to execute it
- Engineering leadership, with change management and compliance, lists the human approval gates that must survive, such as change management sign-off, release authorization, and edits to protected paths.
- The platform engineer expresses each gate as a hook, a script that runs before Claude acts that can allow, ask, or block.
- Team hooks go in
.claude/settings.jsonin Git, and non-negotiable hooks go in managed settings owned by the platform or IT admin, where individual engineers cannot switch them off. - A block should explain itself, so when a hook stops an action, the reason and the route to approval appear in Claude's output.
What it looks like
A standalone example in the project's .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/production-gate.sh" }
]
}
]
}
}And the gate itself (.claude/hooks/production-gate.sh):
#!/bin/bash
# Production deploys require a named release authorization
cmd=$(jq -r '.tool_input.command' < /dev/stdin)
if [[ "$cmd" == *"deploy"* && "$cmd" == *"production"* ]]; then
if [ -z "$RELEASE_APPROVAL" ]; then
echo "Production deploys need a release authorization." >&2
exit 2 # exit 2 blocks the action; the message goes to Claude
fi
fi
exit 0Governance considerations
Hooks are the approval gates. The gate condition is enforced every time, for everyone. Allow and block decisions are logged with a timestamp. The gate also defines what counts as approval, whether that's an approved change ticket or the release manager's sign-off.
Managed settings for a regulated enterprise
Managed settings for a regulated enterprise, deployed by the platform team via mobile device management (MDM) or the admin console. Engineers cannot edit or override any of the settings therein. See below:
{
"permissions": {
"deny": [
"Read(.env*)", "Read(./secrets/**)",
"WebFetch", "Bash(curl *)", "Bash(wget *)"
],
"allow": [
"Bash(git *)", "Bash(make build)",
"Bash(make test)", "Bash(make lint)"
],
"disableBypassPermissionsMode": "disable"
},
"allowManagedPermissionRulesOnly": true,
"sandbox": {
"enabled": true,
"failIfUnavailable": true,
"allowUnsandboxedCommands": false,
"network": { "allowedDomains": ["git.internal.example.com", "registry.npmjs.org"] },
"credentials": {
"files": [
{ "path": "~/.ssh", "mode": "deny" },
{ "path": "~/.aws/credentials", "mode": "deny" }
],
"envVars": [ { "name": "GITHUB_TOKEN", "mode": "deny" } ]
}
},
"allowManagedHooksOnly": true,
"disableSideloadFlags": true,
"allowManagedMcpServersOnly": true,
"strictKnownMarketplaces": [
{ "source": "github", "repo": "example-corp/approved-plugins" }
],
"requiredMinimumVersion": "2.1.193"
}What the settings do, in control terms:
permissions.denykeeps secrets out of the agent's context and blocks arbitrary network egress through tools.permissions.allowpre-approves the safe inner loop so the deny list doesn't turn into prompt fatigue.disableBypassPermissionsModeplusallowManagedPermissionRulesOnlymeans no engineer, project file, or command-line flag can widen the rules.sandboxcovers what permissions cannot. A tool-level deny on WebFetch doesn't stop a shell command reaching the network, whereas the OS-level domain allowlist blocks egress outright, so the two enforce one objective at different layers.failIfUnavailableandallowUnsandboxedCommandsturn the sandbox into a precondition, meaning Claude Code refuses to start when the sandbox cannot initialize and a command that fails inside the sandbox cannot be retried outside it.credentialshandles a case the deny rules miss.permissions.denygoverns Claude's file tools, but a sandboxed shell command could still read~/.sshor~/.aws/credentialsby default. This block denies those reads and strips the listed secrets from the environment of sandboxed commands.allowManagedHooksOnlymeans only hooks defined in managed settings run; hooks in user, project, and local settings are blocked, including the standalone.claude/settings.jsonexample above. To keep this play's approval gate enforced, define it in the managed file's ownhooksblock.disableSideloadFlagsandstrictKnownMarketplacesmean that any skill, agent, hook, or MCP server on an engineer's machine came through the organization's approved plugin marketplace and not from a home directory. The marketplace allowlist controls what can be installed, and the flags that would sideload a plugin, agent, or MCP config for a single run are rejected at startup.allowManagedMcpServersOnlymakes the agent's tool surface an allowlist owned by the platform team.requiredMinimumVersionrefuses to start on a version below the approved floor, so the controls are enforced by a build the organization has actually assessed.
Treat the example as a starting point to customize to your own environment. Each deny rule removes some capability, and the right balance depends on the data classification of the repo. The settings reference(opens in new tab) documents all keys, including the managed-only ones.
How to measure it
For the hooks themselves:
- Leading indicator: Time spent waiting on each approval gate. Every hook decision is written to the OpenTelemetry export with a timestamp and an allow or block verdict, so the wait is visible per gate.
- Lagging indicator: Gate violations reaching production before and after hooks, from the incident tracker.