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
Step 1 · RAG over runbooks (the onboarding) advanced
A generic agent knows Kubernetes; it doesn't know your procedures. Grounding it in the company's runbooks is what "onboarding into a company" actually means. We use a tiny keyword retriever so it runs with no extra dependencies (swap in the Chapter 3 vector store for real semantic search — same interface).
- 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
Autonomy is a ladder you climb on evidence, not a switch. Model it as an ordered enum.
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
The gate is a lookup: given a tool's risk class and the current rung, return allow, ask, or block. Simple, auditable, and — crucially — testable without the model.
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
Insert the gate between "the model requested a tool" and "the tool runs". Now the read-only loop from Lab 8b becomes safe for all tools.
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
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
The whole point of this lab: demonstrate, with a test that needs no API key, that the gate holds. Then watch it hold live.
- 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?