Agent Harness · Reference sheet

The four guards

Rule vs guard, and the four mechanisms that mechanically block: permission rule, PreToolUse hook, git hook, guard test. Where each lives, what it stops, who it binds. They are one working set: if your team already has CI checks or branch protection doing the same job, keep those and fill the gaps.

Rule vs guard

In this course a guard always means something that mechanically blocks. A rule the agent is asked to follow is a rule. A rule is a request in the context window; a guard sits where the call runs, so it holds after compaction, after /clear, and when the model "wants" otherwise. Write the rule too, so the agent stops walking into the wall.

The four kinds, weakest to strongest reach

KindLives inStopsBindsBypass
1 Permission rule.claude/settings.json → permissions allow / ask / denyA tool call the agent was about to make: prompts (ask) or refuses (deny). No scriptThe agent onlyNot by the agent. The rule binds the agent's tool calls, not you typing in your own terminal
2 PreToolUse hookA script wired under hooks.PreToolUse, e.g. .claude/hooks/destructive-guard.shA tool call, by reading its input and denying it. Can match patterns a permission rule cannot expressThe agent onlyNot by the agent; cannot override a deny or ask rule
3 Git hook.githooks/pre-commit, .githooks/commit-msg, enabled by git config core.hooksPath .githooksA git commit whoever runs itAnyone committing, human or agent, in a clone where it is enabledThe no-verify flag, or a clone that never set core.hooksPath
4 Guard testA test, e.g. tests/StockApi.Tests/ConcurrencyTests.csA change that breaks a project invariant, at dotnet testEvery test run: agent, human, CIDeleting or weakening the test (so the reviewer and the diff must watch it)

1 Permission rule

No script. The tool refuses or prompts. Hand-written, reviewed and committed, unlike the auto-saved settings.local.json.

.claude/settings.json (excerpt)
    "ask": [
      "Bash(git commit *)",
      "Bash(git push *)",
      ...
    ],
    "deny": [
      "Bash(git push --force*)",
      "Bash(git commit --no-verify*)",
      "Read(./.env)",
      "Edit(./.env)",
      "Bash(git config user.email *@*)"
    ]

Evaluated deny, then ask, then allow; the first match decides (Permissions). Compound commands are split and each part matched. Deny and ask always apply; project allow rules apply only after workspace trust.

3 Git hook

Runs for anyone committing. Enable once per clone; hooks must be executable.

.githooks/pre-commit (steps 1 and 3)
# 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>"
...
# 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)."
.githooks/commit-msg (excerpt)
echo "$subject" | grep -Eq '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?!?: .{1,72}$' || {
...
if grep -Eqi '^(co-authored-by: .*(claude|codex|gemini|copilot|anthropic|openai|noreply@)|.*generated with .*(claude|codex|gemini|antigravity))' "$msg_file"; then

Secret scanning runs a pattern check and gitleaks: gitleaks 8.23.3 and 8.30.1 both did not flag the low-entropy "SuperSecretAuthenticationKey..." value, and both flagged a random 32-character key (measured 2026-09-30); the pattern check does. No Node needed: a shell regex. @commitlint/cli 21.2.3 was 11 days old on 2026-09-30, inside this course's 14-day rule for new packages (supply-chain attacks target fresh releases). Nothing forces core.hooksPath; the deny rule on the no-verify flag covers the agent.

2 PreToolUse hook

A script that reads the call and can deny it. Wiring, then the script:

.claude/settings.json (wiring)
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"${CLAUDE_PROJECT_DIR}/.claude/hooks/destructive-guard.sh\""
          }
        ]
      }
    ],
.claude/hooks/destructive-guard.sh (excerpt)
deny() {
  printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"%s"}}\n' "$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 '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."

Block with exit 2 (stderr becomes the reason) or exit 0 with JSON permissionDecision: "deny". Exit 0 with no output is "no decision", not approval, and a hook cannot override a deny or ask rule (Hooks).

4 Guard test

A test for a project rule; the only guard that checks behaviour, not text.

tests/StockApi.Tests/ConcurrencyTests.cs (asserts)
        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);

Measured 2026-09-30: read-then-write inside BeginTransactionAsync() still passes on SQLite (Microsoft.Data.Sqlite takes the write lock up front); without the transaction it fails with "Expected: 3 / Actual: 12". A green guard test can be green for the wrong reason, so a reviewer agent reads the diff as well.

Cautions

  • A Bash rule isn't a security boundary. It matches the command text the agent writes, not what the program does. A deny on git push --force* will not see a wrapper script. That is why the guard hook greps the same patterns, and why git hooks and branch protection sit behind them.
  • A trailing space-star also matches the bare command. The first draft denied Bash(git config user.email *); it blocked reading the email and broke the commit skill's identity check. Fixed with two wildcards, which do not match the bare command, plus an allow for the bare read: deny Bash(git config user.email *@*), allow Bash(git config user.email). Test every guard against your own procedures.
  • Allow rules need trust; deny and ask do not. In an untrusted clone Claude Code prints "Ignoring 10 permissions.allow entries from .claude/settings.json: this workspace has not been trusted" (the number is your allow-list size). Deny and ask still apply. verified 2026-09-30
  • Silence is not consent. A hook that prints nothing decides nothing; the normal permission flow continues.
  • Probe every guard once. Ask for the forbidden thing and watch it refuse: force-push; "Update the changes"; a fake key in appsettings.json; read-then-write with the transaction removed.

The attribution setting

Drops the AI trailer and "Generated with" line Claude Code adds to commits and PRs.

.claude/settings.json
{
  "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
  },
  "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
          }
        ]
      }
    ]
  }
}
  • Portable form (above) works on old and new versions.
  • "attribution": false needs v2.1.281+; older versions skip the whole settings file that contains it, dropping your permissions too. v2.1.281+ · verified 2026-09-30
  • includeCoAuthoredBy has been deprecated since v2.0.62.

Settings reference: attribution

Per-tool equivalents

Claude CodeCodexAntigravity
1 Permission rule.claude/settings.json permissions.allow / ask / deny, e.g. Bash(git commit *). Evaluated deny, then ask, then allow; first match wins.codex/rules/*.rules: prefix_rule(pattern=["git","push","--force"], decision="forbidden", justification="..."). Decisions allow | prompt | forbidden; the most restrictive winsPermissions: Deny / Ask / Allow lists with command(git), read_file(path), write_file(path)
2 PreToolUse hookhooks.PreToolUse in settings.json; exit 2 or JSON permissionDecision: "deny".codex/hooks.json (or config.toml); event PreToolUse (also PermissionRequest).agents/hooks.json; event PreToolUse; JSON decision allow | deny | ask
3 Git hookSame: git, not the toolSameSame
4 Guard testSame: your test suiteSameSame
Attribution trailerattribution setting (below)Whether Codex adds a trailer by default: unverified Test with a throwaway commit; the commit-msg hook catches it either wayNot documented unverified Same remedy

Sources: Claude permissions · hooks guide · Codex rules · Codex hooks · Antigravity permissions · Antigravity hooks · githooks. Last verified 2026-09-30.

Appendix: the incident each guard would have caught
Incident (real sessions, anonymised)What would have caught itKind
The agent committed unasked; later the developer asked it to force-push and it ran it. Only the git server's branch protection refusedask on Bash(git commit *); deny on git push --force*; the destructive guard1 and 2
A developer committed a clicked-together allow list as the shared settings, holding git rebase * and rm -f .claude/settings.jsonA written policy: ask on rebase and reset; the guard denies git rebase1 and 2
The agent launched Docker Desktop itself and polled for 35 min before askingGuard pattern on Start-Process and Docker Desktop: "starting software is the human's call"2
The agent targeted net7.0 because only .NET 6/7 was installedGuard denies TargetFramework>net[0-7]. and net7.0; the answer is a 1-3-12
One commit of 1,089 files, mostly a vendored tmp/ toolchain and obj/ outputpre-commit step 1 refuses bin/ obj/ tmp/ *.db .env3
A JWT signing key committed in appsettings.jsonpre-commit pattern check plus gitleaks step; deny Read/Edit(./.env) keeps real values away from the agent3 and 1
Commits authored under a personal emailpre-commit identity check (your work domain, @yourcompany.com in the reference); deny Bash(git config user.email *@*)3 and 1
AI co-author trailers on most commitsattribution setting drops it; commit-msg rejects any that remain1 (setting) and 3
"Update the changes", "First commit"commit-msg Conventional Commits regex3
The atomic ExecuteUpdateAsync rewritten as read-then-write to suit InMemory, called "still correct against concurrent writes"; a live test of 12 orders for 3 units oversoldConcurrencyTests (12 for 3) goes red when the transaction is gone too; stock-invariant-reviewer blocks the pattern even when the test is green4 (plus a reviewer)
Appendix: the whole guard test, verbatim
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);
    }
}

Taught in: Lesson 7: Guards · Lesson 1: How an agent works · Lesson 3: 1-3-1
Other sheets: AGENTS.md structure · Commit skill · 1-3-1 · Glossary · Tool equivalents