AI EngineeringZero to ProductionHome·About·Contact
Part V · Build Lab C

RAG Runbooks & the Safety Gate

This is the lab that makes the agent trustworthy. You'll ground it in the company's runbooks (RAG — the "onboarding"), then build the policy gate and autonomy rungs that stand between the model's intentions and any real action. By the end you'll prove — with a test — that at the OBSERVE rung the agent physically cannot change anything, no matter what it's asked.

⏱️ ~75 min🔐 the safety core✅ safety tests, no API key

Learning objectives

  • Ground the agent in company runbooks so it follows their procedures.
  • Define autonomy rungs (OBSERVE → RECOMMEND → ACT → AUTONOMOUS).
  • Build the policy gate that maps (risk class × rung) → allow / ask / block.
  • Insert the gate into the loop and record every action in an audit log.
  • Write the test that proves irreversible actions are never allowed.

The idea in one sentence advanced

Safety lives in code, not the prompt"I told the model not to touch prod" is not a control. A gate that refuses the action — and, in production, an IAM role that makes it impossible — is. This lab builds that gate.

Step 1 · RAG over runbooks (the onboarding) advanced

A generic agent knows Kubernetes; it doesn't know your procedures. Grounding it in the company's runbooks is what "onboarding into a company" actually means. We use a tiny keyword retriever so it runs with no extra dependencies (swap in the Chapter 3 vector store for real semantic search — same interface).

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Lab 8c · Step 1
  1. Add a runbook the agent should follow.
    runbooks/crashloop.md (excerpt)# Runbook: Pod CrashLoopBackOff
    Do NOT delete it — the crash reason will recur. Diagnose first.
    1. Check the pod's logs for the actual error.
    2. Check recent deploys — a crash that started minutes ago usually
       correlates with a recent change (image, secret, config).
    ## Preferred fix
    Open a PR that corrects the config/secret or rolls back the deploy.
    A restart alone does not fix a config problem.
  2. The retriever.
    agent/runbooks.pyimport os, glob, re
    _DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "runbooks")
    _DOCS = [(os.path.basename(p), open(p).read())
             for p in sorted(glob.glob(os.path.join(_DIR, "*.md")))]
    
    def context_for(query: str) -> str:
        q = set(re.findall(r"\w+", query.lower()))
        scored = [(sum(w in q for w in re.findall(r"\w+", text.lower())), name, text)
                  for name, text in _DOCS]
        scored.sort(reverse=True)
        top = [(n, t) for s, n, t in scored[:1] if s > 0]
        return "\n\n".join(f"### Runbook: {n}\n{t}" for n, t in top) or "(none)"
  3. The loop injects runbooks.context_for(incident) into the system prompt so the agent follows the runbook's procedure (e.g. "open a PR, don't just restart").
▶ How this works

This is not code — it's a plain-English runbook: a written procedure the company follows when a specific problem happens. The whole point of this lab is to make the agent obey this document instead of guessing. Read it as the house rules for one incident: a pod stuck in CrashLoopBackOff (a container that keeps crashing and restarting).

  1. The first rule is a hard don't: do not delete the pod. Deleting it just makes Kubernetes recreate it, and it will crash again — because the real cause (a bad config or secret) is still there.
  2. The numbered steps say diagnose first: read the pod's logs to find the actual error, then check recent deploys, because a crash that started minutes ago usually lines up with a recent change.
  3. The Preferred fix section is the key instruction the agent must copy: open a PR (a proposed, reviewable code change) to correct the config or roll back — not a blind restart. A restart doesn't fix a config problem.

Try this: Think of a runbook as the difference between a new hire who improvises and one who follows your team's checklist. Later steps feed this text to the model so it behaves like the second one. Try adding your own runbooks/*.md file for a different incident.

▶ How this works

This is a tiny retriever — the R in RAG (Retrieval-Augmented Generation). Given the incident text, it picks the one most relevant runbook and returns it, so the loop can paste that runbook into the prompt. It uses simple word-overlap counting, no AI and no extra libraries, so it always runs — but it plugs in behind the same interface as a real vector search.

  1. At import time, _DOCS reads every *.md file in the runbooks/ folder into a list of (filename, text) pairs. This happens once when the module loads.
  2. Inside context_for, q = set(re.findall(r"\w+", query.lower())) breaks the incident text into a set of lowercase words — the query's vocabulary.
  3. The scored = [...] list comprehension gives every runbook a score: it counts how many of that document's words also appear in q. More shared words = more relevant. This is a crude stand-in for semantic similarity.
  4. scored.sort(reverse=True) puts the highest score first; scored[:1] if s > 0 keeps only the single best match, and only if it actually shares at least one word. The final line formats it as ### Runbook: name, or returns "(none)" when nothing matched.

What the output means: A string containing the most relevant runbook's title and full text — ready to drop into the system prompt — or "(none)" if no runbook shared any words with the incident.

Try this: Call context_for("pod keeps crashing"): the words pod and crash overlap with the CrashLoopBackOff runbook, so it wins. The comment in the lesson notes you can swap this for the Chapter 3 vector store for true semantic search — same function signature, better matching.

This is the onboarding leverOnboard the agent to a new company = drop their runbooks into runbooks/. The agent code doesn't change. That separation — generic code, per-company knowledge — is what makes it a product (Ch 8 §11).

Step 2 · Define the autonomy rungs advanced

Autonomy is a ladder you climb on evidence, not a switch. Model it as an ordered enum.

Lab 8c · Step 2
agent/policy.pyfrom enum import IntEnum
from .schemas import RiskClass

class Rung(IntEnum):
    OBSERVE = 1      # read-only only
    RECOMMEND = 2    # + reversible actions that produce reviewable artifacts (PRs)
    ACT = 3          # + reversible/significant actions, each needing approval
    AUTONOMOUS = 4   # + a short allowlist runs unattended (never irreversible)
▶ How this works

Before the gate can make decisions, we need to name the agent's autonomy levels. This defines them as an ordered ladder called Rung. IntEnum means each rung is also a number (1–4), so you can compare them — a higher rung means more freedom. You climb the ladder as the agent earns trust; you don't flip a single on/off switch.

  1. OBSERVE = 1 is the safest floor: the agent may only read (look at logs, list pods). It cannot change anything.
  2. RECOMMEND = 2 adds reversible actions that leave a reviewable artifact — chiefly opening a PR a human can inspect before it merges.
  3. ACT = 3 adds bigger reversible/significant actions, but each one still needs human approval.
  4. AUTONOMOUS = 4 is the top: a short pre-approved allowlist can run unattended — but, as the comment says, never anything irreversible. That limit is enforced in the next steps.

Try this: Notice these describe how much the agent may do, not how risky a single action is — that's a separate axis (RiskClass). The gate in Step 3 combines the two: current rung × action's risk → a decision.

Step 3 · The policy gate — the decision matrix expert

The gate is a lookup: given a tool's risk class and the current rung, return allow, ask, or block. Simple, auditable, and — crucially — testable without the model.

gate(risk, rung) → allow / ask / block R1 ObserveR2 Rec.R3 ActR4 Sel. READ_ONLY REVERSIBLE SIGNIFICANT IRREVERSIBLE allow ask (human) block always Safety is a table, not a vibe. Read-only is always allowed; irreversible is always blocked regardless of rung; the middle risks need human approval until a high enough autonomy rung is earned. Because it's a pure lookup outside the model, a hijacked model (see T1) can change the request but never the policy.
🗺️ How to read this diagram

This grid is the entire safety policy at a glance. It's a lookup table: pick the row for how risky the requested action is, pick the column for the agent's current autonomy rung, and the cell's colour is the verdict. The gate does exactly this lookup — no AI involved, so nothing the model says can change the answer.

  • The rows (top to bottom) are the action's risk class, from safest to most dangerous: READ_ONLY, REVERSIBLE, SIGNIFICANT, IRREVERSIBLE.
  • The columns (left to right) are the four autonomy rungs from Step 2: Observe, Recommend, Act, Sel(f-driving / Autonomous) — the agent gains freedom as you move right.
  • The colours are the verdict: green = allow (run it), amber = ask (pause and get a human's yes/no), red = block (refuse, always). The legend at the bottom spells this out.
  • Read across the top row: READ_ONLY is green everywhere — reading is always safe. Now read the bottom row: IRREVERSIBLE is red in every column — a destructive action is refused no matter how much autonomy the agent has.
  • The middle two rows shift from amber toward green as you move right: the more autonomy earned, the fewer human check-ins needed for medium-risk actions.

In short: The safety guarantee is the whole bottom row being red. Because this is a fixed table checked in code outside the model, a hijacked or tricked model can change what it asks for but never what the table permits.

Lab 8c · Step 3

Continues agent/policy.py from earlier in this lesson — run the previous block(s) first.

agent/policy.py (continued)_MATRIX = {
  Rung.OBSERVE:    {READ_ONLY:"allow", REVERSIBLE:"block", SIGNIFICANT:"block", IRREVERSIBLE:"block"},
  Rung.RECOMMEND:  {READ_ONLY:"allow", REVERSIBLE:"ask",   SIGNIFICANT:"block", IRREVERSIBLE:"block"},
  Rung.ACT:        {READ_ONLY:"allow", REVERSIBLE:"ask",   SIGNIFICANT:"ask",   IRREVERSIBLE:"block"},
  Rung.AUTONOMOUS: {READ_ONLY:"allow", REVERSIBLE:"allow", SIGNIFICANT:"ask",   IRREVERSIBLE:"block"},
}   # (keys shown short; the file uses RiskClass.READ_ONLY etc.)

def evaluate(risk, rung):
    verdict = _MATRIX[rung][risk]
    return PolicyDecision(verdict, f"risk={risk.value} at {rung.name} → {verdict}")
▶ How this works

This is the diagram turned into real code. _MATRIX is a dictionary of dictionaries — the exact grid you just read — and evaluate is the one-line lookup that returns the verdict. This is the heart of the safety system, and it's deliberately dumb and predictable so it can be tested without ever calling the model.

  1. _MATRIX has one entry per rung. Each entry maps a RiskClass to a verdict string: "allow", "ask", or "block". Compare any row to the same-coloured row in the diagram — they match exactly.
  2. Look down the IRREVERSIBLE key in all four rows: it is "block" every time, including at AUTONOMOUS. That single fact is the most important safety property in the whole build.
  3. evaluate(risk, rung) does the two-step lookup: _MATRIX[rung][risk] first picks the rung's row, then the risk's cell — giving one verdict.
  4. It wraps that verdict in a PolicyDecision object with a human-readable reason (e.g. "risk=irreversible at OBSERVE → block"), so the audit log and the model both learn why an action was refused.

What the output means: A PolicyDecision whose .verdict is one of allow / ask / block, plus a .reason string explaining the decision.

Try this: Trace evaluate(RiskClass.REVERSIBLE, Rung.RECOMMEND) by hand: pick the RECOMMEND row, then the REVERSIBLE cell → "ask". Then try IRREVERSIBLE at any rung and confirm you always land on "block".

Read the IRREVERSIBLE columnIt's block at every rung — including AUTONOMOUS. Prod/destructive actions are never granted to the agent automatically. That single column is the most important safety property in the system, and Step 6 tests it.

Step 4 · Wire the gate into the loop expert

Insert the gate between "the model requested a tool" and "the tool runs". Now the read-only loop from Lab 8b becomes safe for all tools.

Lab 8c · Step 4

Illustrative fragment — defines demo values / files are needed before this runs standalone.

agent/engine.py (the gated section)from agent.policy import Rung, evaluate
from agent import audit
from agent.schemas import ToolCallRecord

# inside the loop, for each tool_use block:
tool = TOOLS[block.name]
decision = evaluate(tool.risk, rung)            # ← the gate

allowed, approver = False, None
if decision.verdict == "allow":
    allowed = True
elif decision.verdict == "ask":
    allowed = approval_fn(tool.name, block.input, tool.risk)  # human decides
    approver = "human" if allowed else None

if allowed:
    out = tool.run(**block.input)
else:
    out = f"BLOCKED by policy: {decision.reason}. Action not performed."

audit.record(ToolCallRecord(tool=tool.name, args=dict(block.input),
    risk=tool.risk, allowed=allowed, approved_by=approver,
    result_preview=str(out)[:120]))
▶ How this works

This is where the gate actually protects you. In Lab 8b the loop just ran whatever tool the model asked for. Here, every requested tool must pass through evaluate first, and the verdict decides whether it runs, pauses for a human, or is refused — with a record written either way.

  1. tool = TOOLS[block.name] looks up the tool the model asked to use. Every tool carries a tool.risk tag (its risk class).
  2. decision = evaluate(tool.risk, rung) is the gate: it runs the Step 3 lookup using the tool's risk and the agent's current rung.
  3. The verdict branches: "allow" sets allowed = True straightaway; "ask" calls approval_fn(...) so a human decides yes/no (and we remember the approver); a "block" verdict leaves allowed = False.
  4. If allowed, out = tool.run(**block.input) executes the tool. If not, out becomes a "BLOCKED by policy: ..." message instead of the result.
  5. Finally, audit.record(...) writes a ToolCallRecord for every attempt — allowed or not, who approved it, and a preview of the result. Nothing happens off the record.

What the output means: Either the real tool output, or a "BLOCKED by policy: ..." string — and in both cases a new entry in the audit log.

Try this: The blocked message is handed back to the model as the tool result. So instead of silently failing, the model sees why it was refused and can adapt — e.g. propose opening a PR instead of deleting. Guardrails that explain themselves make the agent behave better.

Note what the model receivesWhen blocked, the model gets back "BLOCKED by policy..." as the tool result — so it adapts (e.g. proposes opening a PR instead of deleting), rather than silently failing. Guardrails that explain themselves make the agent behave better.

Step 5 · The audit log expert

Lab 8c · Step 5
agent/audit.pyfrom .schemas import ToolCallRecord
_LOG: list[ToolCallRecord] = []
def record(rec): _LOG.append(rec)
def entries(): return list(_LOG)
def clear(): _LOG.clear()
def render():
    return "\n".join(
        f"{'✓' if r.allowed else '✗'} {r.tool}({r.args}) [{r.risk.value}]"
        for r in _LOG) or "(no actions)"
▶ How this works

The audit log is the agent's flight recorder: a plain list that remembers every action it attempted. It's tiny on purpose — a few functions over one in-memory list — but it's the thing that lets a human answer "what did the agent do, and who approved it?" after the fact.

  1. _LOG is a module-level list of ToolCallRecord objects. record(rec) appends one; entries() returns a copy; clear() empties it (call this at the start of a run, not the end).
  2. render() turns the log into readable lines — one per action.
  3. Each line uses '✓' if r.allowed else '✗' to show at a glance whether the action ran, followed by the tool name, its arguments, and its risk class in brackets.
  4. The or "(no actions)" at the end is a safety default: if the log is empty, you get a clear message instead of a blank string.

Try this: This is why teams trust an infra agent at all. Try it after a run: print(audit.render()) shows the ✓/✗ trail. Remember to call audit.clear() when a run begins so old entries don't leak into the new log.

Why an audit log is non-negotiable for infraThe first question after any incident is "what did the agent do, and who approved it?" Without a per-action record you can't answer it — and no team will trust an agent near their infra that can't be audited.

Step 6 · Prove it's safe (the test that matters most) expert

The whole point of this lab: demonstrate, with a test that needs no API key, that the gate holds. Then watch it hold live.

Lab 8c · Step 6
  1. The safety unit tests (pure logic, instant, free):
    tests/test_policy.pyfrom agent.policy import Rung, evaluate
    from agent.schemas import RiskClass
    
    def test_observe_blocks_all_writes():
        assert evaluate(RiskClass.READ_ONLY, Rung.OBSERVE).verdict == "allow"
        for r in (RiskClass.REVERSIBLE, RiskClass.SIGNIFICANT, RiskClass.IRREVERSIBLE):
            assert evaluate(r, Rung.OBSERVE).verdict == "block"
    
    def test_irreversible_never_allowed_at_any_rung():
        for rung in Rung:                              # THE core invariant
            assert evaluate(RiskClass.IRREVERSIBLE, rung).verdict != "allow"
    terminalpython -m pytest tests/test_policy.py -v
    test_observe_blocks_all_writes PASSED
    test_recommend_allows_pr_but_asks PASSED
    test_act_asks_for_significant PASSED
    test_irreversible_never_allowed_at_any_rung PASSED
    4 passed in 0.02s
  2. Watch it hold live — ask the agent to do something dangerous at OBSERVE:
    terminalpython cli.py "delete the checkout-api pod"          # rung defaults to OBSERVE
    The runbook says not to delete a crash-looping pod — deleting won't
    fix the underlying config problem. I can't take that action here anyway.
    I'd recommend correcting the DATABASE_URL secret via a PR instead.
    --------------------------------------------------
    📋 Audit log:
    ✓ kubectl_get_pods(...) [read_only]
    ✗ delete_pod({'name': 'checkout-api-7d9f'}) [irreversible]   ← BLOCKED

    The model wanted to (or was asked to), the gate said no, the audit log shows the ✗, and nothing happened. That's the system working.

▶ How this works

This is the payoff of the whole lab: a test that proves the gate is safe, runs in hundredths of a second, and needs no API key (it never calls the model — it just checks the lookup table). Then the terminal shows the same rule holding on a live request.

  1. test_observe_blocks_all_writes asserts that at the OBSERVE rung, read-only is "allow" but every write risk class comes back "block" — proving the read-only rung is truly read-only.
  2. test_irreversible_never_allowed_at_any_rung is the one that matters most. The for rung in Rung loop walks all four rungs and asserts an IRREVERSIBLE action is never "allow". The comment calls it THE core invariant — the safety promise the entire system rests on.
  3. The pytest -v command runs these; the green PASSED lines and 4 passed in 0.02s confirm the policy behaves exactly as the matrix says.
  4. The second half is a live check: python cli.py "delete the checkout-api pod" at the default OBSERVE rung. The agent explains (from the runbook) why deleting is wrong, and the gate refuses it regardless. The audit log shows a ✓ for the read-only get_pods and a ✗ for the blocked delete_pod.

What the output means: All four policy tests pass instantly with no API key; live, the dangerous delete is refused and recorded as ✗ — the model wanted (or was asked) to act, the gate said no, and nothing happened.

Try this: Run pytest tests/test_policy.py -v yourself. This is the difference between saying the agent is safe and proving it: a fast, free test asserting that irreversible actions can never be auto-approved.

✅ Test cases for Lab 8c
TestNeeds API key?Proves
OBSERVE blocks all writesNoRead-only rung is truly read-only
irreversible never allowed at any rungNoThe core safety invariant holds
RECOMMEND asks for PRs, blocks significantNoEach rung permits exactly what it should
live: delete request at OBSERVE is blocked + auditedYesThe gate holds end-to-end, with a record

Troubleshooting expert

⚠️ Common issues & fixes
SymptomCauseFix
Agent ignores the runbook procedureRunbook not retrieved (no keyword overlap)Check context_for(incident) returns the right doc; add keywords to the runbook or use the Ch 3 vector store
A write action ran at OBSERVEGate not wired, or tool mis-tagged as READ_ONLYConfirm the tool's risk= is correct and the loop calls evaluate() before tool.run()
Approvals never promptapproval_fn defaults to denyPass --approve (CLI) or your own input()-based function
Audit log emptyaudit.clear() called after the run, or records not writtenClear at the start of a run; record inside the tool-use branch
Safety test passes but live action slipsTool tagged wrong risk classAudit each tool's risk; a "restart" is REVERSIBLE, a "delete" is IRREVERSIBLE
Model keeps trying the blocked toolIt doesn't see why it failedReturn the "BLOCKED by policy: ..." message as the tool result (Step 4)

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Define the autonomy Rung ladderBeginner

Context: Autonomy is a ladder the agent climbs on evidence, not an on/off switch. Making the rungs an IntEnum lets "more freedom" be a numeric comparison.

Your task: Reproduce the Rung ladder from agent/policy.py and explain why it is an IntEnum rather than a plain Enum.

Requirements:

  • Rungs in order: OBSERVE, RECOMMEND, ACT, AUTONOMOUS
  • IntEnum so each rung is also an integer and ACT > OBSERVE holds
  • Higher value means more freedom
  • Note that autonomy (Rung) is a separate axis from action danger (RiskClass)
  • Pure definition — no API key

💡 Hint: The gate later needs to ask "is this rung high enough?" — that comparison only works cleanly if the rungs are ordered integers.

Show solution
from enum import IntEnum

class Rung(IntEnum):
    OBSERVE = 1      # read-only only
    RECOMMEND = 2    # + reversible actions that produce reviewable artifacts (PRs)
    ACT = 3          # + reversible/significant actions, each needing approval
    AUTONOMOUS = 4   # + a short allowlist runs unattended (never irreversible)

Autonomy is a ladder you climb on evidence, not an on/off switch. Making it an IntEnum means each rung is also an integer (1–4), so higher = more freedom and you can compare them (Rung.ACT > Rung.OBSERVE). Crucially this axis (how much the agent may do) is separate from RiskClass (how dangerous one action is); the gate in Step 3 combines the two. This is a pure definition — no API key needed.

Exercise 2 · Build a runbook and its keyword retrieverIntermediate

Context: The agent should act from the company's actual procedures, so the loop injects the best-matching runbook into the system prompt — a keyword retriever standing in for the Chapter 3 vector store.

Your task: Implement agent/runbooks.py's keyword retriever context_for, which loads every runbooks/*.md and returns the single best-matching runbook by word overlap.

Requirements:

  • Load every .md in the runbooks folder once at import
  • Locate that folder relative to the module, not the working directory
  • Score each doc by word overlap with the lowercased query
  • Return the single best-matching doc, or a "(none)" sentinel when nothing matches
  • Same interface as the Chapter 3 store — pure Python, no API key

💡 Hint: It's a crude stand-in for semantic search, but keeping the interface identical is what lets you swap in a real retriever later without touching the loop.

Show solution
import os, glob, re
_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "runbooks")
_DOCS = [(os.path.basename(p), open(p).read())
         for p in sorted(glob.glob(os.path.join(_DIR, "*.md")))]

def context_for(query: str) -> str:
    q = set(re.findall(r"\w+", query.lower()))
    scored = [(sum(w in q for w in re.findall(r"\w+", text.lower())), name, text)
              for name, text in _DOCS]
    scored.sort(reverse=True)
    top = [(n, t) for s, n, t in scored[:1] if s > 0]
    return "\n\n".join(f"### Runbook: {n}\n{t}" for n, t in top) or "(none)"

This is the R in RAG done with plain word-overlap counting — no AI, no extra libraries, so it always runs, but it plugs in behind the same interface as the Chapter 3 vector store. _DOCS loads each markdown file once at import. context_for scores every doc by how many of its words appear in the incident, keeps the single best match (only if it shares at least one word), and formats it as ### Runbook: name, else returns "(none)". The loop injects this string into the system prompt so the agent follows the runbook's procedure (e.g. open a PR, don't just restart). Pure Python; no API key.

Exercise 3 · Encode the policy decision matrix and evaluate()Advanced

Context: The policy decision lives in a lookup table outside the model, so a hijacked model can never talk its way past it. Every cell is allow, ask, or block — and the irreversible column is entirely block.

Your task: Reproduce the _MATRIX and evaluate() from Lab 8c Step 3, then trace by hand what evaluate(RiskClass.REVERSIBLE, Rung.RECOMMEND) returns.

Requirements:

  • _MATRIX is indexed by rung then risk class, with rows for all four rungs and columns for all four classes
  • Every verdict is one of allow / ask / block
  • The whole IRREVERSIBLE column is block
  • evaluate looks up the cell and wraps it with a human-readable reason
  • Trace the requested cell to its verdict by hand

💡 Hint: Keeping the decision as a pure table lookup outside the model is the whole safety argument — there's no prompt the model can craft to change a cell.

Show solution
from agent.policy import Rung
from agent.schemas import RiskClass as R
from dataclasses import dataclass

@dataclass
class PolicyDecision:
    verdict: str
    reason: str

_MATRIX = {
  Rung.OBSERVE:    {R.READ_ONLY:"allow", R.REVERSIBLE:"block", R.SIGNIFICANT:"block", R.IRREVERSIBLE:"block"},
  Rung.RECOMMEND:  {R.READ_ONLY:"allow", R.REVERSIBLE:"ask",   R.SIGNIFICANT:"block", R.IRREVERSIBLE:"block"},
  Rung.ACT:        {R.READ_ONLY:"allow", R.REVERSIBLE:"ask",   R.SIGNIFICANT:"ask",   R.IRREVERSIBLE:"block"},
  Rung.AUTONOMOUS: {R.READ_ONLY:"allow", R.REVERSIBLE:"allow", R.SIGNIFICANT:"ask",   R.IRREVERSIBLE:"block"},
}

def evaluate(risk, rung):
    verdict = _MATRIX[rung][risk]
    return PolicyDecision(verdict, f"risk={risk.value} at {rung.name} → {verdict}")

# trace: pick RECOMMEND row, then REVERSIBLE cell → "ask"
print(evaluate(R.REVERSIBLE, Rung.RECOMMEND).verdict)  # ask

Safety is a table, not a vibe. evaluate is a two-step lookup: _MATRIX[rung][risk] picks the rung's row, then the risk's cell, giving one verdict of allow/ask/block. Because it is pure code outside the model, a hijacked model can change the request but never the policy. The whole IRREVERSIBLE column is block at every rung. This runs with no API key.

Exercise 4 · Wire the gate into the loop with an audit recordExpert

Context: The gate is only real once it sits in front of every tool call, refuses with a self-explaining message the model can adapt to, and records every attempt for the audit log.

Your task: Write the gated section of agent/engine.py: evaluate before any tool runs, branch on allow/ask/block, return a self-explaining BLOCKED message when refused, and record every attempt.

Requirements:

  • Look up the tool, then call evaluate(tool.risk, rung) before running it
  • allow runs it; ask defers to an approval function; block refuses
  • On refusal, hand the model a BLOCKED by policy: ... string as the tool result so it can adapt
  • Record every attempt — allowed or not — to the audit log
  • The gate logic is testable offline; running the full loop needs an API key

💡 Hint: Returning the block reason as a tool result (not an exception) lets the model try a safer path instead of dying — the refusal is a signal, not a crash.

Show solution
from agent.policy import Rung, evaluate
from agent import audit
from agent.schemas import ToolCallRecord

# inside the loop, for each tool_use block:
tool = TOOLS[block.name]
decision = evaluate(tool.risk, rung)            # ← the gate

allowed, approver = False, None
if decision.verdict == "allow":
    allowed = True
elif decision.verdict == "ask":
    allowed = approval_fn(tool.name, block.input, tool.risk)  # human decides
    approver = "human" if allowed else None

if allowed:
    out = tool.run(**block.input)
else:
    out = f"BLOCKED by policy: {decision.reason}. Action not performed."

audit.record(ToolCallRecord(tool=tool.name, args=dict(block.input),
    risk=tool.risk, allowed=allowed, approved_by=approver,
    result_preview=str(out)[:120]))

The gate sits between "the model requested a tool" and "the tool runs". allow runs it immediately; ask defers to approval_fn (a human yes/no); block leaves allowed=False. When blocked, out becomes a "BLOCKED by policy: ..." string that is handed back to the model as the tool result, so it adapts (e.g. proposes a PR instead of a delete) rather than silently failing. Every attempt — allowed or not, and who approved it — is written via audit.record. This is the full-loop path and needs an API key to run; the gate logic itself is testable without one (see next rung).

Exercise 5 · Prove IRREVERSIBLE is never allowed at any rungProfessional

Context: The most important invariant in the whole capstone is that an irreversible action is never allowed at any rung. Because the gate is a pure table, you can prove it offline and instantly.

Your task: Write the two safety unit tests — OBSERVE blocks all writes, and the core invariant that IRREVERSIBLE is never allowed at any rung — as runnable pure logic, and give the command to run them.

Requirements:

  • Assert OBSERVE allows read-only but blocks reversible, significant and irreversible
  • Loop all four rungs and assert the irreversible verdict is never allow
  • Drive the assertions straight through evaluate — no model call
  • Give the exact pytest command; both pass instantly
  • No API key — pure lookup-table tests

💡 Hint: Because the decision is a table, "prove the whole irreversible column is block" is a finite loop over four rungs — a complete proof, not a sample.

Show solution
from agent.policy import Rung, evaluate
from agent.schemas import RiskClass

def test_observe_blocks_all_writes():
    assert evaluate(RiskClass.READ_ONLY, Rung.OBSERVE).verdict == "allow"
    for r in (RiskClass.REVERSIBLE, RiskClass.SIGNIFICANT, RiskClass.IRREVERSIBLE):
        assert evaluate(r, Rung.OBSERVE).verdict == "block"

def test_irreversible_never_allowed_at_any_rung():
    for rung in Rung:                              # THE core invariant
        assert evaluate(RiskClass.IRREVERSIBLE, rung).verdict != "allow"
python -m pytest tests/test_policy.py -v
# test_observe_blocks_all_writes PASSED
# test_irreversible_never_allowed_at_any_rung PASSED

test_observe_blocks_all_writes proves the read-only rung is truly read-only. test_irreversible_never_allowed_at_any_rung is the one that matters most: the for rung in Rung loop walks all four rungs and asserts a destructive action is never "allow", including at AUTONOMOUS. These never call the model — they only check the lookup table — so they run in hundredths of a second with no API key. This is the difference between saying the agent is safe and proving it.

Exercise 6 · Build the audit log as the agent's flight recorderIndustry scenario

Context: The audit log is the agent's flight recorder: what was attempted, whether it was allowed, and at what risk — the record you reach for after any incident.

Your task: Implement agent/audit.py (record, entries, clear, render) and write a no-API-key test proving a blocked irreversible action is recorded with a ✗.

Requirements:

  • Keep an in-memory log; record appends, entries returns a copy, clear empties it
  • render shows ✓/✗ per entry with the tool, args and risk value
  • Render a friendly placeholder when the log is empty
  • Test that a blocked, irreversible delete renders with a ✗ and the [irreversible] tag
  • Pure Python — no API key

💡 Hint: Record the attempt whether or not it was allowed — a blocked action you can't see in the log is a blind spot in the post-incident story.

Show solution
# agent/audit.py
from agent.schemas import ToolCallRecord
_LOG: list[ToolCallRecord] = []
def record(rec): _LOG.append(rec)
def entries(): return list(_LOG)
def clear(): _LOG.clear()
def render():
    return "\n".join(
        f"{'✓' if r.allowed else '✗'} {r.tool}({r.args}) [{r.risk.value}]"
        for r in _LOG) or "(no actions)"
# test — no API key
from agent import audit
from agent.schemas import ToolCallRecord, RiskClass

def test_blocked_delete_is_audited_with_cross():
    audit.clear()   # clear at the START of a run, not the end
    audit.record(ToolCallRecord(tool="delete_pod",
        args={"name": "checkout-api-7d9f"}, risk=RiskClass.IRREVERSIBLE,
        allowed=False, approved_by=None, result_preview="BLOCKED by policy"))
    out = audit.render()
    assert out.startswith("✗ delete_pod")
    assert "[irreversible]" in out

The audit log is the flight recorder: an in-memory list with a few functions over it. render() shows '✓' if r.allowed else '✗' per action, and or "(no actions)" gives a clear default when empty. The first question after any incident is "what did the agent do, and who approved it?" — without a per-action record you can't answer it, and no team trusts an unauditable agent near infra. Note clear() is called at the start of a run so old entries don't leak in. This test is pure Python, no API key.

✓ Checkpoint — Lab 8c complete when…

  • The agent follows the runbook (e.g. proposes a PR instead of a blind restart).
  • The policy gate is wired into the loop; every action is audited.
  • The no-API-key safety tests pass, including "irreversible never allowed".
  • A live "delete" request at OBSERVE is blocked and shows ✗ in the audit log.

Knowledge check check yourself

✓ Knowledge check

The policy gate is a pure lookup table keyed by (risk class x rung) that lives outside the model. Why is implementing safety as a table rather than a model decision the crucial design choice?

Show answer
Because the verdict (allow/ask/block) is computed in code with no AI involved, a hijacked or prompt-injected model can change what it *asks* for but never what the table *permits*. The whole bottom row (IRREVERSIBLE) is block at every rung, including AUTONOMOUS, so destructive actions are never auto-granted regardless of the prompt.
✓ Knowledge check

When the gate blocks an action, the loop returns 'BLOCKED by policy: ...' back to the model as the tool result instead of silently dropping it. Why does a guardrail that explains itself make the agent behave better?

Show answer
The model reads the reason and adapts — e.g. after a blocked delete it proposes opening a PR to fix the secret instead. Returning the explanation as the tool result keeps the agent reasoning productively rather than silently failing or repeatedly retrying the same blocked tool.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in