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.
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
| Kind | Lives in | Stops | Binds | Bypass |
|---|---|---|---|---|
| 1 Permission rule | .claude/settings.json → permissions allow / ask / deny | A tool call the agent was about to make: prompts (ask) or refuses (deny). No script | The agent only | Not by the agent. The rule binds the agent's tool calls, not you typing in your own terminal |
| 2 PreToolUse hook | A script wired under hooks.PreToolUse, e.g. .claude/hooks/destructive-guard.sh | A tool call, by reading its input and denying it. Can match patterns a permission rule cannot express | The agent only | Not 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 .githooks | A git commit whoever runs it | Anyone committing, human or agent, in a clone where it is enabled | The no-verify flag, or a clone that never set core.hooksPath |
| 4 Guard test | A test, e.g. tests/StockApi.Tests/ConcurrencyTests.cs | A change that breaks a project invariant, at dotnet test | Every test run: agent, human, CI | Deleting 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.
"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.
# 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)."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"; thenSecret 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:
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash \"${CLAUDE_PROJECT_DIR}/.claude/hooks/destructive-guard.sh\""
}
]
}
],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.
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.
{
"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": falseneeds v2.1.281+; older versions skip the whole settings file that contains it, dropping your permissions too. v2.1.281+ · verified 2026-09-30includeCoAuthoredByhas been deprecated since v2.0.62.
Per-tool equivalents
| Claude Code | Codex | Antigravity | |
|---|---|---|---|
| 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 wins | Permissions: Deny / Ask / Allow lists with command(git), read_file(path), write_file(path) |
| 2 PreToolUse hook | hooks.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 hook | Same: git, not the tool | Same | Same |
| 4 Guard test | Same: your test suite | Same | Same |
| Attribution trailer | attribution setting (below) | Whether Codex adds a trailer by default: unverified Test with a throwaway commit; the commit-msg hook catches it either way | Not 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 it | Kind |
|---|---|---|
| The agent committed unasked; later the developer asked it to force-push and it ran it. Only the git server's branch protection refused | ask on Bash(git commit *); deny on git push --force*; the destructive guard | 1 and 2 |
A developer committed a clicked-together allow list as the shared settings, holding git rebase * and rm -f .claude/settings.json | A written policy: ask on rebase and reset; the guard denies git rebase | 1 and 2 |
| The agent launched Docker Desktop itself and polled for 35 min before asking | Guard 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 installed | Guard denies TargetFramework>net[0-7]. and net7.0; the answer is a 1-3-1 | 2 |
One commit of 1,089 files, mostly a vendored tmp/ toolchain and obj/ output | pre-commit step 1 refuses bin/ obj/ tmp/ *.db .env | 3 |
A JWT signing key committed in appsettings.json | pre-commit pattern check plus gitleaks step; deny Read/Edit(./.env) keeps real values away from the agent | 3 and 1 |
| Commits authored under a personal email | pre-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 commits | attribution setting drops it; commit-msg rejects any that remain | 1 (setting) and 3 |
| "Update the changes", "First commit" | commit-msg Conventional Commits regex | 3 |
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 oversold | ConcurrencyTests (12 for 3) goes red when the transaction is gone too; stock-invariant-reviewer blocks the pattern even when the test is green | 4 (plus a reviewer) |
Appendix: the whole guard test, verbatim
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