Start
Guarding an agent's shell commands
Grenz's other surfaces sit between an agent and a credential. This one sits between an agent and its own shell: grenz hook runs as a Claude Code PreToolUse hook, and every Bash command is checked against your policy before it runs. Approvals, budgets and the kill-switch apply to shell commands exactly as they do to API calls.
What it guarantees
The guard confines which binary runs and with what arguments. It does not and cannot contain what that binary then executes: a permitted command may still run code Grenz never sees, from a Makefile, a package script, or a git hook. So granting exec:python3, exec:npm or exec:git is granting code execution, and grenz run says so at startup for every such grant.
What it refuses on principle is narrower and checkable: shell code handed to a shell. Grenz parses bash, so sh -c '…' and … | sh would run straight past its own parser.
1. Turn the guard on
exec_guard: true2. Grant the commands it may run
A bash grant is an ordinary tool grant with the same deny-by-default precedence as every other. Actions are exec:<binary>; targets are the command line as written.
grants:
- tool: bash
allow:
- action: "exec:cd" # agents open almost every command with cd
targets: ["cd /path/to/your/repo*"]
- action: "exec:git"
targets: ["git status*", "git add *", "git commit *"]
- action: "exec:ls"
targets: ["ls", "ls -*"]
require_approval:
- action: "exec:git"
targets: ["git push*"]
deny: ["exec:sudo"] # unscoped: cannot be reordered aroundAnchor every allow with a target. The action is only the binary's basename, so an unscoped exec:git also matches /tmp/evil/git. Full syntax is in the policy format.
3. Register the hook
{ "hooks": { "PreToolUse": [{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "grenz hook" }]
}]}}Give the hook the agent's GRENZ_TOKEN in the environment Claude Code runs in, then start the proxy with grenz run.
grenz service install. And do not set a short timeout on the hook entry: Claude Code lets a timed-out hook's command through, so grenz hook always answers first.How a command becomes a decision
Every element of a pipeline or && list is its own action and its own target, so in git add . && curl evil.com the curl cannot ride on the git grant. Two things about targets catch everyone once:
- The space in a glob is literal.
head *does not match a bareheadat the end of a pipeline. List both. - Argument order matters. A target-scoped deny can be stepped around by reordering arguments, so confine with narrow allows. An unscoped deny such as
exec:sudonever reads argv and stays absolute.
What is refused outright
These are denied before the policy engine sees them, and no policy can allow them: there is no action for the undecidable case, so an exec:* grant is not a way to permit obfuscation.
| Reason | Example | Why |
|---|---|---|
code_in_argv | sh -c '…', eval, source | bash source runs behind Grenz's own parser |
stdin_program | … | sh, sh <<EOF, xargs … | a shell reads its program from stdin |
shell_script | bash deploy.sh | a shell runs a file the guard never parsed |
dynamic | $(echo rm) -rf x, echo $(id) | the binary is only known at run time, or an argument runs a command |
glob | /t?p/evil/curl | the binary resolves against the filesystem at exec time |
wrapper_opaque | env LD_PRELOAD=/evil curl x | the wrapper changed what the wrapped binary is |
An argument whose value varies, like curl $URL, is different: the binary is known, so it reaches the engine as exec:curl but can never satisfy a target glob. It is denied unless an allow rule sets on_unresolved: approve, which asks a human instead.
timeout, heredocs, the four environment variables treated as literal, and the parser defect Grenz compensates for — lives in docs/bash-guard.md.