K1.5.3 Task 1.5

PreToolUse Returns: deny, allow, modify — and the Bugs Between Them

PreToolUse hooks do three things: block tool calls, allow them as-is, or allow them with modified inputs. Each action requires a specific return structure — and getting it wrong produces silent failures that are hard to debug.

The three return patterns

1. Deny: block the tool call

{
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": "Refund exceeds $500 limit, requires human approval"
    }
}

The tool does not execute. The permissionDecisionReason reaches the model so it can explain the situation and take alternative action. Without a reason, the model knows the call was blocked but not why.

2. Allow with modification: transparently change tool inputs

{
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "allow",
        "updatedInput": {"file_path": "/sandbox/src/main.py"}
    }
}

The tool executes with the modified parameters. The model doesn’t see the change — it thinks it wrote to /src/main.py but the file lands in /sandbox/. Use for: sandbox redirection, metadata injection (prepending timestamp comments to file content), parameter enrichment.

Critical: updatedInput is a partial update — include only changed fields. Unchanged fields are preserved from the original input.

3. Allow as-is (or passthrough)

Return {}, None, or simply don’t return. The tool proceeds with its original parameters unchanged.

The most common bug: empty return instead of deny

async def block_delete(input_data, tool_use_id, context):
    if input_data["tool_name"] == "delete_account":
        print("Blocked delete_account!")  # This prints correctly
    return {}  # But this ALWAYS allows the tool call

The print() runs, the log says “Blocked!” — but the function always falls through to return {}, which the SDK interprets as passthrough. The fix: return a deny decision inside the if block, not after it.

updatedInput requires explicit “allow”

A hook returns updatedInput with a modified file path but omits permissionDecision. Result: the SDK treats the response as passthrough and uses the original, unmodified input. The updatedInput is silently ignored.

The rule: updatedInput only takes effect when permissionDecision: "allow" is explicitly included. No allow decision = no input modification, regardless of what’s in updatedInput.

Invalid return formats

  • Custom JSON ({"error": "...", "blocked": true}): SDK ignores arbitrary keys. It expects hookSpecificOutput.
  • Wrong values ("reject", "block", "cancel"): only "deny" and "allow" are valid.
  • Exceptions (raise ValueError(...)): crashes the hook. The SDK may default to allowing the tool call — the opposite of what you intended.

Deny with redirect guidance

When denying a high-value refund, don’t just say “blocked.” Guide the model toward the correct alternative:

“Refund exceeds $500 limit. Please use the escalate_to_human tool instead, including customer ID, order ID, refund amount, and reason.”

The model reads this reason and knows exactly what to do next. Hooks should not call tools directly — they return decisions, not execute actions. The model handles the alternative workflow.

MCP tool name prefix trap

A hook with matcher="process_refund" catches the built-in tool but misses mcp__payments__process_refund — the same tool exposed through an MCP server. Production data: 95% catch rate (built-in), 5% bypass (MCP prefix).

Fix: matcher=".*process_refund" — regex matches any tool name ending in “process_refund” regardless of prefix.

Multiple hooks, one denial reason

Two hooks on process_refund: Hook A checks customer verification, Hook B checks refund limits. Both fire, both deny, but the SDK only propagates one denial reason. The model fixes the amount limit without realizing the customer is also unverified.

Fix: combine related checks into a single hook that evaluates all conditions and returns a comprehensive denial reason: “Denied: (1) Customer not verified, (2) Refund exceeds limit. Both must be resolved.”

Silent semantic modification: the anti-pattern

A hook that silently adds "classification": "confidential" to every extraction without the model’s knowledge. The model thinks it did a normal extraction, but the output is always tagged confidential. Later, the model references its “unclassified” extraction — mismatch between model’s understanding and reality.

Rule: input modifications should be either transparent (the model knows) or limited to non-semantic changes (path redirection, metadata injection).

Defense in depth: hook + prompt

A hook blocks known dangerous Bash patterns (rm -rf, drop database). A system prompt says “never execute destructive commands.” Both together provide defense-in-depth: the hook catches known threats deterministically, the prompt provides a probabilistic safety net for novel threats not in the pattern list (truncate -s 0 /var/log/*). Neither is redundant.

Two-hook shared state for graduated policies

Customer refund limits depend on tenure (new: $100, regular: $300, VIP: $1000). Pattern: a PostToolUse hook on get_customer captures tenure into shared state → a PreToolUse hook on process_refund reads the shared state to determine the applicable limit. This avoids fragile conversation parsing and doesn’t require modifying tool schemas.

PreToolUse vs PostToolUse: correct assignment

  • Blocking requests (domain blocking, maintenance windows) → PreToolUse. Must intercept BEFORE the request is sent.
  • Transforming results (date normalization, format standardization) → PostToolUse. Needs the raw results before transforming them.

Using PostToolUse for blocking means the request was already sent. Using PreToolUse for result transformation means there are no results yet to transform.


One-liner: PreToolUse returns deny (block), allow + updatedInput (modify transparently), or empty (passthrough) — watch for empty-return-instead-of-deny bugs, always pair updatedInput with explicit allow, use regex matchers for MCP prefixes, and combine related checks into one hook to avoid lost denial reasons.