M22C: Agentic Coding Quality Gates & Rich Output
In M25 you configured Claude Code with reusable slash commands and CLAUDE.md; in M26 you wired hooks. This module puts that machinery to work on the highest-stakes case: code an agent writes and commits. You'll add structural linting that catches what regex cannot, pre-commit gates that turn a rejection into a self-correction loop, Claude Code hooks that block unsafe edits before they happen, and rich visual output — an agent that returns a rendered chart, not just prose.
Learning Objectives
- Explain why agent-written code needs stricter deterministic gates than human-written code
- Use structural linting (
ruff,bandit,ast-grep) to catch semantic problems a text search misses - Wire a pre-commit gate and feed its failure back to Claude as a correction loop
- Block unsafe edits at write time with a Claude Code PreToolUse hook
- Layer local pre-commit + server-side CI so a skipped hook still can't reach
main - Produce rich visual output: a tool that renders a chart and returns it as an image content block
- Build a self-correcting commit gate with the Anthropic SDK end to end
Why Agent-Written Code Is Different
BEFORE: Picture a tireless senior-speed developer who never gets bored and is happy to attempt any task at 3 a.m. You ask for a function; seconds later it exists, with tests, looking completely plausible. For routine work this is a superpower — a week of grunt work done before lunch.
PAIN: The trap is that "looks completely plausible" is exactly the failure mode. A human who is unsure pauses, asks, or leaves a TODO. An agent rarely pauses. Under pressure to make a task "done," it can invent an API that does not exist, hardcode a secret to make a test pass, swallow an exception to silence an error, or quietly remove a check it did not understand — at machine speed, across many files, with a commit message that says "done." Human code review, the thing that catches a careless person, cannot keep pace with diffs produced faster than you can read them.
MAPPING: The fix is to stop relying on a human reading every line and put machines in the path: deterministic checks the agent must satisfy before code is allowed to exist. A linterA program that analyzes source code for problems without running it: style violations, likely bugs, unsafe patterns. Deterministic — same input, same verdict every time. that rejects unsafe patterns; a security scanner that finds the hardcoded secret; a pre-commit gateA set of checks that run automatically just before a commit is recorded. If any check fails, the commit is blocked and nothing enters Git history. that blocks the commit and hands the error back. The agent does not get annoyed by strict rules — it reads the error, fixes the code, and tries again. Humans review intent; machines enforce the floor.
A human opens 3–5 reviewable pull requests a day. A Claude coding agent on a loop can open 40+ commits an hour. If your only safety net is "someone reads the diff," you've moved the bottleneck from writing to reviewing — and a tired reviewer rubber-stamps. One hardcoded secret, one swallowed exception, one eval() on user input that slips through is an incident. The gates here run in seconds, identically, on every commit, so human review can focus on "is this the right change?" instead of "did it do something dangerous?"
Structural Code Linting
BEFORE: Your first instinct for "ban eval() in agent output" is a text search: grep -r "eval(" .. Fast, simple, and for about a day it works.
PAIN: Then the false positives and negatives arrive. grep flags self.evaluate() and a comment that says "don't eval this" — noise. Worse, it misses the real thing: e = eval; e(user_input), or getattr(builtins, "ev"+"al"), or the dangerous call on a line your regex never anticipated. Text search sees characters, not meaning; it cannot tell a function call from the same letters in a string, so it is simultaneously too noisy and too leaky to be a security gate.
MAPPING: A structural linterA linter that analyzes the code's Abstract Syntax Tree (its parsed grammatical structure) rather than its raw text — matching the shape of code, immune to spacing, aliasing, and string look-alikes. reads code the way the compiler does: it parses the source into an ASTAbstract Syntax Tree: a tree representation of source code's structure. eval(x) becomes a Call node whose function is the name eval. Matching the tree is immune to whitespace, comments, and string look-alikes. and matches on the shape of the code — "a call to eval with any argument" — deterministically, in milliseconds. ruff (style + bugs), bandit (security), and ast-grep (your own structural rules) all do exactly this.
| Tool | Catches | Example it flags in agent code |
|---|---|---|
ruff | Style violations & likely bugs (a fast linter + formatter) | Unused import, undefined name, mutable default arg |
bandit | Known security anti-patterns in Python | Hardcoded password, subprocess with shell=True, eval() |
ast-grep | Your own structural rules, any language | "No except: pass", "no print in library code" |
The first two are batteries-included. ast-grep is where you encode your policy. Here is a rule banning the swallowed exception an agent loves to use to make an error "go away," plus how all three run.
WHY: It catches
except Exception: pass however spaced or named, and never false-matches a stringGOTCHA: Structural rules match the parse tree — test them on real code so a broad pattern doesn't flag legitimate uses
# rules/no-swallowed-except.yml — run: ast-grep scan -r rules/no-swallowed-except.yml .
id: no-swallowed-except
language: python
severity: error
message: "Swallowed exception: handle the error or re-raise, never 'except: pass'."
rule:
kind: try_statement
has:
kind: except_clause
has:
kind: block
has:
kind: pass_statement
not:
has:
kind: expression_statement # the except body is ONLY a bare 'pass'
# Install once (all free, all local):
pip install ruff bandit
# ast-grep: brew install ast-grep | cargo install ast-grep | npm i -g @ast-grep/cli
ruff check db.py
# db.py:3:1: F401 [*] `os` imported but unused
bandit -q db.py
# >> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2'
# Severity: Low Confidence: Medium Location: db.py:7
ast-grep scan -r rules/no-swallowed-except.yml db.py
# error[no-swallowed-except]: Swallowed exception: handle the error or re-raise.
# --> db.py:12:5
# Any non-zero exit = the gate fails. Chain them:
ruff check db.py && bandit -q db.py && ast-grep scan -r rules/ db.py
A regex gate is security theater you'll regret. eval (user_input) with a space, builtins.eval(x), or aliasing ev = eval all sail past grep "eval" but are identical to the AST: a call whose target resolves to eval. A structural rule matches the call node itself, so spacing, aliasing, and comments cannot evade it, and a string literal "see the eval docs" cannot trigger a false alarm. For a gate deciding whether agent code reaches production, that determinism is the whole point.
You now have three deterministic judges of agent code — ruff, bandit, and your own ast-grep policy — each exiting non-zero on a violation. That shared exit-code contract is what lets a gate branch on them. Next: put these judges in front of the commit so failing code physically cannot enter history.
Pre-Commit Gates & the Correction Loop
A pre-commit gate is a set of checks Git runs automatically just before a commit is recorded. If any check fails, the commit is blocked — nothing enters history. The open-source pre-commit framework manages these as hooks in a .pre-commit-config.yaml: whitespace fixers, ruff, bandit, ast-grep, secret scanners. For a human it's a safety net; for an agent it's a teacher — the agent hits the failing gate, reads the error, fixes the code, and re-commits. The gate turns a vague "write good code" into a concrete, machine-checkable loop.
The animation traces that loop — the most important idea in this module. The agent writes code and tries to commit. The gate runs and rejects it. The agent reads the specific error, fixes exactly that, and commits again. This time the gate passes and the commit lands. The rejection is feedback, not a dead end.
Configuring the Gate
WHY: One file makes every commit — human or agent — clear the same floor before it can be recorded
GOTCHA: Order matters; cheap auto-fixers first so expensive checks run on already-tidied files
# .pre-commit-config.yaml — the gate every commit must pass
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-added-large-files # block a 200MB blob from an agent gone wrong
- id: check-yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args: ["-q"]
- repo: local
hooks:
- id: ast-grep-policy
name: ast-grep structural rules
entry: ast-grep scan -r rules/
language: system
types: [python]
pip install pre-commit
pre-commit install # hooks now fire on every `git commit`
# A blocked commit (agent committed a hardcoded password):
$ git commit -m "add db client"
ruff................................................................Passed
bandit..............................................................Failed
- hook id: bandit
- exit code: 1
>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2'
Location: db.py:7
# --> the commit did NOT happen. Nothing entered history.
pre-commit run --all-files ; echo "gate exit=$?" # run the gate by hand anytime
Closing the Loop with the Anthropic SDK
When the gate rejects a commit, feed its exact output back to Claude and ask for a fix — the correction loop, in code.
WHY: The "gate as teacher" pattern — the agent improves from the real error, not a guess
GOTCHA: Cap retries; stop on a clean pass or exhaustion, never loop on the gate forever
# commit_with_fixups.py — let the gate teach Claude until the commit is clean
# WHAT: Try to commit; if the gate blocks it, send the error to Claude, fix, retry
# WHY: Turns "write good code" into a concrete loop graded by deterministic hooks
# GOTCHA: Bound the retries; a gate Claude can't satisfy must fail loudly, not spin
import subprocess, sys
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from env
MODEL = "claude-sonnet-4-6"
TARGET = "db.py"
def run_gate() -> tuple[bool, str]:
proc = subprocess.run(["pre-commit", "run", "--files", TARGET],
capture_output=True, text=True, timeout=120)
return proc.returncode == 0, proc.stdout + proc.stderr
def ask_fix(code: str, gate_output: str) -> str:
resp = client.messages.create(
model=MODEL, max_tokens=2000,
system="You are a Python engineer. Return ONLY the corrected file, no prose, no fences.",
messages=[{"role": "user", "content":
f"This file was rejected by our commit gate.\n\nFILE ({TARGET}):\n{code}\n\n"
f"GATE OUTPUT:\n{gate_output}\n\nReturn the full corrected file."}])
return resp.content[0].text.strip() # first text block = the fixed file
def commit_clean(max_fixups: int = 3) -> int:
for attempt in range(max_fixups + 1):
subprocess.run(["git", "add", TARGET], check=True)
passed, output = run_gate()
if passed:
subprocess.run(["git", "commit", "-m", "agent change (gate-clean)"], check=True)
print(f"[gate] passed on attempt {attempt}; commit landed", file=sys.stderr)
return 0
print(f"[gate] blocked on attempt {attempt}:\n{output}", file=sys.stderr)
if attempt == max_fixups:
break
fixed = ask_fix(open(TARGET, encoding="utf-8").read(), output)
open(TARGET, "w", encoding="utf-8").write(fixed) # Claude's correction
print("[gate] still failing after retries — escalate to a human", file=sys.stderr)
return 2 # bad output: do not auto-merge
if __name__ == "__main__":
sys.exit(commit_clean())
// commit_with_fixups.js — let the gate teach Claude until the commit is clean
// WHAT: Try to commit; if the gate blocks it, send the error to Claude, fix, retry
// WHY: Turns "write good code" into a concrete loop graded by deterministic hooks
// GOTCHA: Bound the retries; a gate Claude can't satisfy must fail loudly, not spin
import fs from "node:fs";
import { execFileSync } from "node:child_process";
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic(); // reads ANTHROPIC_API_KEY from env
const MODEL = "claude-sonnet-4-6";
const TARGET = "db.py";
function runGate() {
try {
execFileSync("pre-commit", ["run", "--files", TARGET], { stdio: "pipe", timeout: 120000 });
return { passed: true, output: "" };
} catch (e) {
return { passed: false, output: (e.stdout || "") + (e.stderr || "") };
}
}
async function askFix(code, gateOutput) {
const resp = await client.messages.create({
model: MODEL, max_tokens: 2000,
system: "You are a Python engineer. Return ONLY the corrected file, no prose, no fences.",
messages: [{ role: "user", content:
`This file was rejected by our commit gate.\n\nFILE (${TARGET}):\n${code}\n\n` +
`GATE OUTPUT:\n${gateOutput}\n\nReturn the full corrected file.` }],
});
return resp.content[0].text.trim(); // first text block = the fixed file
}
async function commitClean(maxFixups = 3) {
for (let attempt = 0; attempt <= maxFixups; attempt++) {
execFileSync("git", ["add", TARGET]);
const { passed, output } = runGate();
if (passed) {
execFileSync("git", ["commit", "-m", "agent change (gate-clean)"]);
console.error(`[gate] passed on attempt ${attempt}; commit landed`);
return 0;
}
console.error(`[gate] blocked on attempt ${attempt}:\n${output}`);
if (attempt === maxFixups) break;
fs.writeFileSync(TARGET, await askFix(fs.readFileSync(TARGET, "utf8"), output));
}
console.error("[gate] still failing after retries — escalate to a human");
return 2; // bad output: do not auto-merge
}
process.exit(await commitClean());
The gate and Claude are now in a closed loop. Claude commits; the deterministic hooks judge; on rejection the exact gate output goes back to the model, which fixes that specific thing and re-commits. Success lands the commit and returns 0; exhausting retries returns 2 so an outer system escalates instead of merging junk. No human read a line — yet nothing unsafe could enter history.
Gating Edits at Write Time with a Claude Code Hook
A Claude Code PreToolUse hookA command Claude Code runs *before* it executes a matching tool (e.g. Write or Edit). The hook receives the tool's input on stdin. Exiting with code 2 blocks the tool call and returns the hook's stderr to Claude as a reason to reconsider. runs before Claude executes a tool like Write or Edit. The hook receives the proposed tool input on stdin. If it exits with code 2, Claude Code blocks the tool call and feeds the hook's stderr back to Claude as a reason. So instead of catching a hardcoded secret at commit time, you can refuse to even write it — and tell Claude why, so it self-corrects on the spot. This is the M26 hook machinery applied to code safety.
WHY: Stops a bad pattern from ever hitting disk, and tells Claude why so it fixes itself in-session
GOTCHA: Exit code 2 (not 1) is the blocking signal; stderr is what Claude reads as the reason
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{ "type": "command", "command": "python .claude/gate_hook.py" }
]
}
]
}
}
# .claude/gate_hook.py — block an unsafe Write/Edit before it touches disk
# WHAT: Read the proposed tool input, lint the new content, exit 2 to block
# WHY: Catches the problem at WRITE time and tells Claude the reason to self-correct
# GOTCHA: Claude Code passes the tool input as JSON on stdin; exit 2 = block + show stderr
import sys, json, subprocess, tempfile, os
data = json.load(sys.stdin) # the PreToolUse payload
tool_input = data.get("tool_input", {})
# Write provides 'content'; Edit provides 'new_string'
content = tool_input.get("content") or tool_input.get("new_string") or ""
path = tool_input.get("file_path", "")
if not path.endswith(".py") or not content.strip():
sys.exit(0) # not Python / nothing to check: allow
# Lint the PROPOSED content via a temp file
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
f.write(content); tmp = f.name
try:
bandit = subprocess.run(["bandit", "-q", tmp], capture_output=True, text=True)
astg = subprocess.run(["ast-grep", "scan", "-r", "rules/", tmp],
capture_output=True, text=True)
finally:
os.unlink(tmp)
problems = ""
if bandit.returncode != 0: problems += bandit.stdout + bandit.stderr
if astg.returncode != 0: problems += astg.stdout + astg.stderr
if problems:
# stderr is shown to Claude; exit 2 BLOCKS the Write/Edit so Claude reconsiders
print(f"Blocked: the edit to {path} fails the quality gate.\n{problems}", file=sys.stderr)
sys.exit(2)
sys.exit(0) # clean: allow the write
A favorite exam theme: when to use a hook versus a prompt instruction. "Please don't commit secrets" in CLAUDE.md is a probabilistic request the model may ignore under pressure. A PreToolUse hook that exits 2 is a deterministic guarantee enforced outside the model. The rule of thumb the cert rewards: if a requirement must hold every time (security, compliance, irreversible actions), encode it as a hook or an external gate — never rely on prompting alone.
You now have defense at two moments: the PreToolUse hook refuses to write an unsafe edit (and tells Claude why, so it fixes in-session), and the pre-commit gate refuses to commit one that slipped through. Both are deterministic, both run in seconds, and both hand the model a specific reason rather than a vague "be careful."
--no-verify. The authoritative gate has to live somewhere the agent can't bypass: CI.Two Layers: Pre-Commit + CI
A local pre-commit hook protects your machine, but it can be skipped (git commit --no-verify), misconfigured, or absent on a fresh clone. So the same checks must run again on a clean server after the code is pushed — in CIContinuous Integration: a server that automatically runs your checks on every push, on a fresh clean machine. It can't be skipped with --no-verify, so it is the authoritative gate before merge.. Two layers: pre-commit catches mistakes before the commit; CI catches them before the merge.
WHY: CI can't be skipped with
--no-verify, so it's the authoritative gate before mergeGOTCHA: Agents push many small commits fast — concurrency cancels stale runs so you don't waste CI on outdated code
# .github/workflows/gate.yml — the same checks, re-run where they can't be skipped
name: Quality gate
on: [push, pull_request]
# Agents push many small commits; cancel superseded runs to save CI minutes.
concurrency:
group: gate-${{ github.ref }}
cancel-in-progress: true
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- name: Install the gate
run: pip install pre-commit
# Re-run the IDENTICAL hooks on a clean machine. Non-zero exit blocks the merge.
- name: Run pre-commit on all files
run: pre-commit run --all-files --show-diff-on-failure
--no-verify Exists
Local hooks are advisory: anyone — or any agent — can bypass them with git commit --no-verify, and a fresh clone that never ran pre-commit install has no hooks at all. If local were your only gate, the one commit that skips it is the one that breaks production. CI re-running the same config on a clean server, gating a protected branch that refuses merges on a red check, is what makes the floor non-optional. Pre-commit is the fast feedback you want; CI is the guarantee you need.
Rich & Visual Output
Visual output generation is an agent producing a visual artifact — a chart, a diagram, a rendered image — as the result of its work, not just describing one. Claude does not draw pixels; it calls a tool that renders the visual, and the tool returns an image content blockA piece of message content of type "image" carrying base64 bytes and a media type. Claude's tool_result can include image blocks, so a tool can hand back a rendered picture, and Claude can even "see" the image it produced to verify it.. So "plot last quarter's filings by month" ends with an actual chart, generated deterministically by code Claude orchestrated — and because Claude can see the returned image, it can verify the chart matches the request.
A number in a sentence and a trend line tell a human very different stories. The key engineering choice: keep the rendering in deterministic code and let Claude decide when and with what data to call it. Here is a chart tool wired into the Messages API tool loop, returning an image tool_result.
render_chart tool whose result is an image block; Claude orchestrates when to call itWHY: Lets the agent answer "show the trend" with a real, deterministic visual it can also verify
GOTCHA: Return the image as a base64
tool_result block — not a file path the model can't open# chart_agent.py — Claude calls a render_chart tool that returns an IMAGE. pip install anthropic matplotlib
# WHAT: A tool loop where the tool result is a rendered PNG, not text
# WHY: The model decides what to chart; deterministic code draws it correctly
# GOTCHA: Use the headless 'Agg' backend; return an image content block in tool_result
import io, base64, json
import matplotlib
matplotlib.use("Agg") # no display on a server
import matplotlib.pyplot as plt
from anthropic import Anthropic
client = Anthropic()
MODEL = "claude-sonnet-4-6"
TOOLS = [{
"name": "render_chart",
"description": "Render a bar chart from labels and values; returns a PNG image.",
"input_schema": {
"type": "object",
"properties": {
"labels": {"type": "array", "items": {"type": "string"}},
"values": {"type": "array", "items": {"type": "number"}},
"title": {"type": "string"},
},
"required": ["labels", "values"],
},
}]
def render_chart(labels, values, title="") -> str:
fig, ax = plt.subplots(figsize=(6, 3.5))
ax.bar(labels, values, color="#3B82F6")
ax.set_title(title); fig.tight_layout()
buf = io.BytesIO(); fig.savefig(buf, format="png", dpi=120); plt.close(fig)
return base64.standard_b64encode(buf.getvalue()).decode() # base64 PNG
def run(prompt: str):
messages = [{"role": "user", "content": prompt}]
for _ in range(6): # bounded tool loop
resp = client.messages.create(model=MODEL, max_tokens=1024,
tools=TOOLS, messages=messages)
messages.append({"role": "assistant", "content": resp.content})
if resp.stop_reason != "tool_use":
return next((b.text for b in resp.content if b.type == "text"), "")
tool_results = []
for block in resp.content:
if block.type == "tool_use" and block.name == "render_chart":
png_b64 = render_chart(**block.input)
open("chart.png", "wb").write(base64.b64decode(png_b64)) # save artifact
tool_results.append({
"type": "tool_result", "tool_use_id": block.id,
"content": [{"type": "image", "source": {
"type": "base64", "media_type": "image/png", "data": png_b64}}],
})
messages.append({"role": "user", "content": tool_results})
if __name__ == "__main__":
print(run("Chart these monthly filing counts: Jan 12, Feb 19, Mar 27. Title it Q1 Filings."))
// chart_agent.js — Claude calls a render_chart tool that returns an IMAGE.
// npm i @anthropic-ai/sdk chartjs-node-canvas chart.js
import fs from "node:fs";
import Anthropic from "@anthropic-ai/sdk";
import { ChartJSNodeCanvas } from "chartjs-node-canvas";
const client = new Anthropic();
const MODEL = "claude-sonnet-4-6";
const canvas = new ChartJSNodeCanvas({ width: 600, height: 350 });
const TOOLS = [{
name: "render_chart",
description: "Render a bar chart from labels and values; returns a PNG image.",
input_schema: {
type: "object",
properties: {
labels: { type: "array", items: { type: "string" } },
values: { type: "array", items: { type: "number" } },
title: { type: "string" },
},
required: ["labels", "values"],
},
}];
async function renderChart({ labels, values, title = "" }) {
const png = await canvas.renderToBuffer({
type: "bar",
data: { labels, datasets: [{ data: values, backgroundColor: "#3B82F6" }] },
options: { plugins: { title: { display: !!title, text: title } } },
});
return png.toString("base64");
}
async function run(prompt) {
const messages = [{ role: "user", content: prompt }];
for (let i = 0; i < 6; i++) { // bounded tool loop
const resp = await client.messages.create({ model: MODEL, max_tokens: 1024,
tools: TOOLS, messages });
messages.push({ role: "assistant", content: resp.content });
if (resp.stop_reason !== "tool_use")
return resp.content.find((b) => b.type === "text")?.text ?? "";
const toolResults = [];
for (const block of resp.content) {
if (block.type === "tool_use" && block.name === "render_chart") {
const pngB64 = await renderChart(block.input);
fs.writeFileSync("chart.png", Buffer.from(pngB64, "base64")); // save artifact
toolResults.push({ type: "tool_result", tool_use_id: block.id,
content: [{ type: "image", source: {
type: "base64", media_type: "image/png", data: pngB64 } }] });
}
}
messages.push({ role: "user", content: toolResults });
}
}
console.log(await run("Chart these monthly filing counts: Jan 12, Feb 19, Mar 27. Title it Q1 Filings."));
The animation shows the full path: a question comes in, Claude reasons it needs a picture, calls render_chart with structured data, and the rendered image returns as the artifact.
It's tempting to ask a model to "draw" by emitting SVG or ASCII art directly. Don't — that wastes tokens and produces inconsistent visuals. The robust pattern is the one here: the model supplies structured data and intent, and battle-tested rendering code (matplotlib, Chart.js) produces a correct image every time. Returning it as an image block has a bonus only multimodal models like Claude get: the model can look at its own output and catch "the axis labels are wrong" before handing it to the user. The agent decides what to chart; the tool draws it right; Claude verifies.
Lab: A Self-Correcting Commit Gate
What you'll build: a workflow where Claude writes a small module, a pre-commit gate rejects unsafe code, and the correction loop drives Claude to a clean, committed version — with a PreToolUse hook catching the worst cases even earlier. Time: ~40 min. Prereqs: ANTHROPIC_API_KEY set; git; Python 3.12.
mkdir gate-lab && cd gate-lab && git init
pip install anthropic ruff bandit pre-commit
# ast-grep: brew install ast-grep (or) cargo install ast-grep (or) npm i -g @ast-grep/cli
mkdir -p rules .claude
export ANTHROPIC_API_KEY=sk-ant-... # your key
Create .pre-commit-config.yaml (all four layers) and rules/no-swallowed-except.yml. Run pre-commit install, then pre-commit run --all-files to confirm the gate is live on an empty repo.
Drop .claude/settings.json and .claude/gate_hook.py from the hooks section into the repo. In a Claude Code session, ask Claude to write db.py with a hardcoded password and watch the Write get blocked with the bandit reason — Claude should immediately offer a fixed version.
Outside Claude Code, prompt the Anthropic SDK to "write db.py that connects to Postgres." Models sometimes hardcode a password or wrap connect in try/except: pass. Write the reply to db.py (inject password = "hunter2" if needed to see the gate fire).
Run commit_with_fixups.py. It runs the gate, feeds the exact failure back to Claude, writes the corrected file, and retries up to 3 times. Watch stderr go "blocked on attempt 0" then "passed on attempt 1" as the secret and exception handling are fixed.
Add .github/workflows/gate.yml. Confirm the contract: it re-runs pre-commit run --all-files on a clean machine, so a teammate who used --no-verify is still caught before merge on a protected branch.
A hardcoded-secret Write is blocked at write time by the hook; a flawed commit is blocked by the gate (nothing in git log); the correction loop lands exactly one gate-clean commit; exhausting retries returns exit 2 and escalates; and you can explain why CI is required even though the local gate passed. That's a production-shaped agentic coding workflow: humans review intent, machines enforce the floor.
Knowledge Check
Test your understanding of structural linting, pre-commit gates, Claude Code hooks, and rich output.
1. Why does agent-written code need stricter automated gates than human-written code?
2. Why is a structural linter (AST-based) better than grep "eval(" as a security gate?
3. The module calls a failing pre-commit gate a "teacher" for an agent. Why?
4. In a Claude Code PreToolUse hook, what exit code blocks the tool call and returns a reason to Claude?
5. You have a local pre-commit gate and a PreToolUse hook. Why is a CI job re-running the checks still necessary?
--no-verify), disabled, or absent on a fresh clone; CI re-runs on a clean server and gates a protected branch, so the floor is non-optional