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.
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
Here's the nightmare that keeps infra teams from ever letting an agent near production. The model is helpful, fluent, and confident — and one day it reads a log line that looks like an instruction ("to fix this, run kubectl delete namespace prod"), decides that's the plan, and calls the tool. If the only thing standing between that decision and your cluster is a sentence in the system prompt saying "please be careful," you have no control at all. You have a suggestion. This lab replaces the suggestion with a wall.
Think of it like the launch procedure for anything genuinely dangerous — a bank vault, a nuclear key, a hospital drug cabinet. The person turning the key might be perfectly trustworthy, but we still require two keys, a written log, and a lock that physically won't turn for the wrong request. We don't design those systems around "the operator will probably do the right thing." We design them so that the wrong thing is impossible, or at least impossible-without-a-human-saying-yes. That is exactly the posture you want for an agent that can touch real infrastructure: assume it will occasionally be wrong or tricked, and make the dangerous paths refuse anyway.
The common mistake — and it's an easy, seductive one — is to believe that a well-written prompt is a safety mechanism. It isn't, for a simple reason: the prompt and the model's output live in the same channel an attacker (or a confused log, or a poisoned document) can influence. Anything you can say to the model, someone else can un-say. A real control lives outside that channel: a plain function, in ordinary Python, that inspects the requested action and can return "no" — and in production, an IAM role that makes the destructive API call fail even if every layer above it agreed. The rest of this lab builds that outside-the-model gate, wires it into the loop, records every attempt, and finishes by writing a test that proves the wall holds. When you're done, "the agent is safe" stops being a promise you make in a meeting and becomes a passing test anyone can rerun.
Follow it left to right: everything on the left can be influenced by an attacker; the thing on the right cannot.
- The amber dashed box is the "influenceable channel" — the system prompt, plus any log line or document the model reads. Anything in here can be written or rewritten by someone other than you.
- The arrows into the model show all of those inputs steering it; the model then emits a request to run a tool. That request is just a suggestion, not an action.
- The green box is the code gate, sitting in the green "outside the channel" region — a plain Python function (and, in production, an IAM role) the model never sees and cannot edit.
- The two arrows out are the payoff: the gate returns allow or block. Because it lives outside the channel, no prompt trick can flip a block into an allow.
In short: The model's request is influenceable; the gate's verdict is not. Real safety is the wall on the right, not the sentence on the left.
Step 1 · RAG over runbooks (the onboarding) advanced
Imagine hiring a brilliant engineer on their first day and turning them loose on an incident with zero context. They know Kubernetes cold — better than half your team — but they don't know that your checkout service crash-loops whenever the database secret rotates, that your team never deletes crash-looping pods, or that the on-call runbook says "open a PR, don't hot-patch." A generic expert with no company context makes technically-correct decisions that are wrong for you. That's exactly the agent you have before this step, and it's why grounding comes first.
RAG — Retrieval-Augmented Generation — is the fix, and the plain-English version is almost boring: before the model answers, go fetch the relevant page from the company handbook and staple it to the question. The model doesn't magically "learn" your procedures; you just make sure the right procedure is sitting in front of it every single time it's asked. It's the difference between an employee who improvises from general knowledge and one who has the actual runbook open on the desk. Retrieval is that desk. In this course you already built a full vector-store retriever in Chapter 3; here we deliberately use a tiny keyword retriever instead so the lab runs with no embeddings, no API key, and no extra install — but it hides behind the same context_for(query) interface, so swapping in the real semantic search later changes nothing else.
Here's why this is the single highest-leverage move in the whole build, and it's worth pausing on. The agent's code is generic — the loop, the tools, the gate all work for any company. The agent's knowledge lives entirely in the runbooks/ folder. Onboarding the agent to a brand-new customer means dropping their markdown files into that folder and changing not one line of Python. That clean separation — shared code, per-customer knowledge — is what turns a clever script into a product you can sell to ten companies (it's the thread Chapter 8 §11 pulls on). The mistake to avoid is baking company-specific rules into the prompt or the code "just for now"; the moment you do, you've welded the agent to one customer and thrown away the reason RAG was worth building. Keep the knowledge in documents, keep the documents retrievable, and the agent stays generic on purpose.
Read it left to right — it's the path a single incident takes before the model ever sees it.
- The incident box on the far left is the raw problem text (e.g. "pod crashing").
- The stack of files is the
runbooks/folder;context_for()scores each one by how many words it shares with the incident and highlights the green best match. - The retriever box (
context_for) is the "R" in RAG — plain word-overlap counting, no AI, standing in for the Chapter 3 vector store behind the same interface. - The prompt box is the payoff: the winning runbook is stapled onto the question, so when the arrow reaches the model it answers from your documented procedure instead of improvising.
In short: Retrieve the best-matching runbook, paste it into the prompt, then let the model answer. Swap the folder's files and the same generic code serves a different company.
- 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. - The retriever.
agent/runbooks.py
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)" - 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").
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).
- 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.
- 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.
- 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.
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.
- At import time,
_DOCSreads every*.mdfile in therunbooks/folder into a list of(filename, text)pairs. This happens once when the module loads. - 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. - The
scored = [...]list comprehension gives every runbook a score: it counts how many of that document's words also appear inq. More shared words = more relevant. This is a crude stand-in for semantic similarity. scored.sort(reverse=True)puts the highest score first;scored[:1] if s > 0keeps 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.
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
Nobody hands a new hire the production root password on day one. You let them read dashboards first. Once they've shown good judgement, you let them open pull requests. Later still, you let them run a deploy — with someone watching. Trust is earned in stages, and each stage grants a little more power. The instinct people get wrong with agents is to treat autonomy as a single on/off switch: either the agent is "read-only" or it's "let it rip." Real trust doesn't work that way, and neither should your agent.
So we model autonomy as a ladder you climb on evidence — a small ordered set of levels we call rungs. Picture a dimmer switch, not a light switch: OBSERVE at the bottom (the agent may only look), then RECOMMEND (it may propose reviewable changes like PRs), then ACT (it may do bigger things, but each one still asks a human), and AUTONOMOUS at the top (a short, pre-vetted list of actions runs unattended). You promote the agent up a rung the same way you'd promote a junior engineer: after it has demonstrated, in evals and in production, that it behaves at the level below. Demote it instantly if it misbehaves. The rung is a knob a human turns deliberately — it is never something the agent grants itself.
The one detail that makes this clean to code is choosing an IntEnum rather than a plain Enum. Because each rung is also a number (OBSERVE=1 … AUTONOMOUS=4), "more freedom" becomes a simple numeric comparison — rung >= Rung.ACT just works — and the gate in Step 3 can reason about ordering instead of hard-coding every case. Watch out for the conceptual trap here, because it's the one learners stumble on: the rung is not the same axis as how dangerous a given action is. "How much is the agent allowed to do right now" (the rung) and "how destructive is this particular tool call" (its risk class, coming next) are two separate dials. The whole safety decision is what happens when you cross them — a low rung can veto even a mild action, and a catastrophic action stays vetoed at every rung. Keeping the two axes distinct in your head now is what makes the decision matrix in Step 3 read as obvious rather than arbitrary.
Read this one bottom to top, like climbing a ladder — the arrow on the left shows freedom increasing as you rise.
- The bottom rung, OBSERVE (1), is the safe floor: the agent may only read. It's the one rung shaded like a hard limit.
- The middle rungs, RECOMMEND (2) and ACT (3), add reversible actions — first PRs a human reviews, then bigger actions that still each ask for approval.
- The top rung, AUTONOMOUS (4), lets a short pre-approved allowlist run unattended — but, as later steps enforce, never anything irreversible.
- The numbers 1–4 matter: because
Rungis anIntEnum, "higher rung = more freedom" is a plain numeric comparison the gate can use.
In short: Four ordered rungs from read-only up to unattended-with-limits; a human moves the agent up on evidence and down on any misstep.
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)
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.
OBSERVE = 1is the safest floor: the agent may only read (look at logs, list pods). It cannot change anything.RECOMMEND = 2adds reversible actions that leave a reviewable artifact — chiefly opening a PR a human can inspect before it merges.ACT = 3adds bigger reversible/significant actions, but each one still needs human approval.AUTONOMOUS = 4is 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
This is the heart of the whole lab, and the surprising thing about it is how dumb it is. You might expect the safety brain of an autonomous agent to be sophisticated — some clever model that weighs context and judges intent. It's the opposite. The most trustworthy part of the system is a plain lookup table with sixteen cells, no intelligence at all. And that's precisely why you can trust it: a lookup table can't be talked out of its answer, can't have a bad day, and can't be jailbroken. Cleverness is a liability in a safety control; predictability is the feature.
Think of it like the bouncer's laminated card at a venue, not the bouncer's mood. The card says: this wristband colour (your rung) plus this door (the action's risk) equals in, ask-the-manager, or absolutely-not. The bouncer doesn't improvise or get persuaded by a good story — they read the card. Our gate is that card in code: a function evaluate(risk, rung) that takes two inputs — how risky the requested action is, and how much autonomy the agent currently has — and returns exactly one of three verdicts: allow (run it), ask (pause for a human yes/no), or block (refuse, always). Cross the four risk classes with the four rungs and you get the sixteen-cell grid in the diagram below. Every possible decision the agent could face is already answered, in advance, by a human.
The property that makes this bulletproof is where the gate lives: entirely outside the model. The model can propose any action it likes — it can be confused, prompt-injected, or actively hijacked (the threat model in T1) — but its output is just a request. The gate is a separate function that inspects that request against a fixed table the model never sees and cannot edit. So a compromised model can change what it asks for; it can never change what the table permits. The classic mistake people make is putting this logic where the model can reach it — "I'll have the model classify its own action's risk and decide" — which hands the fox the keys to the henhouse. Risk is tagged on the tool, in code; the verdict is computed by a function, in code; and — read the bottom row of the matrix before you read another word — the IRREVERSIBLE row is block in every single column, including the most autonomous one. That one immovable row is the promise the entire system is built to keep, and Step 6 turns it into a test.
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_ONLYis green everywhere — reading is always safe. Now read the bottom row:IRREVERSIBLEis 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.
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}")
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.
_MATRIXhas one entry per rung. Each entry maps aRiskClassto a verdict string:"allow","ask", or"block". Compare any row to the same-coloured row in the diagram — they match exactly.- Look down the
IRREVERSIBLEkey in all four rows: it is"block"every time, including atAUTONOMOUS. That single fact is the most important safety property in the whole build. 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.- It wraps that verdict in a
PolicyDecisionobject 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".
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
A lock only protects you if it's on the door — a brilliant lock sitting on the workbench stops nobody. In Step 3 you built a perfect little safety table, fully tested, provably correct. And right now it protects absolutely nothing, because the agent loop from Lab 8b doesn't call it: the model asks for a tool, and the loop just runs it. This step is the small, unglamorous, and utterly critical act of bolting the lock onto the door — inserting exactly one checkpoint that every tool call must pass through before it executes.
The mental model is airport security, and the key word is chokepoint. It doesn't matter which gate you're flying from or how important you are — there is one line everyone funnels through, and nobody reaches the aircraft without passing it. Our chokepoint sits in exactly one place: between "the model produced a tool_use request" and "the tool actually runs." At that point the loop calls evaluate(tool.risk, rung), reads the verdict, and branches — allow runs it immediately, ask pauses and calls a human approval function, block refuses and hands back a message instead of a result. Because the check is structural — it's the only path from request to execution — the read-only loop you had in Lab 8b instantly becomes safe for all tools, including the dangerous ones you were previously too scared to register.
Two design choices in this wiring are easy to skip and expensive to get wrong. First, the gate runs on every attempt and writes an audit record either way — allowed or refused, nothing happens off the books (that log is Step 5). The tempting shortcut is to only log the actions that ran; resist it, because "what did the agent try to do" is often the most important line in an incident review. Second — and this is the subtle, delightful part — when an action is blocked, you don't just drop it silently. You return the string "BLOCKED by policy: …" back to the model as the tool's result. Now the model sees that its delete was refused and why, so on the next turn it adapts: it proposes opening a PR instead, or asks the human, or rolls back. A guardrail that explains itself doesn't just stop bad actions — it steers the agent toward the good one. The mistake here is treating "blocked" as a dead end (an exception, a silent failure) rather than as feedback the agent can learn from within the same conversation.
evaluate(): allow runs it, ask defers to a human, block hands a self-explaining message back to the model. Both outcomes are written to the audit log — nothing happens off the record.
Trace it left to right, then notice the two grey dotted lines dropping down to the log at the bottom.
- The request box on the left is the model asking to run a tool — just a request, nothing has happened yet.
- The amber
evaluate()box is the single chokepoint: like airport security, every tool call funnels through this one function before it can run. - The three branches are the verdict: allow goes straight to the green
tool.run(); ask pauses for a human; block goes to the red box that returns"BLOCKED by policy: …"to the model instead of a result. - The grey dotted lines to
audit.record()are the second guarantee: both the run and the block write a record, so every attempt — successful or refused — is on the books.
In short: Every tool request must pass one gate; the verdict runs it, asks a human, or refuses with an explanation the model can learn from — and either way it's logged.
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]))
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.
tool = TOOLS[block.name]looks up the tool the model asked to use. Every tool carries atool.risktag (its risk class).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.- The verdict branches:
"allow"setsallowed = Truestraightaway;"ask"callsapproval_fn(...)so a human decides yes/no (and we remember the approver); a"block"verdict leavesallowed = False. - If allowed,
out = tool.run(**block.input)executes the tool. If not,outbecomes a"BLOCKED by policy: ..."message instead of the result. - Finally,
audit.record(...)writes aToolCallRecordfor 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.
Step 5 · The audit log expert
Picture the meeting the morning after something breaks. A service went down at 2am, the agent was on call, and the room wants one answer before anything else: what did the agent actually do? If your reply is "I think it restarted something, let me check the model's chat history and try to reconstruct it," you have already lost the room — and you have almost certainly lost permission to keep the agent anywhere near production. The audit log exists so that answer is instant, complete, and beyond dispute: every action it attempted, whether it ran, and who approved it.
The right way to picture this is an aircraft's flight recorder — the black box. Its whole job is to sit there recording faithfully so that after something goes wrong, humans can reconstruct exactly what happened, in order, without relying on anyone's memory or the model's own account of itself. Note the emphasis on "not the model's own account": you never want the system being investigated to also be the sole witness. The audit log is written by the loop, in plain code, at the same chokepoint that runs the gate (Step 4) — so it captures reality (the tool call the loop dispatched) rather than the model's story about reality. Ours is deliberately tiny: a module-level list of ToolCallRecord objects with functions to append one, read them back, clear the list, and render a readable ✓/✗ trail. Small on purpose — a black box you can't trust the correctness of is worse than none.
Two things matter more than the code's size. First, you record every attempt, not just the successes — the ✗ lines (actions the gate refused) are frequently the most valuable entries in the whole log, because they're the record of the agent trying something dangerous and being stopped. A log that only shows what ran can't tell you the gate is doing its job. Second, watch the timing gotcha that bites everyone once: call clear() at the start of a run, never the end. Clear-at-the-end feels tidy but it's exactly backwards — it erases the evidence the moment before you'd want to read it, and it means a crash mid-run leaves you with nothing. Clear at the start and the log always holds the current run's full story, ready for that morning-after meeting. This unglamorous list is, in practice, the thing that decides whether a team will ever trust an agent near their infrastructure at all — capability gets you a demo, auditability gets you deployed.
render() replays them as a ✓/✗ trail. The ✗ lines are the proof the gate stopped something dangerous, which is exactly what the post-incident meeting needs.
Read left to right: attempts come in on the left, and the log replays them on the right.
- The left column of boxes is each tool attempt as it happens — green when allowed, red when blocked. Note the blocked
delete_podis recorded too, not dropped. - The arrows show every one of those attempts being appended to the log via
record()— nothing happens off the books. - The right panel is what
render()produces: a readable ✓/✗ trail with the tool name and its risk class in brackets, the exact thing a human reads after an incident. - The caption line at the bottom is the timing gotcha: clear the log at the start of a run so a mid-run crash still leaves the evidence intact.
In short: Append one record per attempt, allowed or not, then render the ✓/✗ trail — the ✗ lines are what prove the gate is doing its job.
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)"
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.
_LOGis a module-level list ofToolCallRecordobjects.record(rec)appends one;entries()returns a copy;clear()empties it (call this at the start of a run, not the end).render()turns the log into readable lines — one per action.- 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. - 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.
Step 6 · Prove it's safe (the test that matters most) expert
"Trust me, it's safe" is not a safety claim — it's a hope. Every step so far built the machinery: the runbooks, the rungs, the gate, the wiring, the log. But a stakeholder who's about to let this thing touch production doesn't want your reassurance, and they shouldn't accept it. They want the difference between saying the agent can't delete prod and proving it — and the only thing that closes that gap is a test they can run themselves and watch pass. This step writes that test. It is, genuinely, the most important code in the entire lab.
Here's the quietly beautiful part, and it's what makes the guarantee credible rather than aspirational: the safety test never calls the model. Because the gate is a pure function over a fixed table (Step 3), you can assert its behaviour directly — no API key, no network, no cost, no flakiness, runs in hundredths of a second. Contrast that with testing the model's behaviour, which is probabilistic and can drift between versions. We're not testing "does the model usually decide not to delete prod?" — that's a coin you can't fully trust. We're testing "can the delete ever be approved, for any rung, no matter what the model decided?" — and that's a fact about a lookup table, provable with certainty. Deterministic guarantees beat probabilistic hopes every time, and this is where you cash that in.
The test to obsess over is the one the comment flags as THE core invariant: a loop over all four rungs asserting that an IRREVERSIBLE action is never returned as allow. That single assertion is the whole promise of the lab reduced to executable form — "destructive actions can never be auto-approved, at any autonomy level" — and if a future refactor ever weakens it, this test goes red before the change reaches anyone's cluster. That's the real payoff: the safety property stops being a paragraph in a design doc that slowly rots and becomes a tripwire wired into CI. The common failure mode here isn't writing the test wrong — it's a subtler one the troubleshooting table catches: the policy test passes, but a live action still slips through because a tool was tagged with the wrong risk class (a "delete" mislabelled REVERSIBLE). The lookup was correct; the input to it lied. So after the pure-logic tests go green, the section finishes with a live run — asking the agent to delete a pod at the OBSERVE rung — and watches the gate refuse it and the audit log stamp the ✗. Unit test proves the table; live run proves the wiring and the tags. Both green, and "it's safe" is finally a statement you can defend.
allow — a complete proof of the table, instant and free. The live run then checks the wiring and the risk tags: the delete is refused and stamped ✗ in the log.
The dotted line down the middle splits the section's two complementary checks — read each column top to bottom.
- The left column is the pure-logic unit test: a
for rung in Rungloop asserting anIRREVERSIBLEaction is never"allow". It never calls the model, so it's instant and free — hence4 passed in 0.02s. - The green box on the left names what that test buys you: it proves the table — a complete, deterministic check of all four rungs, not a probabilistic sample.
- The right column is the live run: asking the agent to delete a pod at OBSERVE. The green box says it proves the wiring and the tags — that the tool is tagged the right risk class and the gate is actually called.
- The red ✗ line at the bottom right is the live proof: the delete was refused and stamped into the audit log, catching the mislabelled-risk failure mode the table test alone can't.
In short: The unit test proves the lookup table is correct; the live run proves the tool tags and wiring feed it the truth. You need both before "it's safe" is defensible.
- The safety unit tests (pure logic, instant, free):
tests/test_policy.py
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"terminal
python -m pytest tests/test_policy.py -vtest_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 - Watch it hold live — ask the agent to do something dangerous at OBSERVE:
terminal
python cli.py "delete the checkout-api pod" # rung defaults to OBSERVEThe 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] ← BLOCKEDThe model wanted to (or was asked to), the gate said no, the audit log shows the ✗, and nothing happened. That's the system working.
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.
test_observe_blocks_all_writesasserts that at theOBSERVErung, read-only is"allow"but every write risk class comes back"block"— proving the read-only rung is truly read-only.test_irreversible_never_allowed_at_any_rungis the one that matters most. Thefor rung in Rungloop walks all four rungs and asserts anIRREVERSIBLEaction is never"allow". The comment calls it THE core invariant — the safety promise the entire system rests on.- The
pytest -vcommand runs these; the greenPASSEDlines and4 passed in 0.02sconfirm the policy behaves exactly as the matrix says. - 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-onlyget_podsand a ✗ for the blockeddelete_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 | Needs API key? | Proves |
|---|---|---|
| OBSERVE blocks all writes | No | Read-only rung is truly read-only |
| irreversible never allowed at any rung | No | The core safety invariant holds |
| RECOMMEND asks for PRs, blocks significant | No | Each rung permits exactly what it should |
| live: delete request at OBSERVE is blocked + audited | Yes | The gate holds end-to-end, with a record |
Troubleshooting expert
| Symptom | Cause | Fix |
|---|---|---|
| Agent ignores the runbook procedure | Runbook 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 OBSERVE | Gate not wired, or tool mis-tagged as READ_ONLY | Confirm the tool's risk= is correct and the loop calls evaluate() before tool.run() |
| Approvals never prompt | approval_fn defaults to deny | Pass --approve (CLI) or your own input()-based function |
| Audit log empty | audit.clear() called after the run, or records not written | Clear at the start of a run; record inside the tool-use branch |
| Safety test passes but live action slips | Tool tagged wrong risk class | Audit each tool's risk; a "restart" is REVERSIBLE, a "delete" is IRREVERSIBLE |
| Model keeps trying the blocked tool | It doesn't see why it failed | Return 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.
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 IntEnumso each rung is also an integer andACT > OBSERVEholds- 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.
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
.mdin 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.
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:
_MATRIXis 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
IRREVERSIBLEcolumn isblock evaluatelooks 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.
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 allowruns it;askdefers to an approval function;blockrefuses- 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).
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
pytestcommand; 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.
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;
recordappends,entriesreturns a copy,clearempties it rendershows ✓/✗ 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
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
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?