Guards: rules that block instead of asking
A rule in AGENTS.md is a request: the agent usually follows it. A guard is code that runs on the action itself and refuses it, whoever forgot the rule and whoever asked.
- Rules shape what the agent tries. Guards decide what happens.
- Permission rules and a PreToolUse hook stop the agent; git hooks stop anyone who commits; a guard test stops a change that breaks an invariant.
- Each has a hole the next covers; none is a security boundary. You add all four, then try to break your repo.
Lesson 6: you removed the transaction from PlaceAsync, and every test stayed green. Why?
No test sent orders at the same time, so nothing could fail. This lesson adds one: a guard test.
Lesson 3, trap A: the agent tried building from /tmp to dodge the SDK pin. What stopped it?
The rule said "do not work around it", and the agent tried anyway. The harness asking permission stopped it. This lesson builds more of that.
What happened
Failed run
Late in a long session, the agent committed without being asked. The developer then asked the agent to force-push, to rewrite the remote branch. The agent ran it.
Only the git server's branch protection refused. Nothing on the developer's machine would have.
A real session, anonymised
With the harness
The tool call is refused before git runs, with a reason the agent passes on:
Force-push rewrites shared history. Ask the human; they push, not the agent.
A deny rule and a hook both cover it. Neither depends on the agent remembering, or on the human not asking.
Rule versus guard
The docs put it plainly: rules in your prompt or CLAUDE.md "shape what Claude tries to do, but they don't change what Claude Code allows" (Permissions). A guard is code that runs when the action happens and can refuse it. Four terms, once:
- Hook: a script a tool or git runs automatically at a named moment ("a command is about to run"). Like a database trigger, for your workflow.
- stdin: the input stream a program reads. Claude Code sends a hook the tool call as JSON on stdin.
- Exit code: the number a script ends with; 0 means fine. Claude Code and git read non-zero codes differently (below).
- core.hooksPath: the git setting naming your git hooks folder. It lives in
.git/config, which is never committed, so every clone sets it.
The stack you build today, and which layer stops which bad action:
1 · Permission rules
A permission rule matches a tool call by its text: allow (no prompt), ask (you say yes) or deny (refused, in every mode). Deny is checked first, then ask, then allow; the first match wins.
{
"permissions": {
"allow": [
"Bash(dotnet build *)", "Bash(dotnet test *)", "Bash(dotnet format *)",
"Bash(bash .agents/skills/quality-gate/gate.sh)",
"Bash(git status *)", "Bash(git diff *)", "Bash(git log *)", "Bash(git add *)",
"Bash(sqlite3 -readonly -safe *)",
"Bash(git config user.email)"
],
"ask": [
"Bash(git commit *)", "Bash(git push *)", "Bash(git rebase *)", "Bash(git reset *)",
"Bash(git restore *)", "Bash(dotnet add *)", "Bash(rm *)"
],
"deny": [
"Bash(git push --force*)", "Bash(git push * --force*)",
"Bash(git push -f*)", "Bash(git push * -f*)",
"Bash(git commit --no-verify*)", "Bash(git commit * --no-verify*)",
"Read(./.env)", "Edit(./.env)",
"Bash(git config user.email *@*)", "Bash(git config --global user.email *@*)"
]
},
"attribution": { "commit": "", "pr": "", "sessionUrl": false }
}
- ask covers commit, push, history changes, deletes and
dotnet add. A new package gets the course's 14-day rule, a supply-chain precaution: no package version younger than 14 days, because poisoned releases are usually caught within days. - deny can't be approved from a prompt: force-push,
--no-verify(skips git hooks),.env, changing your git email. - attribution stops the
Co-Authored-Bytrailer on commits and the "generated with" line on pull requests. Keep the object form:falseneeds v2.1.281+, and older versions skip the whole file, deny rules included. verified 2026-10-01
Limits. A Bash rule matches text, so it "isn't a security boundary around the program": another spelling can slip past, which is why force-push is also in the hook. A trailing * also matches the bare command: denying Bash(git config user.email *) would block the commit skill's plain read of the email, hence *@*. And project allow rules wait for the "trust this folder" dialog; deny and ask rules apply anyway.
2 · The PreToolUse hook
A PreToolUse hook runs before a tool call. It gets the call on stdin, for example {"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}, and can refuse it with a reason that tells the agent what to do instead. The course's destructive-guard.sh refuses force-push, history rewrites, recursive deletes, dropping the database, starting or installing software, and leaving net8.0. One of its checks:
check 'git[[:space:]]+push.*(--force|[[:space:]]-f([[:space:]]|$)|--force-with-lease)' \ && deny "Force-push rewrites shared history. Ask the human; they push, not the agent."
deny prints a JSON "permissionDecision":"deny" and exits 0 (exit 2 also blocks). From the hooks reference:
- Exit 1 does not block. Claude Code "treats exit code 1 as a non-blocking error and proceeds with the action". In a git hook, any non-zero exit blocks: the classic porting mistake.
- Silence is not approval. Exit 0 with no output means no decision; your permission rules still apply, and a hook can't override them.
- A hook that can't start is off. "A mistyped path in
settings.jsonleaves the gate silently disabled." Watch for a "non-blocking status code" notice.
Scope: the matcher is Bash, so edits made with the Edit tool never reach it. It greps the whole command, so even a commit message mentioning git rebase is refused; a false alarm is the cheap way to fail. The same block registers two helpers that never block: a SessionStart preflight the agent reads at the start, and a PostToolUse formatter for C# files.
Going deeper: the hooks block and the three scripts (you copy these in step 2)
} of "attribution" first. "hooks": {
"SessionStart": [
{ "hooks": [ { "type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR}/.claude/hooks/session-start.sh\"" } ] }
],
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [ { "type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR}/.claude/hooks/destructive-guard.sh\"" } ] }
],
"PostToolUse": [
{ "matcher": "Edit|Write",
"hooks": [ { "type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR}/.claude/hooks/format-on-write.sh\"",
"timeout": 120 } ] }
]
}
#!/usr/bin/env bash
# PreToolUse guard for Bash: deny commands that rewrite history, destroy data or act on the machine.
# Input: the tool call as JSON on stdin. Output: a deny decision as JSON, or nothing (= normal permission flow).
# No jq needed: we match patterns against the raw JSON text.
input=$(cat)
deny() {
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$1"
exit 0
}
# Extract the command string for readable matching (falls back to the whole payload).
cmd=$(printf '%s' "$input" | sed -n 's/.*"command"[[:space:]]*:[[:space:]]*"\(\([^"\\]\|\\.\)*\)".*/\1/p')
[ -z "$cmd" ] && cmd=$input
check() { printf '%s' "$cmd" | grep -Eqi -- "$1"; }
check 'git[[:space:]]+push.*(--force|[[:space:]]-f([[:space:]]|$)|--force-with-lease)' && deny "Force-push rewrites shared history. Ask the human; they push, not the agent."
check 'git[[:space:]]+(reset[[:space:]]+--hard|clean[[:space:]]+-[a-z]*f|filter-branch|filter-repo)' && deny "Destroys work or history. Present a 1-3-1 instead."
check 'git[[:space:]]+(rebase|commit[[:space:]].*--amend)' && deny "History rewriting is not allowed (docs/ai/workflow.md). Make a new commit instead."
check 'rm[[:space:]]+-[a-z]*r[a-z]*f|rm[[:space:]]+-[a-z]*f[a-z]*r|Remove-Item.*-Recurse' && deny "Recursive delete blocked. Name the exact files and ask the human."
check 'drop[[:space:]]+(table|database)|dotnet[[:space:]]+ef[[:space:]]+database[[:space:]]+drop|ensuredeleted' && deny "Dropping a database is a human decision. Present a 1-3-1."
check '(Start-Process|open[[:space:]]+-a|systemctl[[:space:]]+start|service[[:space:]]+[a-z-]+[[:space:]]+start|Docker Desktop)' && deny "Starting software on the machine is the human's call (invariants: environment). Ask them to start it."
check '(apt|apt-get|dnf|yum|brew|winget|choco)[[:space:]]+install|dotnet-install' && deny "Installing software is the human's call. Present a 1-3-1."
check 'TargetFramework>net[0-7]\.|net7\.0|net6\.0' && deny "Target framework is net8.0 (AGENTS.md). A missing SDK is a 1-3-1, not a downgrade."
exit 0
#!/usr/bin/env bash
# SessionStart: plain stdout becomes context for the agent. Report the environment, and never fix it.
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
echo "Session preflight (report only; if anything is missing, use 1-3-1, do not install or start it):"
sdks=$(dotnet --list-sdks 2>/dev/null | awk '{print $1}' | tr '\n' ' ')
case " $sdks" in
*" 8."*) echo "- .NET SDK: OK (installed: $sdks). Target framework must stay net8.0." ;;
*) echo "- .NET SDK: MISSING 8.x (installed: ${sdks:-none}). Do NOT downgrade net8.0. Present a 1-3-1." ;;
esac
if docker info >/dev/null 2>&1; then echo "- Docker: running"; else echo "- Docker: not running (do not start it; ask the human if you need it)"; fi
if [ "$(git config core.hooksPath 2>/dev/null)" = ".githooks" ]; then echo "- Git hooks: enabled"; else echo "- Git hooks: NOT enabled in this clone (ask the human to run: git config core.hooksPath .githooks)"; fi
echo "- Git: branch $(git branch --show-current 2>/dev/null), $(git status --porcelain 2>/dev/null | wc -l | tr -d ' ') uncommitted path(s), email $(git config user.email 2>/dev/null || echo unset)"
if [ -f ai-session/PLAN.md ]; then
next=$(grep -m1 -E '^\s*- \[ \]' ai-session/PLAN.md | sed 's/^\s*- \[ \] //')
echo "- Plan: next unchecked slice: ${next:-none (all done, or the plan needs updating)}"
else
echo "- Plan: no ai-session/PLAN.md yet. Explore, then propose a plan before any code."
fi
exit 0
#!/usr/bin/env bash
# PostToolUse on Edit|Write: format the C# file the agent just wrote, so the quality gate never fails on whitespace.
input=$(cat)
file=$(printf '%s' "$input" | sed -n 's/.*"file_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
case "$file" in
*.cs) ;;
*) exit 0 ;;
esac
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
rel=${file#"$PWD"/}
# whitespace formatting only: fast, and needs no build
dotnet format whitespace StockApi.sln --include "$rel" >/dev/null 2>&1 || true
exit 0
3 · Git hooks
A commit made by Codex, Antigravity or you in a terminal never passes through Claude Code, but it always passes through git. A git hook is an executable file git runs at a step: pre-commit before the commit is recorded, commit-msg once the message is written. Any non-zero exit aborts the commit.
| Hook | Refuses |
|---|---|
pre‑commit | Staged bin/, obj/, tmp/, .db or .env · an author email outside your work domain · a config-style secret (a pattern check, then gitleaks if installed) · formatting drift |
commit‑msg | A subject that isn't type(scope): summary · a Co-Authored-By trailer naming an AI tool, or a "generated with" line |
Why two secret checks: we measured that gitleaks 8.23.3 and 8.30.1 both miss a readable key like SuperSecretAuthenticationKeyForFulfillmentService2026!, the kind a real session committed. It looks like words, not noise. The pattern check catches it; you'll see this in probe 3.
Holes: the hooks run only in a clone that ran git config core.hooksPath .githooks, only if executable, and git commit --no-verify skips them. The deny rule stops the agent typing that; you still can. Server-side checks (CI, branch protection) are the real backstop, as the incident showed.
Going deeper: the two git hooks (you copy these in step 3)
Adapted from the reference solution: replace yourcompany.com with the domain of the work email you set in lesson 1.
#!/usr/bin/env sh
# Runs for every committer, human or agent. Enable once per clone: git config core.hooksPath .githooks
fail() { echo "pre-commit: $1" >&2; exit 1; }
# 1. No build output, local databases or env files.
bad=$(git diff --cached --name-only | grep -E '(^|/)(bin|obj|tmp)/|\.db$|(^|/)\.env$')
[ -n "$bad" ] && fail "refusing to commit build output or local files:
$bad
Unstage with: git restore --staged <path>"
# 2. Work identity. Change yourcompany.com to your own work email domain.
case "$(git config user.email)" in
*@yourcompany.com) ;;
*) fail "author email '$(git config user.email)' is not your work address. Run: git config user.email you@yourcompany.com" ;;
esac
# 3. Secrets: a pattern check for config-style secrets, which gitleaks misses on low-entropy values
# such as "SuperSecretAuthenticationKey…" (measured 2026-09-30), then gitleaks for known key formats.
# ai-session/ holds raw transcripts, which quote config on purpose; gitleaks still scans them below.
hits=$(git diff --cached -U0 -- . ':(exclude)ai-session' | grep -E '^\+' | grep -nEi '(key|secret|password|pwd|token)["'\'' ]*[:=] *["'\''][^"'\'' ]{8,}')
[ -n "$hits" ] && fail "possible secret in staged changes (move it to .env, document it in .env.example):
$hits"
if command -v gitleaks >/dev/null 2>&1; then
gitleaks git --pre-commit --staged --redact --no-banner || fail "gitleaks found a secret (see above)."
else
echo "pre-commit: gitleaks not installed; only the pattern check ran" >&2
fi
# 4. Formatting.
dotnet format --verify-no-changes >/dev/null 2>&1 || fail "formatting drift. Run: dotnet format"
exit 0
#!/usr/bin/env sh
# Conventional Commits subject, and no AI attribution. No Node needed.
msg_file="$1"
subject=$(head -n1 "$msg_file")
case "$subject" in
Merge\ *|Revert\ *) exit 0 ;;
esac
echo "$subject" | grep -Eq '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: .{1,72}$' || {
echo "commit-msg: subject must be 'type(scope): summary' (Conventional Commits, max 72 chars after the colon)." >&2
echo " got: $subject" >&2
exit 1
}
if grep -Eqi '^(co-authored-by: .*(claude|codex|gemini|copilot|anthropic|openai|noreply@)|.*generated with .*(claude|codex|gemini|antigravity))' "$msg_file"; then
echo "commit-msg: AI attribution lines are not allowed in this repo. Remove the trailer." >&2
exit 1
fi
exit 0
The trailer check repeats the attribution setting on purpose: the setting covers Claude Code only, the hook covers every tool.
Why a shell regex and not commitlint: commitlint's newest release (21.2.3, published 2026-09-19) was 11 days old when this lesson was checked, which the 14-day rule rules out, and it would add Node to a .NET repo. The regex checks the subject's shape only.
4 · The guard test
The first three guards stop an action. A guard test stops a result: a test that encodes an invariant, so breaking it fails the build. Yours sends 12 one-unit orders at once for a product with 3 in stock: exactly 3 created, 9 refused with 409, stock 0. The requests wait at a gate (a TaskCompletionSource) so they really overlap, on real SQLite in a temp file, because the shared in-memory connection isn't thread-safe.
Know what it proves. We measured: read-then-write without a transaction fails it (Expected: 3, Actual: 12). Inside BeginTransactionAsync() it passes, because SQLite's transaction takes the write lock up front. That green is about SQLite, not the design. The test catches the regression; the lesson 6 reviewer catches the design.
Going deeper: the test and its factory (compare in step 4)
using System.Net;
using System.Net.Http.Json;
using StockApi.Domain;
namespace StockApi.Tests;
public class ConcurrencyTests : IClassFixture<FileStockApiFactory>
{
private readonly HttpClient _client;
public ConcurrencyTests(FileStockApiFactory factory) => _client = factory.CreateClient();
[Fact]
public async Task PlaceOrder_TwelveConcurrentOrdersForThreeUnits_ExactlyThreeSucceed()
{
var product = await _client.CreateProductAsync("CONC-1", 3);
// Hold every request at a gate and release them together so they really overlap.
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var inFlight = Enumerable.Range(0, 12).Select(_ => Task.Run(async () =>
{
await gate.Task;
return await _client.PostAsJsonAsync("/api/orders", new { productId = product.Id, quantity = 1 });
})).ToList();
gate.SetResult();
var responses = await Task.WhenAll(inFlight);
Assert.Equal(3, responses.Count(r => r.StatusCode == HttpStatusCode.Created));
Assert.Equal(9, responses.Count(r => r.StatusCode == HttpStatusCode.Conflict));
var after = await _client.GetFromJsonAsync<Product>($"/api/products/{product.Id}");
Assert.Equal(0, after!.Stock);
}
}
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using StockApi.Data;
namespace StockApi.Tests;
/// <summary>Real SQLite on a unique temp file: a shared in-memory connection is not thread-safe, so concurrency tests need a file.</summary>
public class FileStockApiFactory : WebApplicationFactory<Program>
{
private readonly string _path = Path.Combine(Path.GetTempPath(), $"stockapi-{Guid.NewGuid():N}.db");
protected override void ConfigureWebHost(IWebHostBuilder builder) =>
builder.ConfigureServices(services =>
{
var existing = services.Single(d => d.ServiceType == typeof(DbContextOptions<StockDbContext>));
services.Remove(existing);
services.AddDbContext<StockDbContext>(o => o.UseSqlite($"Data Source={_path};Default Timeout=30"));
});
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing) return;
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
foreach (var suffix in new[] { "", "-wal", "-shm" })
File.Delete(_path + suffix);
}
}
CreateProductAsync is an extension method on HttpClient in a shared TestHelpers.cs, moved out of ApiTests so both test classes use one helper.
Who each guard binds
The layers differ most in who they stop:
Going deeper: the same guards in Codex and Antigravity
| Claude Code | Codex | Antigravity | |
|---|---|---|---|
| Permission rules | permissions in .claude/settings.json | prefix_rule(pattern=[…], decision="forbidden") in .codex/rules | Deny / Ask / Allow lists |
| Tool hooks | hooks in settings | .codex/hooks.json | .agents/hooks.json |
| Git hooks, guard test | Identical: the same .githooks/, the same core.hooksPath, the same test. This is where the tools stop differing. | ||
| No AI trailer | attribution object verified 2026-10-01 | Default not verified; commit-msg catches it either way | |
Sources: Claude Code permissions · Claude Code hooks · Codex rules · Antigravity permissions
Check yourself
You commit from a plain terminal, with no agent running. Which guard still runs?
Permission rules, Claude Code hooks and skills live inside the agent. Git hooks run for anyone who commits, in a clone with core.hooksPath set.
A Claude Code PreToolUse hook prints nothing and exits with status 1. What happens to the tool call?
Exit 1 is a non-blocking error; the call proceeds to your permission rules. Only exit 2 or a JSON deny blocks. In a git hook, any non-zero exit blocks.
Read-then-write inside a transaction passes the 12-for-3 test on SQLite. What catches it?
That green proves SQLite's locking, not the design. The reviewer reads the diff and blocks read-then-write even when the suite is green.
Practice first
Quick drills with instant feedback, before you touch the repo: read a permissions block the way the tool does, then pick the guard that actually binds.
A teammate proposes these permission rules for .claude/settings.json. Click the rules that let a risky command run with no prompt.
- "allow": [
- "Bash(dotnet build *)", "Bash(dotnet test *)",
- "Bash(git *)",
- "Bash(dotnet add *)",
- "Bash(git push --force*)"
- ],
- "ask": [ "Bash(git commit *)", "Bash(rm *)" ],
- "deny": [ "Bash(git commit --no-verify*)", "Read(./.env)",
- "Bash(git config user.email *@*)" ]
What would you do?
AGENTS.md says "never force-push". You want that to hold even when someone asks the agent to do it. What do you add?
Rules and skills shape what the agent tries; they don't change what Claude Code allows. A deny rule and the hook refuse the call before git runs, even when the human asks.
After a week of answering "Yes, and don't ask again", git status lists .claude/settings.local.json as a new file. What do you do?
That file holds your own approvals, so the exercise keeps it out of git with a .gitignore line. Shared rules go in settings.json, written on purpose and reviewed.
The agent writes a JWT key into appsettings.json with the Edit tool, and you then commit. Which guard stops the commit?
The Bash hook never sees Edit-tool calls, and the deny rule covers .env, not appsettings.json. The ask rule only asks you. The pre-commit hook scans the staged diff, whoever wrote it.
Exercise: add four guards, then try to break the repo
Work in your practice repo (~/projects/stockapi) from a clean git status. Commit each step through /commit. Windows: use WSL2 or Git Bash (setup).
- Permission rules. Ask the agent to write
.claude/settings.jsonwith the permissions andattributionabove, and no hooks block yet (so it never points at missing scripts). Compare with the listing. Keep your own approvals out of git:printf '.claude/settings.local.json\n' >> .gitignore git check-ignore -v .claude/settings.local.json # prints the matching rule
Start a new session (accept the trust dialog), then commit both files:chore(harness): add permission rules./commitnow asks beforegit commit: the ask rule at work. - Claude Code hooks. Copy the three scripts and the hooks block from the box exactly. Then test the guard without any agent:
chmod +x .claude/hooks/*.sh echo '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}' \ | bash .claude/hooks/destructive-guard.shExpect deny JSON ending in"permissionDecisionReason":"Force-push rewrites shared history. Ask the human; they push, not the agent."}}. In a new session, ask "What did the session preflight report?" and check no "non-blocking status code" notice appears. Commit:chore(harness): add claude code hooks. - Git hooks. Write
.githooks/pre-commitandcommit-msgfrom the box, with your email domain. Then, yourself:chmod +x .githooks/pre-commit .githooks/commit-msg git config core.hooksPath .githooks git config core.hooksPath # prints .githooks
gitleaks is optional: install it yourself if you want it, checking the release date against the 14-day rule; without it the hook warns and runs the pattern check only. Git Bash:chmodrecords nothing in git, so aftergit addrungit update-index --chmod=+x .githooks/* .claude/hooks/*.sh. Commit:chore(harness): add git hooks. This commit already runs through them. - The guard test. Ask: "Add tests/StockApi.Tests/ConcurrencyTests.cs: 12 concurrent POST /api/orders of quantity 1 for a product with stock 3, released together through a TaskCompletionSource; assert 3 Created, 9 Conflict, stock 0. Use a new FileStockApiFactory on a real SQLite temp file. Move CreateProductAsync out of ApiTests into a shared TestHelpers extension; don't copy it." Compare with the listings, run
/quality-gate(green, one more test), and commit:test(orders): add 12-for-3 concurrency guard. - Probe 1, force-push. New session: "Run exactly this command with your Bash tool and show me the output: git push --force origin main". Expect the call refused before git runs, with the hook's reason or a permission denial. If the agent only declines in words, that was the rule; ask again.
- Probe 2, message and trailer. In your terminal:
echo >> README.md && git add README.md git commit -m "Update the changes" git commit -m "docs: tidy readme" -m "Co-Authored-By: Claude <noreply@anthropic.com>"
Expect, after any gitleaks lines:commit-msg: subject must be 'type(scope): summary' (Conventional Commits, max 72 chars after the colon). got: Update the changes commit-msg: AI attribution lines are not allowed in this repo. Remove the trailer.
Reset:git restore --staged README.md, thengit restore README.md. - Probe 3, a secret. In
src/StockApi/appsettings.json, add a comma after"AllowedHosts": "*"and, below it,"Jwt": { "Key": "SuperSecretAuthenticationKeyForFulfillmentService2026!" }. Stage it and rungit commit -m "feat(auth): add jwt key". Expect (line number may differ):pre-commit: possible secret in staged changes (move it to .env, document it in .env.example): 3:+ "Jwt": { "Key": "SuperSecretAuthenticationKeyForFulfillmentService2026!" }With gitleaks, rungitleaks git --pre-commit --staged --no-bannerwhile staged: we gotno leaks found. Reset (git restore --staged, thengit restore, on the file) and repeat withx7Qp2LmV9sKd3RtYw8ZbN4cHj6FgA1eU: the hook stops at the pattern check, and running the samegitleakscommand again shows it catches this one too (leaks found: 1). Reset again. - Probe 4, the oversell.
git switch -c scratch/oversell, then ask: "Change OrderService.PlaceAsync to load the product, check Stock, decrement it and SaveChanges, and remove the transaction."dotnet testfails:Expected: 3,Actual: 12. Ask it to put the transaction back around the same code: green. Run@agent-stock-invariant-reviewer review git diff main: expect BLOCK anyway. Discard:git restore .,git switch main,git branch -D scratch/oversell. - Point your docs at the guard. In lesson 2 you removed or reworded the concurrency-test lines in
docs/ai/testing.mdanddocs/ai/invariants.md. Restore them, namingtests/StockApi.Tests/ConcurrencyTests.cs, and commit:docs(ai): name the concurrency guard test. Last, see the per-clone hole:git clone . ../stockapi-clone, thengit -C ../stockapi-clone config core.hooksPathprints nothing. Delete the clone.
Codex: put the force-push and --no-verify commands in .codex/rules as prefix_rule(…, decision="forbidden"). Antigravity: add them to the Deny list. In both, steps 3, 4 and probes 2–4 are identical.
- The hook printed the deny JSON from stdin, and the agent's
git push --forcecall was refused before git ran -
git config core.hooksPathprints.githooks, andcommit-msgrejected both "Update the changes" and the AI trailer -
pre-commitblocked both keys, and you noted which one gitleaks alone misses (or that you skipped gitleaks) -
ConcurrencyTestsis committed, failed withExpected: 3/Actual: 12on the rewrite, and the reviewer said BLOCK even when it went green
- hookify: Turns a plain-language rule ("never run X") into a hook for you. Read what it writes, as with any guard.
- pre-commit: A widely used framework for managing git hooks across a team; it can run gitleaks for you.
- gitleaks: The secret scanner the course's pre-commit hook uses.
Each link has its own install steps. Install only what you will use, check how recent it is, and read what a plugin adds before you enable it. Links checked 2026-10-01.
Every “never do X” you've had to repeat is a guard waiting to be written: a deny rule, a hook, or a test.
Hooks reference: "Exit code output" and the PreToolUse decision fields. Keep Permissions open for the rule syntax. The four guards reference sheet has all of this on one page.
"Read my .claude/settings.json and hooks. Give me three spellings of a dangerous command that would slip past my deny rules, and say which other layer would catch each one."