AI EngineeringZero to ProductionHome·About·What’s new·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

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.

influenceable channel — prompts, logs, docs systemprompt poisoned doc/ confused log modelemits a request code gateplain Python + IAM outside the channel allow block The gate lives outside the model's channel. A prompt, a log line, or a poisoned document can all steer the model's request — but the gate is a separate function they cannot reach, so it decides allow or block regardless.
🗺️ How to read this diagram

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.

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

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.

incident"pod crashing" crashloop.md best match rollout.md runbooks/ folder context_for()word overlap prompt+ runbook model RAG = staple the right page to the question. The incident text scores every runbook by word overlap; the single best doc is pasted into the prompt so the model answers from your procedure, not generic knowledge.
🗺️ How to read this diagram

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.

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

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.

1 · OBSERVEread only 2 · RECOMMEND+ open PRs 3 · ACT+ approved actions 4 · AUTONOMOUS+ allowlist unattended more freedom, earned on evidence → Autonomy is a ladder, not a switch. Each rung grants a little more power than the one below; you promote the agent up only after it proves itself, and demote instantly if it misbehaves. The rung is a knob a human turns — never one the agent grants itself.
🗺️ How to read this diagram

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 Rung is an IntEnum, "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.

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

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.

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

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.

tool_userequest evaluate()the chokepoint allow ask → human block tool.run() "BLOCKED by policy: …"returned to model audit.record() — every attempt One chokepoint, every tool. No tool reaches execution without passing 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.
🗺️ How to read this diagram

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.

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

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.

each attempt get_pods · allowed delete_pod · blocked get_logs · allowed render() → the trail ✓ kubectl_get_pods(...) [read_only] ✗ delete_pod(...) [irreversible] ✓ kubectl_get_logs(...) [read_only] clear() at the START of a run, never the end The flight recorder. Every attempt — allowed or refused — appends one record; 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.
🗺️ How to read this diagram

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_pod is 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.

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

"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.

unit test · no API key for rung in Rung:IRREVERSIBLE never == "allow" proves the table 4 passed in 0.02s live run · needs API key delete pod @ OBSERVEgate refuses · audit ✗ proves wiring + tags ✗ delete_pod [irreversible] Two proofs, not one. The unit test loops every rung and asserts an irreversible action is never 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.
🗺️ How to read this diagram

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 Rung loop asserting an IRREVERSIBLE action is never "allow". It never calls the model, so it's instant and free — hence 4 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.

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