Stage 7 of 11

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.

14 min read 75 min exercise Plaque: Guard Builder
In 30 seconds
Recall

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:

The stack you build today, and which layer stops which bad action:

a request guards: code that refuses RuleAGENTS.md PreToolUse hookBash only Permissionsettings.json Git hook.githooks/ Guard testdotnet test A bad action Force-pushgit push --forcetalked out of itSTOPdeny + reasonSTOPdeny rule·· Vague message“Update the changes”you typed itdoesn’t read itdoesn’t read itSTOPcommit-msg· Committed secreta readable JWT keyforgottennever sees editsan edit is allowedSTOPpre-commit· Overselling rewriteread-then-write“still correct”never sees editsan edit is allowedcode looks fineSTOP3 vs 12 orders Same, in a transactionstill read-then-writegreen on SQLitereviewer (lesson 6) catches it

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.

.claude/settings.json (permissions and attribution, condensed; the hooks block comes in step 2)
{
  "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 }
}

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:

.claude/hooks/destructive-guard.sh (one of its eight 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:

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)
.claude/settings.json: add this after the "attribution" block. JSON needs a comma between entries, so put one after the closing } 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 } ] }
    ]
  }
.claude/hooks/destructive-guard.sh
#!/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
.claude/hooks/session-start.sh
#!/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
.claude/hooks/format-on-write.sh
#!/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.

HookRefuses
pre‑commitStaged 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‑msgA 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.

.githooks/pre-commit
#!/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
.githooks/commit-msg
#!/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)
tests/StockApi.Tests/ConcurrencyTests.cs
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);
    }
}
tests/StockApi.Tests/FileStockApiFactory.cs
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:

Youin a terminal Claude Code Codex Antigravity CIor a teammate Permission rules,PreToolUse hookbinds the agent, in Claude Code only Git hooks.githooks/binds anyone who commits, in a clone with core.hooksPath set Guard testConcurrencyTestsbinds whoever runs dotnet test, CI included Codex and Antigravity have their own rule and hook files: same idea, separate config
Going deeper: the same guards in Codex and Antigravity
Claude CodeCodexAntigravity
Permission rulespermissions in .claude/settings.jsonprefix_rule(pattern=[…], decision="forbidden") in .codex/rulesDeny / Ask / Allow lists
Tool hookshooks in settings.codex/hooks.json.agents/hooks.json
Git hooks, guard testIdentical: the same .githooks/, the same core.hooksPath, the same test. This is where the tools stop differing.
No AI trailerattribution object verified 2026-10-01Default 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.

  1. "allow": [
  2. "Bash(dotnet build *)", "Bash(dotnet test *)",
  3. "Bash(git *)",
  4. "Bash(dotnet add *)",
  5. "Bash(git push --force*)"
  6. ],
  7. "ask": [ "Bash(git commit *)", "Bash(rm *)" ],
  8. "deny": [ "Bash(git commit --no-verify*)", "Read(./.env)",
  9. "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

Exercise · about 75 min · you add: settings.json, three Claude Code hooks, two git hooks, ConcurrencyTests

Work in your practice repo (~/projects/stockapi) from a clean git status. Commit each step through /commit. Windows: use WSL2 or Git Bash (setup).

  1. Permission rules. Ask the agent to write .claude/settings.json with the permissions and attribution above, 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. /commit now asks before git commit: the ask rule at work.
  2. 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.sh
    Expect 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.
  3. Git hooks. Write .githooks/pre-commit and commit-msg from 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: chmod records nothing in git, so after git add run git update-index --chmod=+x .githooks/* .claude/hooks/*.sh. Commit: chore(harness): add git hooks. This commit already runs through them.
  4. 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.
  5. 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.
  6. 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, then git restore README.md.
  7. Probe 3, a secret. In src/StockApi/appsettings.json, add a comma after "AllowedHosts": "*" and, below it, "Jwt": { "Key": "SuperSecretAuthenticationKeyForFulfillmentService2026!" }. Stage it and run git 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, run gitleaks git --pre-commit --staged --no-banner while staged: we got no leaks found. Reset (git restore --staged, then git restore, on the file) and repeat with x7Qp2LmV9sKd3RtYw8ZbN4cHj6FgA1eU: the hook stops at the pattern check, and running the same gitleaks command again shows it catches this one too (leaks found: 1). Reset again.
  8. 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 test fails: 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.
  9. Point your docs at the guard. In lesson 2 you removed or reworded the concurrency-test lines in docs/ai/testing.md and docs/ai/invariants.md. Restore them, naming tests/StockApi.Tests/ConcurrencyTests.cs, and commit: docs(ai): name the concurrency guard test. Last, see the per-clone hole: git clone . ../stockapi-clone, then git -C ../stockapi-clone config core.hooksPath prints 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.

Done when: tick all four to earn the Guard Builder plaque
Worth installing or reading

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.

Harness it

Every “never do X” you've had to repeat is a guard waiting to be written: a deny rule, a hook, or a test.

Read next

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.

Ask your agent

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