~/posts/guardrails-not-vibes

i don't vibe code. i build fences.

nineteen months after calling llm coding a dopamine trap: the hooks and blocked commands that make an agent safe in a go codebase — and why my original fix was the same mistake i keep writing about

i reread what i wrote about vibe coding in march 2025. the story still holds up: two weeks of gloriously fast llm-assisted refactoring, an app that looked better and was cleaner, and a dialog that froze the entire page the first time i actually used the thing. i’d noticed something was off days earlier and talked myself out of it, because the output was arriving faster than my scepticism.

the diagnosis was right. i’d stand by every word of it.

then i got to my own list of fixes. never copy-paste without understanding every line. test frequently. recognize when you’re in the dopamine loop and break out of it.

five instructions to myself. and i have spent the nineteen months since writing post after post arguing that instructions to yourself are not a control.

the same mistake, in a different outfit

this blog has one recurring argument and it shows up everywhere. put the invariant in a check constraint, not a code review. stamp ownership from the session instead of checking the request body, because an assignment can’t be forgotten and an if can. the app layer is where you’re polite; the database is where you’re safe.

“remember to break out of the dopamine loop” is exactly the thing i’d reject in a pull request. it’s a rule enforced by the person most motivated to skip it, at the moment they’re least able to notice they’re skipping it. i diagnosed a failure of attention and prescribed more attention.

so this is the follow-up, and it isn’t a retraction — the danger was real and hasn’t gone anywhere. it’s a correction to the fix. the answer wasn’t better discipline or better prompting. it was writing down, in shell, the small number of things that must never happen, and letting a machine that doesn’t get tired enforce them.

you can read what i was afraid of

here’s the thing i noticed pulling this post together: you can reconstruct someone’s actual fears from their hooks directory far better than from anything they say about ai. mine, across two repos — mojodojo, the go saas, and the docker-swarm infrastructure template i run torro.cat ↗ on:

text
mojodojo/.claude/hooks/block-migration-edit.sh
infra/.claude/hooks/block-dangerous-commands.sh
infra/.claude/hooks/block-main-push.sh

three files. every one of them exists because of a specific thing i did not want to find out about after the fact.

and writing that list is how i found out two of them weren’t running. mojodojo’s settings.json registers all three by name. its hooks/ directory contained exactly one file. a hook whose command can’t be found doesn’t error — it just doesn’t stop anything — so the repo with the production database had its migration guard on and its DROP TABLE and force-push guards absent, for months, while i believed otherwise because i’d read the config.

then i went looking for who forgot to copy the files, and it was worse than that. line 49 of .gitignore:

text
.claude/

a blanket ignore on the whole directory. block-migration-edit.sh only survives because it was committed before that line landed, and git keeps tracking what it already tracks. when the other two arrived, i ran git add on them and git declined, silently — adding an ignored path isn’t an error, it’s a no-op with no output. so they existed on my disk, worked perfectly when i tested them by hand, and were in no commit. the same ignore was also quietly eating a whole subagent definition.

sit with the shape of that for a second, because it’s the same failure the entire post is about, one level up. i had a control. i had evidence the control existed — i could open the file. what i didn’t have was anything that would tell me it wasn’t installed, because every layer involved fails by doing nothing: .gitignore silently drops the add, git silently doesn’t track it, the hook runner silently doesn’t block. a fence you configured is not a fence you installed, and three separate tools agreed to not mention it.

the fix isn’t another hook. it’s .gitignore negations so the shared config is tracked and only my personal settings are ignored, plus a test in pre-push that asserts every hook named in settings.json actually exists on disk and that each one still blocks what it claims to. twenty-four assertions, and i checked it fails when i delete a hook — a test i haven’t seen fail is a test i haven’t written.

the mechanism is deliberately stupid. a hook is a program that receives the proposed tool call as json on stdin and exits with a status. exit 2 means no ↗ , and whatever it printed to stderr goes back to the model.

.claude/hooks/block-dangerous-commands.sh — two of its four rules

bash
#!/bin/bash
# PreToolUse hook (Bash): Block dangerous commands
# Exit 2 = block with error message, Exit 0 = allow

INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qE 'rm\s+-rf\s+(/|~|/home)'; then
  echo "BLOCKED: Destructive rm -rf targeting root or home directory" >&2
  exit 2
fi

if echo "$COMMAND" | grep -qiE '(DROP\s+TABLE|DROP\s+DATABASE|TRUNCATE)'; then
  echo "BLOCKED: Destructive database operation" >&2
  exit 2
fi

no model is consulted about whether the command is a good idea. there’s no confidence score. it’s grep, and grep is not susceptible to a persuasive argument about why this particular TRUNCATE is fine.

that’s the property i want. the entire failure mode of the 2025 post was that i was reasoning about output while a dopamine loop was quietly editing my judgement. a fence made of grep doesn’t reason and can’t be talked round. it’s the same reason i’d rather have a CHECK constraint than a promise: not because the promise is dishonest, but because the constraint is not the kind of thing that can have a bad day.

what it emphatically is not is complete. i sat down to list what these regexes miss and stopped when it got embarrassing: git push -f walks straight past a rule that matches --force. a bare git push while you happen to be standing on main is never matched, because the string “main” doesn’t appear in it. rm -fr / and rm -r -f / both miss. and \b(main|master)\b cheerfully blocks git push origin feature/main-menu, which is a rule doing harm rather than nothing.

so: a fence can’t be argued with, but it absolutely can miss. those are different properties and i had them fused into one comfortable feeling. the value here is that the guard has no bad days — not that it has no gaps.

the fence with a door in it

.claude/hooks/block-main-push.sh

bash
if echo "$COMMAND" | grep -qE 'git\s+push\s+.*--force' && ! echo "$COMMAND" | grep -qE '--force-with-lease'; then
  echo "BLOCKED: Force push is not allowed. Use --force-with-lease if absolutely necessary." >&2
  exit 2
fi

if echo "$COMMAND" | grep -qE 'git\s+push\s+.*\b(main|master)\b'; then
  echo "BLOCKED: Direct push to main/master is not allowed. Use a feature branch and merge request." >&2
  exit 2
fi

look at the first rule. it blocks --force and then explicitly un-blocks --force-with-lease.

that carve-out is the part i’d defend hardest. --force-with-lease refuses to overwrite work that arrived after you last looked — it’s force-push with a compare-and-swap. so the rule isn’t “force pushing is forbidden,” it’s “overwriting history you haven’t seen is forbidden,” which is the thing i actually care about. the safe version of the operation stays available.

two honest caveats on that. the un-block is a grep for --force-with-lease anywhere in the command string, not a parse of the push — so anything merely containing that substring defeats the first rule. and a genuine git push --force-with-lease origin main is still stopped by the second rule, so “the safe version stays available” holds for feature branches and not for main. the door is real; it’s narrower than the sentence above implies.

a fence with no door gets removed. if the only way to do a legitimate thing is to disable the guard, the guard will be disabled on a bad day and never re-enabled. every one of these hooks names the safe alternative in its own error message, and that’s not politeness — it’s what stops the fence from becoming an obstacle worth defeating.

note also that the error text goes to stderr, and the agent reads it. the block isn’t a wall, it’s a correction: “not that way, this way.” in practice the next thing that happens is the branch push, without me in the loop at all.

the one that encodes a fact about the world

my favourite is the smallest.

.claude/hooks/block-migration-edit.sh — the middle of it

bash
# PreToolUse hook: block modifications to existing migration files
# New migration files (not yet on disk) are allowed through

if echo "$FILE" | grep -qE 'db/migrations/[0-9]+_.*\.sql$'; then
  # Allow creating new migrations (file doesn't exist yet)
  if [ -f "$FILE" ]; then
    echo "Cannot modify existing migration: $(basename "$FILE"). Create a new migration instead." >&2
    exit 2
  fi
fi

it doesn’t block migrations. it blocks editing an existing one, and lets creating a new one straight through — the test is [ -f "$FILE" ], whether the file is already on disk.

that’s not a rule about agents. it’s the oldest rule in schema migration: a migration that has run somewhere is immutable, because the thing it changed is already changed. editing an already-applied migration — call it 000012_add_users.sql — doesn’t alter production; it makes your file lie about what production contains, and the drift only surfaces when someone builds a fresh database and gets a different schema from the one you’re running.

a human learns that rule by causing the drift once. the hook means nobody has to learn it here again — and i want to be careful about what “nobody” covers, because i had a nicer sentence here and it was false.

a PreToolUse hook fires on the agent’s tool calls. it does not fire when i open 000012_…up.sql in neovim. i went looking for a commit-time equivalent and there isn’t one: the repo’s pre-commit runs go mod tidy, gofmt, golangci-lint and hadolint, pre-push runs the tests and govulncheck, and not one of them checks migration immutability. the guard covers the machine and not the human, which is the wrong way round given which of us has the stronger urge to just fix the typo in place.

what’s true is the weaker and more interesting version: i didn’t write this defensively against a machine. i wrote down an invariant that had been living in my head for years, and the agent was the thing that made leaving it undocumented finally expensive. the fence should be a git hook too. that it isn’t is a gap, not a design.

the janitor, and the advisor

not every hook is a fence. this one runs after a successful edit:

.claude/settings.json

json
"PostToolUse": [{
  "matcher": "Edit|Write",
  "hooks": [{
    "type": "command",
    "command": "FILE=$(jq -r '.tool_input.file_path // empty'); if echo \"$FILE\" | grep -q '\\.go$'; then cd \"$CLAUDE_PROJECT_DIR\" && go fmt \"$FILE\" > /dev/null 2>&1; fi; exit 0"
  }]
}]

go fmt on every touched go file. note that it can’t fail the operation — and not because of the exit 0: a PostToolUse hook runs after the tool has already done its work, so there is nothing left to block at any exit code. the most a non-zero exit does there is feed a message back to the model. this is the category of thing you should never spend a word of instruction on: formatting is not a matter of judgement, so it should not consume any judgement. don’t ask for it, don’t review it, just run it.

and then there’s a third category, which is the one i’m least sure about:

.claude/agents/db-query-validator.md — front matter and the first check

markdown
---
name: db-query-validator
description: Validates that database queries follow multi-tenancy, soft-delete, and security standards.
tools: Read, Grep, Glob
model: haiku
---

**1. Tenant Isolation (CRITICAL)**
Every SELECT, UPDATE, and DELETE must have `WHERE dojo_id = ?`. Missing this is a cross-tenant data leak.

a specialist reviewer, pointed at the repository layer, checking the one thing that would be catastrophic in a multi-tenant product. read-only tools — Read, Grep, Glob, no Edit, no Bash — so the worst case is a wrong opinion rather than a wrong change. cheap model, because pattern-matching WHERE dojo_id is not hard work.

but be clear about what it is: this one is advisory, and advisory is a weaker category than everything above it. it can miss for reasons you cannot enumerate. a hook misses for reasons you can — i listed several of mine above, and every one of them is a regex i could go and fix this afternoon. that’s the real distinction: a gap in a grep is a bug with an address, and a gap in a model’s attention is a probability. the honest hierarchy is: make it impossible, then make it automatic, then — only for things that genuinely need judgement — ask something to look. i’ve caught myself wanting to promote the third tier into the first, and it doesn’t go there. tenant isolation belongs in the database , enforced by a constraint; the agent that greps for it is a second pair of eyes, not the boundary.

what the fences don’t catch

they’d stop none of what actually went wrong in 2025.

the react refactor didn’t rm -rf anything. it didn’t drop a table or force-push to main. it produced code that compiled, looked cleaner, passed a glance, and froze the page when a dialog opened. every hook in this post would have exited 0 the entire time.

so let me be precise about the claim. these fences bound the blast radius, not the quality. they turn the worst plausible outcome from “the production database is gone” into “some bad code is on a branch,” which is a category of problem i already know how to handle, because it’s the one i’ve had my whole career. what stops bad code from shipping is what always stopped it: tests, review, and actually using the thing — which is precisely the conclusion i had to reach the hard way about testing too , and for the same reason.

and there’s a real failure mode i’d be dishonest to leave out: the fences can make you complacent. knowing the destructive stuff is blocked makes it easier to skim the non-destructive stuff, which is where the actual bugs live. the hook that stops DROP TABLE says nothing about the migration that adds a nullable column the code assumes is non-null. i don’t have a fix for that beyond naming it.

lessons learned

  • if your answer to a systemic risk is “be more careful”, you haven’t answered it. that was the mistake in my own 2025 post, and it’s the same mistake as a code-review comment where a constraint belongs. the tell is that the rule has to be re-obeyed by a tired person every single time.
  • a fence is worth more than a policy because it can’t be reasoned with — not because it can’t miss. grep doesn’t get persuaded, doesn’t get tired at 1am, and doesn’t want the feature to be done. it also sails straight past git push -f. keep those two facts apart; i had them fused, and the fused version is how you end up trusting a regex you never tested.
  • every fence needs a door. block --force, allow --force-with-lease; block editing a migration, allow writing a new one. a guard with no legitimate path through it gets disabled, and it gets disabled on exactly the day you needed it.
  • a fence you configured is not a fence you installed. two of the three hooks here were registered in settings.json and absent from disk, because a blanket .gitignore made git add a silent no-op. every layer failed by doing nothing. if a guard matters, something has to fail when it stops firing — i found this by writing an article, and an article is not a control.
  • your hooks directory is an honest inventory of your fears — and reading mine back, most of them weren’t about ai at all. they were invariants i’d been carrying in my head for years and never written down. the agent didn’t create the risk; it made the undocumented rules expensive enough to finally document.
  • name the tier: impossible, automatic, or advisory. a hook makes something impossible. a formatter makes something automatic. a reviewing subagent is advisory and can miss. the mistake is letting an advisory check feel like a boundary, because then you stop building the real one.

the 2025 post ended by asking whether you understand what’s happening or you’re just chasing the next hit. i’d keep the question. i’d just stop pretending the answer is willpower.

next: back to measurements. i spent a paragraph in the two-databases post apologising for a prepared-INSERT loop and promising to benchmark duckdb’s appender against it. the number is in, and it decides whether the apology was warranted or just a way of feeling rigorous without being rigorous.

support

if this saved you an afternoon, coffee is the going rate. no paywall, no tiers, no thank-you video.

$ ko-fi --send coffee

opens ko-fi.com. nothing is loaded from them on this page.

letters to the editor

no letters yet. the editor is patient.