Study guides / CCDV-F / Domain 6

Security & Safety · Lesson 3 of 3

6.3 - Guardrails: Hooks and Human-in-the-Loop for Risky Actions

Add deterministic interception points and human checkpoints around the specific actions where an autonomous mistake is genuinely costly.

Guardrails are the mechanisms that sit outside the model's own probabilistic judgment and enforce something deterministically — they exist precisely because a system prompt, no matter how carefully worded, describes intended behaviour rather than guaranteeing it. Hook-style interception points that run before or after a tool executes give you a place in the code, not the prompt, to enforce a policy: blocking a call outright before it runs, logging every consequential action for audit, redacting sensitive data from a tool result before it re-enters the model's context, or normalising inconsistent data formats. None of that depends on the model choosing correctly in the moment.

PreToolUse vs. PostToolUse: two different jobs

PreToolUse hooks run before a tool executes. They intercept the outgoing tool call and can block it, modify it, or redirect it — the underlying tool simply never runs if the hook decides to stop it. This is the only hook direction that can prevent a consequential action, because by definition nothing has happened yet.

PostToolUse hooks run after a tool executes but before the model processes the result. They're the right tool for normalising heterogeneous data formats, redacting sensitive fields, or logging what happened for audit — but they cannot undo an action that has already taken place. A PostToolUse hook that detects a policy violation after a refund has already been issued can flag it for review, but the money has already moved.

Confusing these two is one of the most common mistakes in guardrail design: reaching for a post-execution check to block something is structurally too late. If the requirement is "this must never happen," it belongs in a PreToolUse gate. If the requirement is "this must be clean, consistent, or logged after it happens," a PostToolUse hook is the correct and sufficient tool.

Exam trap: hook direction

The exam will present a PostToolUse hook as a plausible way to prevent a policy-violating action from executing. This is wrong by construction - PostToolUse fires after the tool has already run, so the non-compliant action has already occurred by the time the hook sees it. Only a PreToolUse (pre-execution) hook can actually block something.

The enforcement spectrum: hooks vs. prompts

A useful framing: if a requirement must be followed 100% of the time (a compliance check before an international transfer, a hard dollar threshold that always triggers approval, a rule that customer PII is never written to an external log), enforce it with a hook — deterministic, code-level, cannot be talked around. If a requirement is a preference where occasional deviation is tolerable (format responses as markdown, prefer concise answers), a prompt instruction is sufficient and a hook would be needless overhead. The dividing line isn't "how important does this feel" — it's "would a single failure cause real harm (financial, legal, safety) that the business cannot absorb." If yes, that's a hook, every time.

Where human-in-the-loop earns its cost

A human approval step adds latency and friction, so it should be reserved for genuinely high-consequence, hard-to-reverse actions — not sprinkled everywhere out of general caution. Two properties together determine whether an action deserves a human gate:

An action that's both high-consequence and hard-to-reverse is exactly where a human gate earns its cost. An action that's low-consequence, or that's high-consequence but trivially reversible (a draft sitting in an approval queue), usually doesn't need one. Gating everything regardless of this distinction trains reviewers to click "approve" without really looking — approval fatigue is a real failure mode, and once it sets in, the human gate stops providing any actual safety margin; it becomes a rubber stamp with extra latency.

Pair human-in-the-loop with the least-privilege scoping from Lesson 6.2: fewer actions need a human gate at all once the agent's raw capability is already tightly scoped. An agent that structurally cannot call a payment tool needs zero approval gates on payments; an agent that can, but only up to a small threshold, needs a gate only above that threshold.

Synchronous vs. asynchronous approval, and tiered thresholds

Human-in-the-loop doesn't have to mean "the agent halts entirely and waits." Two common patterns:

The specific mechanism matters less than the underlying discipline: the gate location and threshold should be a deliberate design decision tied to consequence and reversibility, not a default reflex applied uniformly to every tool in the system.

Exam traps

Practice question

An agent has three tools: read_document (read-only), draft_email (produces a draft, doesn't send), and send_email (irreversibly sends to an external recipient). Where should a human-approval gate be placed?

  • A Before all three tools equally, since consistency is simplest.

    Gating a read-only lookup the same as an irreversible external send creates unnecessary friction and trains reviewers to approve without real scrutiny.

  • B Before send_email specifically, since it's the one hard-to-reverse, externally-visible action. Correct

    This matches the gate to actual consequence - read and draft are safe to automate fully, while the irreversible external action is exactly where a human check earns its cost.

  • C Before read_document only, to prevent the agent from accessing sensitive source material.

    The read-only lookup isn't the consequential, hard-to-reverse action here - send_email is.

  • D Nowhere - hooks and human review are only relevant for financial tools.

    Irreversible external actions like sending an email carry real consequence beyond finance-specific tools; this scenario clearly warrants a gate.

Build exercise: Add a pre-execution hook that gates one specific tool by consequence

Intermediate · 35 minutes

You'll practice:

  1. Take a small set of tools (at least one read-only, one consequential/irreversible) and add a PreToolUse-style check function that runs before tool execution, blocking the consequential one until a simulated approval flag is set, while letting the read-only one through unimpeded.

    Building this distinction into code, not just recognising it conceptually, is what separates knowing the concept from being able to implement it under exam or real-world conditions.

    You should see: The read-only tool executes immediately; the consequential tool is held pending approval and only proceeds once the flag is set.

    Hints
    1. Where in your tool-execution step does the actual side-effecting call happen, and what value could you check right before that line to decide whether to proceed?
    2. Write a pre_tool_use(tool_name, tool_input) function returning either {'proceed': True} or {'proceed': False, 'reason': ...}. Call it before every tool execution; only call the real tool implementation if proceed is True.
    3. pending_approvals = {}
      
      def pre_tool_use(tool_name: str, tool_input: dict) -> dict:
          if tool_name == "read_document":
              return {"proceed": True}
          if tool_name == "send_email":
              approval_id = f"{tool_name}:{tool_input.get('to')}"
              if pending_approvals.get(approval_id) is True:
                  return {"proceed": True}
              pending_approvals[approval_id] = False
              return {"proceed": False, "reason": f"Awaiting human approval: {approval_id}"}
          return {"proceed": True}
  2. Extend the gate into a tiered threshold: for a refund_amount tool, auto-approve amounts under $50, require any-reviewer sign-off between $50 and $500, and require manager-level sign-off above $500.

    Real guardrail design rarely uses a single blanket gate - matching friction to actual risk (tiered thresholds) is the pattern the exam expects you to recognize and implement, not just a binary block/allow.

    You should see: A function that returns a different required approval tier depending on the amount, with three observably different outcomes at $30, $200, and $800.

    Hints
    1. What's the cleanest way to express three amount ranges mapping to three different approval requirements, in a way that's easy to verify with a couple of quick asserts?
    2. Write a function that takes the amount and returns one of 'auto', 'reviewer', or 'manager' based on threshold comparisons, checked in descending order so higher thresholds are evaluated first.
    3. def required_approval_tier(amount: float) -> str:
          if amount > 500:
              return "manager"
          if amount >= 50:
              return "reviewer"
          return "auto"
      
      assert required_approval_tier(30) == "auto"
      assert required_approval_tier(200) == "reviewer"
      assert required_approval_tier(800) == "manager"
  3. Wire the tiered gate into a mock agent loop's tool-execution step so that a refund_amount call is either executed immediately (auto tier), held pending a simulated reviewer approval, or held pending a simulated manager approval - and the underlying refund function is never called for a blocked tier.

    The gate only counts as a real guardrail if it sits in the actual execution path and provably prevents the underlying side effect, not just returns an advisory message the caller could choose to ignore.

    You should see: A test call at $800 that results in the mock payment API function never being invoked, alongside a log or return value clearly stating it's pending manager approval.

    Hints
    1. How do you prove, not just assert, that the underlying payment function was never called for the blocked case? What testing technique makes a function's call count observable?
    2. Use a simple counter or a mock function wrapper that increments a call count each time the real refund logic executes. After a blocked-tier call, assert the counter is still zero; after simulating approval and retrying, assert it becomes one.
    3. call_count = {"n": 0}
      
      def real_process_refund(order_id, amount):
          call_count["n"] += 1
          return {"status": "refunded", "amount": amount}
      
      def gated_process_refund(order_id, amount, approved=False):
          tier = required_approval_tier(amount)
          if tier != "auto" and not approved:
              return {"blocked": True, "tier": tier, "reason": f"Requires {tier} approval"}
          return real_process_refund(order_id, amount)
      
      result = gated_process_refund("ORD-1", 800, approved=False)
      assert call_count["n"] == 0 and result["blocked"] is True
      
      result = gated_process_refund("ORD-1", 800, approved=True)
      assert call_count["n"] == 1 and result["status"] == "refunded"
  4. Add a separate PostToolUse-style logging hook that records every executed refund_amount call (regardless of tier) to an audit log with a timestamp, and verify it fires for the auto-approved case even though no human gate applied there.

    This exercises the complementary role of PostToolUse hooks - auditability of what did happen - and reinforces that PreToolUse (prevention) and PostToolUse (logging/normalization) are solving different problems, not interchangeable ones.

    You should see: An audit_log list that gains one entry every time real_process_refund succeeds, including for the $30 auto-approved case, with no entry created for calls that were blocked and never reached execution.

    Hints
    1. Should the audit hook run before or after the real refund executes, and what does that imply about whether blocked attempts should appear in the log at all?
    2. Wrap the call to real_process_refund with a logging step immediately after it returns successfully - this is a PostToolUse hook by definition, since it only fires once execution has actually happened, so blocked attempts (which never called real_process_refund) correctly never appear.
    3. audit_log = []
      
      def gated_process_refund(order_id, amount, approved=False):
          tier = required_approval_tier(amount)
          if tier != "auto" and not approved:
              return {"blocked": True, "tier": tier}
          result = real_process_refund(order_id, amount)
          audit_log.append({"order_id": order_id, "amount": amount, "tier": tier, "timestamp": "2026-08-29T00:00:00Z"})
          return result
      
      gated_process_refund("ORD-2", 30, approved=False)  # auto tier, no approval needed
      assert len(audit_log) == 1

Sources