CertSafari
    Anthropic Claude Certified Developer – Foundations (CCDV-F)· Lessons

    Domain 7 · Lesson 21/25

    PreToolUse guardrails: exit codes and permission decisions

    Claude Hooks

    10 min read
    2.03% of exam
    5 sources
    Published 12 Oct 2026
    Docs as of 30 Sep 2026

    What you will be able to do

    • Read the tool name and arguments a PreToolUse hook receives
    • Block a tool call with exit code 2 and know what exit 0 and other codes do
    • Return a structured deny, ask or allow decision, and know the limits of allow
    • Write the same guardrail as an Agent SDK callback

    1.What a PreToolUse hook receives

    A PreToolUse hook runs before a tool call executes, and it is the hook event that can cancel the call. That is why destructive-action guardrails live there. Each time it fires, the handler receives the call as JSON. Shared fields such as session_id, cwd and hook_event_name come first, then tool_name, then tool_input, which holds the arguments Claude passed. For Bash the shell command is in tool_input.command. For file tools the target path is in tool_input.file_path.

    The JSON a PreToolUse hook receives for a Bash calljson
    {
      "session_id": "abc123",
      "cwd": "/Users/sarah/myproject",
      "hook_event_name": "PreToolUse",
      "tool_name": "Bash",
      "tool_input": {
        "command": "npm test"
      }
    }

    A guardrail judges these arguments, not the model's stated intent. A script that pulls out tool_input.command with jq and greps it for drop table will catch that statement however the conversation got there, whether the user asked for it or instructions hidden in a fetched page did.

    Checkpoint 1 of 5· Check yourself

    A hook must stop any Bash call containing git push --force. Which field of the hook input holds the text to inspect?

    Sources12

    2.Blocking with exit codes

    The simplest guardrail answers with its exit code alone. Exit 2 blocks the call, and anything the script writes to stderr goes back to Claude as feedback, so Claude can change course rather than retry the same command blindly. Exit 0 means no objection, and the call carries on to the normal permission checks.

    Other exit codes are where guardrails quietly fail. With any other code, Claude Code looks at stdout. If stdout holds JSON that passes schema validation, the JSON decides the outcome and the exit code is ignored. If stdout is plain text or empty, the call goes ahead and the transcript shows a non-blocking hook error. Suppose a script crashes with exit 1 when jq is missing. It has blocked nothing. A guardrail that must hold should exit 2 deliberately when it detects a problem, not rely on crashing.

    How Claude Code reads a PreToolUse hook's exit code
    Exit codeOutcome for the tool call
    0No objection; the normal permission flow applies
    2Blocked; stderr becomes Claude's feedback
    Other, with valid JSON on stdoutExit code ignored; the JSON alone decides
    Other, with plain text or empty stdoutProceeds as a non-blocking error

    Checkpoint 2 of 5· Match them up

    Match each PreToolUse hook result to what happens to the tool call

    Tap a term, then the definition that fits it.

    Sources1

    3.Structured decisions: deny, ask and allow

    An exit code can say only "block" or "no objection". JSON output can say more. A PreToolUse hook prints a hookSpecificOutput object whose permissionDecision takes one of three values. deny cancels the call and sends permissionDecisionReason to Claude. ask shows the usual permission prompt to the user. allow skips the interactive prompt.

    A command hook that denies rm -rf with a structured decision and otherwise exits 0bash
    #!/bin/bash
    # .claude/hooks/block-rm.sh
    COMMAND=$(jq -r '.tool_input.command')
    
    if echo "$COMMAND" | grep -q 'rm -rf'; then
      jq -n '{
        hookSpecificOutput: {
          hookEventName: "PreToolUse",
          permissionDecision: "deny",
          permissionDecisionReason: "Destructive command blocked by hook"
        }
      }'
    else
      exit 0  # no decision; normal permission flow applies
    fi

    ask fills a gap the exit code leaves. Take an operations team that wants every database-write MCP tool call to wait for a human. A PreToolUse hook with a matcher such as mcp__db__.* can return ask for write calls. The user sees the prompt, and once they approve, the tool runs normally. Blocking with deny would rule the action out entirely, which is not what the team wanted.

    allow is weaker than it looks. It removes the interactive prompt and nothing else. Deny and ask rules still apply, including enterprise-managed deny lists. An rm or rmdir that targets a critical path cannot be approved by any allow rule or hook allow. The asymmetry runs the other way for blocking: hooks are evaluated before the permission mode is checked, so a hook's deny still stops a tool when the session runs in bypassPermissions.

    Checkpoint 3 of 5· Check yourself

    Project settings carry a deny rule for Bash(curl *). A PreToolUse hook returns permissionDecision: "allow" for a curl command. What happens?

    Checkpoint 4 of 5· Exam question

    A team configures a PreToolUse hook that returns exit code 0 with the following JSON on stdout: `{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "Writing to /etc is not allowed"}}`. What happens to the pending tool call?

    Sources13

    4.The same guardrail in the Agent SDK

    In the Agent SDK, hooks are callback functions passed in the agent's options instead of entries in a settings file. The hooks option maps an event name such as PreToolUse to a list of matchers. Each matcher has a pattern and the callbacks to run, and the pattern follows the same rules as matchers in settings files. MCP tools keep the mcp__<server>__<action> naming.

    Registering a PreToolUse callback for the Bash tool in Pythonpython
    options = ClaudeAgentOptions(
        hooks={"PreToolUse": [HookMatcher(matcher="Bash", hooks=[my_callback])]}
    )

    A callback answers by returning a value, not by exiting with a code. It receives the input data (tool_name, tool_input and the shared fields), a tool use ID that ties a PreToolUse event to the matching PostToolUse, and a context argument. To refuse a call it returns the same hookSpecificOutput structure a command hook prints. To let the call proceed it returns an empty object. A slow callback fails closed: one that exceeds its timeout blocks the tool call. Language support also differs. PreToolUse and PostToolUse work in both Python and TypeScript, but events such as SessionStart are TypeScript-only.

    Top-level fields such as systemMessage, which shows a message to the user, and continue are accepted on every event. Some events discard them or deliver them somewhere else. If a guardrail's explanation never reaches your UI, check that event's section on the hooks page before you go hunting for a bug in the callback.

    Checkpoint 5 of 5· Fill the gap

    Which value completes this callback so that it blocks writes to .env files?

    # Define a hook callback that receives tool call details
    async def protect_env_files(input_data, tool_use_id, context):
        # Extract the file path from the tool's input arguments
        file_path = input_data["tool_input"].get("file_path", "")
        file_name = file_path.split("/")[-1]
    
        # Block the operation if targeting a .env file
        if file_name == ".env":
            return {
                "hookSpecificOutput": {
                    "hookEventName": input_data["hook_event_name"],
                    "permissionDecision": " ? ",
                    "permissionDecisionReason": "Cannot modify .env files",
                }
            }
    
        # Return empty object to allow the operation
        return {}

    Sources42

    Exam traps

    Each one states something that sounds right. Open it to see what is actually true.

    1. 1.A PreToolUse hook that exits 0 approves the tool call.Why is that wrong?

      Exit 0 only means the hook has no objection. The normal permission flow still decides, and it may still prompt the user.

      Covered in Blocking with exit codes

    2. 2.A hook returning permissionDecision allow can approve anything, including rm on a critical path.Why is that wrong?

      Allow only skips the prompt. Deny and ask rules still apply, and critical-path rm and rmdir removals cannot be approved by a hook's allow.

      Covered in Structured decisions: deny, ask and allow

    Practise it for real

    Install a project PreToolUse hook that denies rm -rf and watch it block a real call.

    1. 1.Save the block-rm.sh script from this page as .claude/hooks/block-rm.sh in a scratch project.

      Why: Inside the project, the script can be committed alongside the settings that call it.

      You should see: The file exists and reads tool_input.command with jq.

    2. 2.Run chmod +x .claude/hooks/block-rm.sh.

      Why: A command handler has to be executable on macOS and Linux.

      You should see: ls -l shows the execute bit set.

    3. 3.In .claude/settings.json, register the script under PreToolUse with matcher Bash and if set to Bash(rm *).

      Why: The matcher limits the hook to Bash, and the if condition limits it further to rm commands.

      You should see: Claude Code loads the hook at the start of the next session.

    4. 4.Ask Claude to delete a scratch directory with rm -rf.

      Why: The call matches both filters, so the handler runs and returns deny.

      You should see: The call is cancelled and Claude receives "Destructive command blocked by hook".

    5. 5.Ask Claude to run ls.

      Why: The matcher passes it, but the if condition does not, so the handler never runs.

      You should see: The command goes through the normal permission flow.

    Stuck? Get a nudge

    If rm still runs, check whether the script crashed. Exit codes other than 2, with no valid JSON on stdout, let the call proceed.

    Sources

    Every claim above is drawn from one of these pages, quoted as it was written on the date shown.

    1. 1.
      “tool_input: the arguments Claude passed to the tool. For Bash, its command field holds the shell command.”
      ↩︎ What a PreToolUse hook receives
      “Claude Code ignores the exit code, the JSON alone decides the outcome, and the hook isn’t reported as an error.”
      ↩︎ Blocking with exit codes
      “Stdout that Claude Code treats as plain text, or empty stdout: the action proceeds as a non-blocking error.”
      ↩︎ Blocking with exit codes
      “stderr becomes Claude's feedback”
      ↩︎ Blocking with exit codes
      “cancel the tool call and send the reason to Claude”
      ↩︎ Structured decisions: deny, ask and allow
      “show the permission prompt to the user as normal”
      ↩︎ Structured decisions: deny, ask and allow
      “For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies.”
      ↩︎ Exam trap 1
      “For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies.”
      ↩︎ Prediction
      “Exit 2: Claude Code blocks the action. Write a reason to stderr.”
      ↩︎ Checkpoint
      “skip the interactive permission prompt. Deny and ask rules, including enterprise managed deny lists, still apply”
      ↩︎ Checkpoint
    2. 2.
      “Before a tool call executes. Can block it”
      ↩︎ What a PreToolUse hook receives
      “An Agent SDK callback hook that exceeds its timeout blocks the tool call.”
      ↩︎ The same guardrail in the Agent SDK
    3. 3.
      “Deny rules, explicit ask rules, and hooks are evaluated before the mode check and can still block a tool.”
      ↩︎ Structured decisions: deny, ask and allow
    4. 4.
      “Return empty object to allow the operation”
      ↩︎ The same guardrail in the Agent SDK
      “Some events discard them or deliver them elsewhere.”
      ↩︎ The same guardrail in the Agent SDK
      “correlates PreToolUse and PostToolUse events for the same tool call”
      ↩︎ The same guardrail in the Agent SDK

    Also cited

    Ready to test yourself?

    Practise the 20 questions on this subdomain.

    Spotted a mistake, or was something unclear? Tell us.