Hooks and permissions: putting a boundary around an agent
AI · Aug 2026 · 19 min read
A prompt asking an agent not to do something is a request. A hook that returns deny is a boundary. Blocking, rewriting, auditing, and where each one belongs.
Everything in this tutorial exists because of one distinction. Instructions in a system prompt are input to a model that weighs them against everything else in its context — including file contents, tool output, and anything an attacker managed to get in front of it. A hook is code that runs before the tool does.
So: put preferences in the prompt, and put rules in a hook.
Step 1: block something
A hook is a callback registered against an event. PreToolUse fires before a tool runs and can allow, deny, or rewrite the call. The matcher is a regex over tool names, so the callback only fires for the tools you care about.
import asyncio
from claude_agent_sdk import ClaudeSDKClient, ClaudeAgentOptions, HookMatcher
async def protect_secrets(input_data, tool_use_id, context):
path = input_data['tool_input'].get('file_path', '')
if path.split('/')[-1] in ('.env', 'credentials.json'):
return {
'hookSpecificOutput': {
'hookEventName': input_data['hook_event_name'],
'permissionDecision': 'deny',
'permissionDecisionReason': 'Secret files are off limits.',
}
}
return {} # empty dict = allow, unchanged
options = ClaudeAgentOptions(
hooks={
'PreToolUse': [
HookMatcher(matcher='Write|Edit', hooks=[protect_secrets]),
]
}
)
The reason string is not decoration — it goes back to the model, which then knows why it was stopped and can choose a different approach. A denial with no explanation produces an agent that retries the same thing.
Step 2: rewrite instead of refusing
Denial is blunt. Often the intent is fine and only the target is wrong, and updatedInput lets you fix the call rather than reject it — sandboxing writes, forcing a flag, pinning an environment.
async def sandbox_writes(input_data, tool_use_id, context):
if input_data['hook_event_name'] != 'PreToolUse':
return {}
updated = dict(input_data['tool_input'])
updated['file_path'] = '/sandbox' + updated['file_path']
return {
'hookSpecificOutput': {
'hookEventName': input_data['hook_event_name'],
'permissionDecision': 'allow',
'updatedInput': updated,
}
}
Rewriting is not containment: String-prefixing a path is a redirect, not a jail — ../ and symlinks walk straight back out. If the sandbox is a security boundary rather than a convenience, resolve the path to canonical form and verify it is still under your root before allowing the call.
Step 3: audit everything
PostToolUse fires after the tool returns and is where the audit trail belongs. For pure side effects there is an async form that lets the agent continue without waiting on your logging.
async def audit(input_data, tool_use_id, context):
asyncio.create_task(ship_to_log_service({
'session': input_data['session_id'],
'tool': input_data['tool_name'],
'input': input_data['tool_input'],
'tool_use_id': tool_use_id, # correlates Pre- and PostToolUse
}))
return {'async_': True, 'asyncTimeout': 30000}
# 'async_' with the trailing underscore - 'async' is a keyword in Python.
# async hooks cannot block or modify: the agent has already moved on.
tool_use_id is the join key between the PreToolUse and PostToolUse events for the same call. Log it on both and you can reconstruct what was requested against what actually happened — which is the question you will be asked after an incident.
Step 4: know which events you actually have
The hook catalogue differs between the two SDKs, and building around an event the Python SDK does not fire is a frustrating way to spend an afternoon. SessionStart and SessionEnd, in particular, are TypeScript-only.
hook events in the Python SDK Event Fires when Typical use PreToolUse Before a tool runs Block, rewrite, gate PostToolUse After a tool returns Audit, append context PostToolUseFailure A tool errored Alerting UserPromptSubmit A prompt is submitted Inject context PermissionRequest A decision is needed Custom permission flow SubagentStart / Stop Delegation boundaries Track fan-out PreCompact Before compaction Archive the transcript Stop The agent stops Persist state
Hooks fire inside subagents: PreToolUse runs for subagent tool calls too, and agent_id / agent_type are populated when it does. One registration covers the whole tree — you do not re-register per subagent, and you should not assume a rule only applies to the main loop.
Step 5: the other lever — can_use_tool
Hooks are pattern-matched by tool name. When a decision needs real context — who the user is, what plan they are on, whether an approval exists — can_use_tool gives you one callback for every tool call.
from claude_agent_sdk.types import (
PermissionResultAllow, PermissionResultDeny, ToolPermissionContext,
)
async def gate(tool_name: str, input_data: dict, context: ToolPermissionContext):
if tool_name == 'Bash' and not user.can_run_commands:
return PermissionResultDeny(
message='Your plan does not include shell access.',
interrupt=True, # stop the run, do not just skip
)
return PermissionResultAllow(updated_input=input_data)
options = ClaudeAgentOptions(
can_use_tool=gate,
permission_mode='default',
)
interrupt=True is the difference between a refused call and an ended run. Use it when continuing would be meaningless — the agent will otherwise carry on, work around the gap, and hand you a confidently incomplete result.
Step 6: how the decisions combine
Multiple hooks, permission rules, and modes can all have an opinion about the same call. The resolution order is fixed and it is worth committing to memory, because it is what makes layering safe.
deny > defer > ask > allow
# any hook returning deny blocks the call, regardless of what
# every other hook said. safety rules compose - you can add a
# stricter hook without auditing the permissive ones first.
# 'defer' ends the query so the call can be resumed later -
# the shape you want for genuine human approval, rather than
# blocking an event loop on someone reading Slack.
A layered configuration
Put together, the pieces stack into something you can defend: the mode sets the default posture, allowed_tools enumerates the pre-approved surface, hooks encode the non-negotiable rules, and can_use_tool handles anything needing outside context.
options = ClaudeAgentOptions(
permission_mode='dontAsk', # nobody is there to answer
allowed_tools=['Read', 'Grep', 'Glob', 'Edit'],
disallowed_tools=['Bash', 'WebFetch'],
max_turns=40,
max_budget_usd=3.00,
cwd=checkout_path, # scope the filesystem
hooks={
'PreToolUse': [HookMatcher(matcher='Write|Edit',
hooks=[protect_secrets])],
'PostToolUse': [HookMatcher(matcher='.*', hooks=[audit])],
},
)
Note that dontAsk is the strict choice here, not the permissive one: it denies what is not pre-approved instead of prompting a human who does not exist. The reflex to reach for bypassPermissions in CI has it exactly backwards — an unattended run is the case for more constraint, because there is nobody to catch the surprise.
Where the real risk is: The threat is rarely a model deciding to do damage unprompted. It is a model reading a file, a web page, or a tool result that contains text engineered to look like an instruction. Every boundary here holds regardless of what the model was persuaded of — which is exactly why it belongs in code rather than in the prompt.
References
Takeaways
Preferences go in the prompt; rules go in a hook — only one of them survives a prompt injection. PreToolUse can deny or rewrite with updatedInput; PostToolUse is where the audit trail belongs. SessionStart and SessionEnd are TypeScript-only — check the Python catalogue before designing around an event. deny beats defer beats ask beats allow, so a stricter hook can be added without auditing the permissive ones.
All notes · Shehzad Aslam