Available for rolesPiotr Czerwiński

Writing · August 3, 2026 · 10 min read

Giving a coding agent shell access without losing sleep: the guardrails I run

Claude Code · AI agents · security · developer tooling

TL;DR: An autonomous coding agent with shell access is genuine leverage and a genuine liability in the same breath. It can grind through a week of tedious work while you sleep, and it can delete the wrong directory, force-push over history, or print a live secret into a transcript - and report success either way. I wanted an agent that works to completion without pestering me about every reversible step, while being physically unable to do the irreversible ones. That took four layers: a safety-checked permission mode, hard deny rules, a pre-execution hook, and a behavior instruction. This is the shape of that setup and, more usefully, why each layer exists. The exact rules stay private; the reasoning is what transfers.

The choice that sets everything else up

The first decision is the permission mode, and it is the one most people get wrong by reaching for the most permissive option. A full-bypass mode does exactly what it says: it stops asking. The problem is that it also stops asking about writes to the paths you least want touched - the git internals, the agent's own configuration, your shell profile, your package registry credentials. Everything is allowed, so nothing is inspected.

The mode I run instead lets normal work through without prompts, but routes every action past a background safety classifier first. Reads, file edits in the working directory, installing declared dependencies, read-only network calls, pushing to a branch on the repo you are working on - all of that flows. But an attempt to escalate privileges, run a destructive reset, wipe cloud resources, pipe a script from the internet straight into a shell, or write a live credential somewhere it should never be - that gets stopped. The trade is that I give up the illusion of "never being asked" in exchange for a mode where the dangerous categories are actually looked at. On a subscription plan where the marginal cost of a task is effectively zero, there is no reason to prefer the blunter tool.

Layer two: deny rules, the only hard guarantee

A classifier is smart but soft. Deny rules are dumb and hard, and that is the point: they apply in every mode, they cannot be overridden by a more specific allow, and - critically - they do not live in the conversation. A boundary you set by saying "please don't push" lives in the transcript, and the transcript can be compacted away when the context gets long. A deny rule survives that. It is the one guarantee that holds regardless of what the model remembers.

The precedence is deny, then ask, then allow, first match wins, and rule specificity does not change that ordering. The shape is small and declarative - a handful of entries that name the tools and command forms that must never run unattended:

// illustrative shape only - not the real list
"permissions": {
  "deny": [
    "Bash(git push --force*)",
    "Bash(git reset --hard*)"
    // ...irreversible categories
  ]
}

Writing those rules taught me some sharp syntax lessons that are worth stating as principles rather than recipes. Block whole tools, not arguments. Patterns that try to constrain a specific URL or flag are brittle - a different method, a redirect, or a variable holding the value walks straight past them. Some tool forms are accepted but never actually match, which is worse than useless because a dead rule looks like protection. And certain wrappers are stripped before matching while others are not, so a rule that blocks a command can be sidestepped by running it through a wrapper the matcher does not see through. The meta-lesson: a deny list is only as good as your empirical tests of it, which brings us to the layer that exists precisely because this one has a hole.

Layer three: a hook, because pattern matching has a blind spot

Here is the gap I verified by hand rather than reading about. A denied command run directly is blocked. The same command chained with another is still blocked, because each sub-command is checked. But wrap it inside an interpreter string - hand it to a shell as a quoted argument - and the matcher does not look inside the string. It sees a call to the interpreter, judges that harmless, and lets the payload through. That is a real bypass, not a hypothetical one.

A pre-execution hook closes it. The hook receives the raw command string, normalizes it - strips quotes, collapses whitespace - and scans the whole thing, so interpreter wrappers and nested quoting have nowhere to hide. The contract is minimal: exit with a failure code to block, and whatever the hook prints becomes the reason the model sees; exit zero to allow.

Two things kept this layer honest. First, it is deliberately narrow. It blocks only the irreversible - privilege escalation, recursive deletes of home or root, disk-wiping tools, history rewrites, deleting a remote branch, destroying infrastructure, publishing a package. Everything else passes, because every false positive is friction, and friction is the exact thing the whole setup exists to remove. Removing a build cache directory has to just work. Second, the hook has its own failure mode that is genuinely dangerous: if the hook itself errors, it exits zero, and the entire protection silently switches off. I once introduced a case-insensitivity flag in the wrong position in a pattern, which threw at runtime, which meant the guard was quietly doing nothing. The lesson is blunt: a safety layer you have never tried to break is a hope, not a control. I keep a self-test that runs the known bypasses and the known false positives, and I run it after every single change to the hook.

Layer four: telling the agent to actually finish

Permissions decide what is possible; they do not decide behavior. An agent can be technically allowed to proceed and still stop every few steps to ask whether it may continue, which defeats the point. So the last layer is an instruction, not a control: make reversible decisions on your own and report what you chose and why, ask only about the irreversible, the costly, or a genuine change of direction. The safety-checked mode reinforces this by nudging the agent to keep working rather than stall on clarifying questions. The permissions make autonomy safe; this layer makes it actually autonomous.

What the layers protect, and what they do not

Worth being precise about, because it is easy to feel safer than you are. Deleting the whole repository is blocked at the provider level when the access token simply lacks that capability - no local process can do what the token cannot express. Deleting the default branch is refused by the host outright. But deleting a non-default branch, or rewriting history, is stopped only by my local hook - there is no server-side net under it unless you pay for the tier that offers one. That asymmetry is the honest state of things: some boundaries are guaranteed by infrastructure, and some rest on a script on my laptop that I have to keep tested. Knowing which is which is the difference between real safety and the feeling of it.

What carries over

  • Prefer the inspected mode over the bypass. A classifier that looks at dangerous actions beats a mode that stops looking, especially when task cost is effectively zero.
  • Deny rules are the only guarantee that survives context compaction. A boundary set in conversation can be forgotten; a deny rule cannot.
  • Block whole tools, not arguments. Argument-level filters are brittle and give a false sense of coverage.
  • Add a hook for what patterns cannot see. Interpreter-wrapped commands slip past matching; a hook that scans the normalized whole string catches them.
  • Test the guardrails, every time you touch them. A safety layer that silently fails open is worse than none, because you trust it.
  • Know which boundaries are infrastructure and which are your laptop. Only one of those holds when the laptop is wrong.