AI-Native Updated 2026-09 9 min read View as Markdown

Shell scripts as agent guardrails

Your agent runs more shell than anything else, and shell is the one place a mistake is not recoverable. Here is how to write the scripts that keep it honest.

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.

shell
#!/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:

shell
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#

shell
# 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.

shell
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.

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, and sandboxing for the containment layer that bounds what a hook did not anticipate.

.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" }]
      }
    ]
  }
}
.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#

.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.

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:

shell
[[ "${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.

A script skeleton worth reusing#

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.

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.

Get the Bash agent pack

A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Bash. One email, then occasional updates when the tooling shifts. No course pitch.

Unsubscribe in one click. We never sell the list. Or just take the AGENTS.md now — no email needed.

Disclosure: some links on this page are affiliate links. If you buy something through one, we earn a commission at no extra cost to you. We only list tools we would tell a friend to use, and we say so when we have not used something ourselves. This is how the site stays free and ad-light.