# Shell scripts as agent guardrails

> Source: https://learn-bash.net/ai/hooks-and-guardrails/
> Part of Learn Bash, free to read.

Bash occupies an odd position in agentic development. It is the language your agent uses most — every test run, every build, every git operation — and it is the one where a mistake cannot be undone by reverting a file.

It is also, usefully, the language you can use to constrain the agent. A hook is a shell script that runs before or after a tool call and can refuse it. That makes shell both the risk and the mitigation.

## First: scripts that fail loudly

Generated shell defaults to the tutorial style, where nothing checks anything. In a script an agent runs unattended, that is how a partial failure becomes a corrupted state.

```bash
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'
trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR
```

Four lines, and they change the semantics of everything below:

- `-e` exit on error, `-u` error on undefined variable, `-o pipefail` a pipeline fails if any stage does
- `-E` the ERR trap survives into functions and subshells
- `IFS` restricted so unquoted expansion cannot split on spaces

`set -u` alone prevents the single most destructive shell accident there is:

```bash
rm -rf "$BUILD_DIR/"      # if BUILD_DIR is unset, this is rm -rf /
```

With `set -u` the script exits instead. Without it, it does exactly what it says.

## Quote everything, and let a linter enforce it

```bash
# generated shell does this
for f in $(ls *.txt); do        # breaks on spaces, on newlines, on globs
  cp $f $BACKUP/               # unquoted, twice
done

# correct
for f in ./*.txt; do
  [[ -e "$f" ]] || continue     # glob may not match
  cp -- "$f" "$BACKUP/"
done
```

**Use `shellcheck`.** It is the `ruff` of shell: fast, accurate, and it catches essentially all of the quoting and expansion bugs that generated shell produces.

```bash
shellcheck -S warning scripts/*.sh
```

Wire it into the agent's edit hook and it will fix its own shell before you ever see it.

:::tip The `--` habit
`rm -- "$file"` and `cp -- "$src" "$dst"` stop a filename beginning with `-` from being read as an option. Generated scripts almost never do it, and it costs three characters.
:::

## Now the interesting part: hooks

A hook is a script the agent runs at a lifecycle point. A non-zero exit blocks the action. This turns your policy from a request in `AGENTS.md` into an enforced rule.

This page covers the shell side. For the full lifecycle across harnesses — every event, prompt preprocessing, and what Codex offers instead — see [harness hooks](https://codelearningdojo.com/harness-hooks/), and [sandboxing](https://codelearningdojo.com/sandboxing/) for the containment layer that bounds what a hook did not anticipate.

```json .claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": ".claude/hooks/guard.sh" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [{ "type": "command", "command": ".claude/hooks/lint.sh" }]
      }
    ]
  }
}
```

```bash .claude/hooks/guard.sh
#!/usr/bin/env bash
set -Eeuo pipefail

# The proposed command arrives on stdin as JSON.
cmd=$(jq -r '.tool_input.command // empty')

deny() { echo "BLOCKED: $1" >&2; exit 2; }   # exit 2 = refuse, tell the model why

case "$cmd" in
  *"rm -rf /"*|*"rm -rf ~"*)      deny "recursive delete of a root path" ;;
  *"git push --force"*)            deny "force push; use --force-with-lease and ask" ;;
  *"git reset --hard"*)            deny "destroys uncommitted work; stash instead" ;;
  *"curl "*"| sh"*|*"| bash"*)     deny "piping a download into a shell" ;;
  *"chmod 777"*)                   deny "world-writable permissions" ;;
  *".env"*)                        deny "reading or writing .env" ;;
esac

# Block writes outside the repo
if [[ "$cmd" == *" > /"* && "$cmd" != *" > $PWD"* ]]; then
  deny "writing outside the working directory"
fi

exit 0
```

Two properties make this work well:

1. **Exit 2 blocks and returns your message to the model**, so it learns the rule rather than retrying. Exit 0 allows; exit 1 is an error in the hook itself.
2. **It runs whether or not the agent chooses to comply.** Unlike an instruction, it is not subject to attention.

## The post-edit hook is where quality comes from

```bash .claude/hooks/lint.sh
#!/usr/bin/env bash
set -Eeuo pipefail

# The hook receives a JSON object on stdin; read the path from tool_input.
# (An older CLAUDE_FILE_PATHS env var exists in some versions — parsing stdin
# is the form that is stable across releases and works for every event.)
f=$(jq -r '.tool_input.file_path // empty')
[[ -n "$f" && -f "$f" ]] || exit 0

case "$f" in
  *.sh)  shellcheck -S warning "$f" || true
         shfmt -w -i 2 -ci "$f" ;;
  *.py)  uv run ruff check --fix "$f"; uv run ruff format "$f" ;;
  *.go)  gofmt -w "$f"; go vet "./$(dirname "$f")" || true ;;
  *.ts|*.tsx) npx tsc --noEmit 2>&1 | head -20 ;;
esac
```

Every edit is immediately followed by real feedback the agent did not have to request. This is the highest-value hook you can install, in any language — and there is a full reference for the whole hook system, including Codex, in [harness hooks](https://codelearningdojo.com/harness-hooks/).

## Guardrails that are not hooks

**A `.gitignore` and a deny-list beat vigilance.** `.env`, `*.pem`, `secrets/`, `~/.aws`, `~/.ssh` in your agent's `deny` config. One line each.

**`git` is your undo.** Commit before every session. A dirty working tree is the only thing an agent can genuinely destroy, and the fix is free.

**Alias the dangerous things out of reach.** If a script must do a destructive operation, make it require an explicit flag:

```bash
[[ "${1:-}" == "--i-mean-it" ]] || { echo "refusing without --i-mean-it" >&2; exit 1; }
```

**Prefer `trash` to `rm`** in anything an agent runs interactively. `brew install trash`, then `trash file` is recoverable and `rm` is not.

:::danger The failure mode to actually worry about
Not the agent deciding to delete your home directory — that is rare and blockable. It is `cd` into the wrong directory followed by a perfectly reasonable `rm -rf build/`. Guard on absolute paths and always `cd` with a check:

```bash
cd "$(dirname "$0")/.." || exit 1
[[ -f go.mod || -f pyproject.toml ]] || { echo "not the repo root" >&2; exit 1; }
```
:::

## A script skeleton worth reusing

```bash scripts/template.sh
#!/usr/bin/env bash
set -Eeuo pipefail
IFS=$'\n\t'

readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
readonly REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)"

usage() { cat <<'EOF'
Usage: template.sh [--dry-run] <target>
EOF
}

main() {
  local dry_run=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --dry-run) dry_run=1; shift ;;
      -h|--help) usage; exit 0 ;;
      --)        shift; break ;;
      -*)        echo "unknown option: $1" >&2; usage; exit 2 ;;
      *)         break ;;
    esac
  done

  [[ $# -ge 1 ]] || { usage; exit 2; }
  local target="$1"

  tmp="$(mktemp -d)"
  trap 'rm -rf -- "$tmp"' EXIT

  (( dry_run )) && { echo "would process: $target"; exit 0; }
  echo "processing: $target"
}

main "$@"
```

`--dry-run` deserves special mention: it is the cheapest way to make an agent-run script safe to iterate on, because the first run tells you what it *would* do.

:::promo warp
:::

## Common questions

### Do hooks work in tools other than Claude Code?

The mechanism varies — some have explicit hook configuration, others rely on pre-commit or file watchers — but the substance transfers. Anything that runs your linter after an edit and can refuse a dangerous command gives you the same two properties.

### Is blocking `git reset --hard` too aggressive?

It is the most common way agent work gets destroyed, so start with it blocked and relax later if it annoys you. The hook message can tell the model to use `git stash` instead, which is almost always what was wanted.

### Should I write scripts in bash or Python?

Bash for orchestration and anything under about fifty lines; Python once there is real logic, data structures or error handling. The mistake is a 400-line bash script — at that size you are hand-rolling everything Python gives you, and generated code at that length is very hard to review.
