DEV Community

Cover image for Your agent's instructions file is a suggestion. A hook is a contract.

Your agent's instructions file is a suggestion. A hook is a contract.

alphanumericentity on October 02, 2026

Every coding agent now reads an instructions file. CLAUDE.md, AGENTS.md, .cursorrules, whatever yours is called. Mine is long. It has been long for...
Collapse
 
max_quimby profile image
Max Quimby •

"The reason string is the whole product" — yes, this is the line. The failure mode of a bare blocked is that the agent treats it as noise and either retries a variant or escalates to you, so a lazy reason converts a deterministic guard back into the exact attention tax you were trying to kill. One thing I'd add: the salience argument cuts both ways over time. A dozen hooks is maintainable; the trap is that hooks quietly become their own unversioned rulebook, and six months in nobody remembers why sed -i is blocked, so someone loosens it during an incident. We started treating the hook set like code — each one gets a one-line comment with the incident that birthed it, and they get reviewed, not just accumulated. Also curious where you draw the line between "block" and "warn-and-continue" — some rules (merge, prod DELETE) are obviously hard blocks, but for the fuzzier ones, does returning a reason and letting the model decide work better than a wall?

Collapse
 
alphanumericentity profile image
alphanumericentity •

Yes to the rulebook risk, and sed -i is my sharpest example of it too. What's saved me there isn't a comment in the file, it's that the reason string has to explain itself. Mine says why sed trips the harness and what to use instead, so whoever loosens it mid-incident reads the rationale inside the refusal rather than going hunting for it. The incident still goes in the docstring, and two of my hooks have unit tests, which felt absurd to write and doesn't any more.

On block versus warn, I ended up with three modes, and severity turned out to be the wrong axis. Determinism is the right one: can the hook tell from the tool input alone, with near certainty, that this call is wrong? If yes, block, because a correct retry is cheap and the reason says how. If the rule is a heuristic that will fire on legitimate work, warn and continue, inject the context and let the model keep its judgement. My comment guard does exactly that split: a real multi-line block is a hard block, a borderline one-liner just gets a reminder appended.

The third mode is the one I never see discussed: auto-allow. Same mechanism, used to approve read-only commands so they never surface a prompt. Most of my deny list isn't about danger, it's about not spending my own attention, and allowing is the same job from the other end.

Collapse
 
syntaxwanderer_26 profile image
Taras Hanych •

"The reason string is the whole product" matches what I saw when I moved rules out of an instructions file into a check: a failure that says what to do instead gets fixed in one step, a bare "blocked" starts a guessing loop. Hooks are great for actions that can't be undone, because they stop the call. The rules I kept losing were a different kind, structural ones like where a class may live or what a module may import, and those fit better in a check the agent runs after editing, with the same principle: the failure message says exactly what to change. Do you test the hooks themselves, so a change in the command format doesn't quietly turn a tripwire into a no-op?

Collapse
 
alphanumericentity profile image
alphanumericentity •

Yep, two of them have unit tests, and they're the two I trust. Both assert the deny path, one has a table of about thirty adversarial command variants, and the review one checks both directions, allow with a valid receipt and block again once I delete it.

But your question catches the gap, because that isn't what those tests cover. The test builds the input payload itself, with the shape I believe the harness sends. If the field holding the command got renamed tomorrow, every test would still pass while the hook quietly matched nothing. Green tests, dead tripwire.

The thing that would actually catch it is failing closed on input I don't recognise: if this is the tool I'm guarding and the field I need isn't there, that's an anomaly, not an allow. Most hooks, mine included, default to allow on anything they can't parse. Haven't fixed that yet.

And agreed on the structural rules. A hook sees one call in isolation, so it can't know what a module imports or where a class lives. That needs something that reads the tree after the edit.

Collapse
 
syntaxwanderer_26 profile image
Taras Hanych •

"Green tests, dead tripwire" is exactly the failure that never announces itself. Failing closed on an unrecognised payload for a guarded tool is the fix I'd make too, and it's cheap: the cost is one false block on the day the harness changes, and that block is what tells you the shape moved. A contract test that replays one payload recorded from the real harness would catch the rename even before that day. Would you fail closed for every hook, or only for the ones guarding irreversible actions?

Thread Thread
 
alphanumericentity profile image
alphanumericentity •

I don't think irreversibility is the axis, it's what the fallback is when the hook declines to decide.

Mapping it onto the three modes: a block hook should fail closed, because the fallback is a retry or a question and both are cheap. A warn hook shouldn't, because its whole premise is "I'm not sure", so turning an unparseable payload into a wall is incoherent, and the right move there is to emit the warning text anyway and let the model proceed. The interesting one is auto-allow, which already fails closed for free: if it can't parse the input it just doesn't allow, and you get the normal permission prompt. The fallback is the human, which is exactly where you want to land.

So the rule I'd write is fail closed wherever declining costs a prompt, don't wherever declining costs a block on legitimate work.

And yes to the recorded-payload contract test, with one condition: record it from a real session, never hand-write the fixture. A hand-written one is just my belief about the shape stored in a second file, which is the same bug with more steps.

Thread Thread
 
syntaxwanderer_26 profile image
Taras Hanych •

"Fail closed wherever declining costs a prompt" is a better rule than mine, and the three modes show why: the axis is the price of saying no, not how scary the action looks. Auto-allow failing closed for free is a nice detail, since the permission prompt is already the safe landing. One edge I'd watch in warn mode: when the payload can't be parsed, the warning can't name the file or command either, so it should say exactly that ("could not read the input") instead of a generic warning the model learns to skip. And agreed on recording the fixture from a real session; I'd stamp it with the harness version, so when the shape changes the failing test tells you which recording went stale.

Collapse
 
build996 profile image
build996 •

On git push, I think the line is whether the agent can do anything with the reason. Your review-receipt guard is a contract because its reason names a condition the agent can go and satisfy, then push. A flat block on push has no such condition, so the only correct response is to stop and ask you, which is exactly the round trip the hook was supposed to save. So I'd keep push guarded but never unconditionally blocked. One gap in the timezone hook: it reads literal text, so a query whose timestamp is assembled from a variable at runtime walks straight past it.

Collapse
 
alphanumericentity profile image
alphanumericentity •

Fair, yours is the better test. :)

Both my push guards do name a condition, the review one tells it which file to write, the comment one prints the lines it deleted so they're satisfiable. Merge is the one I block flat out, and that's deliberate, it isn't the agent's call to make.

On the timezone hook: yep, literals only. I wrote it for a bare date typed into an ad-hoc query, which is what bit me. A template or a bound param sails straight past and I don't have a good answer for that yet. Probably needs catching where the query gets built instead.

Would love to know your opinions. Your comment made me think. Thanks for it.

Collapse
 
build996 profile image
build996 •

Catching it where the query gets built sounds right to me, since that's the last place the value still knows it's a date and not just a string. If the builder takes typed parameters, it could refuse a naive datetime outright and make the caller pick a zone, which covers templates and bound params in one spot. I'd still keep the literal check in the hook for the ad-hoc case that bit you, rather than replace one with the other. And blocking merge flat out makes sense; some calls shouldn't come with a condition the agent can satisfy.

Thread Thread
 
alphanumericentity profile image
alphanumericentity •

Thank you :)

Typed parameters refusing a naive datetime is the version I'd actually build, mostly because the builder is the last place the value still knows it's a date rather than a string. It also gives one place to log the refusal instead of thirty call sites. And yes, both layers, the hook stays for the ad-hoc query I type at 1am, which is the only time I've ever got this wrong. haha

Collapse
 
doriansotpyrc profile image
Dorian Sotpyrc •

The point that the reason string is the whole product changed how I read this. A block with a good reason lets the agent fix itself. A bad one makes it guess.

My agent runs under a written rule file: observe and recommend by default, ask before anything public or irreversible. I see the drift you describe on long tasks, when the rule is the least visible thing in the context. I'm now sorting which of those rules can't stay as prose.

How do you decide when a rule moves from the file into a hook? Is it "it broke once", or something firmer?

Collapse
 
alphanumericentity profile image
alphanumericentity • • Edited

Not "it broke once". Once tells you the rule exists, and a sentence in the file is the right response to that. Twice is the signal, because the second time is evidence the prose isn't carrying it, and nothing about writing the sentence a third time changes that.

Two gates on top of the counter. The rule has to be decidable from the tool input alone, with near certainty, or a hook can't express it no matter how much the miss hurt. And the cost of the miss has to beat the cost of a false block, since every tripwire eventually fires on legitimate work and you'll be reading the refusal at the worst moment.

On your rule file specifically: "ask before anything public or irreversible" is a posture rather than a condition, so it won't hook directly. A hook can't know whether something is public, but it can know this is a git push, this is a pr merge, this is a POST to a host that isn't localhost. Translating the posture into the concrete list of calls that actually realise it is most of the work, and it's also where you find out which parts of the posture you never really meant.