# bash-guard: an agent hook by Scalably

Canonical: https://scalably.io/hooks/bash-guard
Source: https://github.com/scalably-io/agent-hooks/blob/v1.0.0/hooks/bash-guard.sh
Runs on: PreToolUse on Bash
Release: v1.0.0 at commit 53dcece
Integrity: sha256 of the script served at https://scalably.io/hooks/bash-guard.sh is a9b61313264441376633b5dc661b7a8f44d703aa2b36130bffd13ad7b21da901. Verify: curl -s https://scalably.io/hooks/bash-guard.sh | shasum -a 256
This is the machine-readable representation of the page at the canonical URL. Same facts, denser format.

## Summary

PreToolUse (Bash) hook: triage destructive shell commands before they run.

## Install

```
/plugin marketplace add scalably-io/agent-skills
/plugin install agent-hooks@scalably-agent-skills
```

## What it does

Three outcomes, and the split matters more than the rules:

```text
  DENY  (exit 2)   catastrophic and unrecoverable. Never allowed, not even
                   with an override. The agent is told to stop.
  ASK   (exit 0)   destructive but legitimate. A human sees a danger analysis
                   and decides. This is most of the file.
  ALLOW (exit 0)   recognised as safe. Silent, because a guard that warns
                   about ordinary work gets switched off within a week.
```

The reason ASK is the default for destructive work rather than DENY: a blunt
denial teaches an agent to find another route to the same place. A prompt
keeps a human in the loop while still letting the work happen.

The pattern list is public on purpose. This guards against an agent doing
something destructive by accident. It is not an access control and does not
depend on anyone not knowing what is in it.

KNOWN BEHAVIOUR: the guard matches patterns in the command string it is given
and cannot tell code from prose. Writing a document that quotes a dangerous
command through a shell heredoc will trip it. Use a file-writing tool instead.
Do not reword the command to slip past the guard.

Configuration:
```text
  BASH_GUARD_SAFE_PATHS   colon-separated paths where recursive deletes are
                          allowed silently, in addition to the built-in
                          temp, build and dependency directories.
```

Override: a command carrying CONFIRM_OK='<reason>' skips the ASK prompts. That
token means a human already approved it. An agent must never add it itself.

## The whole script

```bash
#!/usr/bin/env bash
# PreToolUse (Bash) hook: triage destructive shell commands before they run.
#
# Three outcomes, and the split matters more than the rules:
#
#   DENY  (exit 2)   catastrophic and unrecoverable. Never allowed, not even
#                    with an override. The agent is told to stop.
#   ASK   (exit 0)   destructive but legitimate. A human sees a danger analysis
#                    and decides. This is most of the file.
#   ALLOW (exit 0)   recognised as safe. Silent, because a guard that warns
#                    about ordinary work gets switched off within a week.
#
# The reason ASK is the default for destructive work rather than DENY: a blunt
# denial teaches an agent to find another route to the same place. A prompt
# keeps a human in the loop while still letting the work happen.
#
# The pattern list is public on purpose. This guards against an agent doing
# something destructive by accident. It is not an access control and does not
# depend on anyone not knowing what is in it.
#
# KNOWN BEHAVIOUR: the guard matches patterns in the command string it is given
# and cannot tell code from prose. Writing a document that quotes a dangerous
# command through a shell heredoc will trip it. Use a file-writing tool instead.
# Do not reword the command to slip past the guard.
#
# Configuration:
#   BASH_GUARD_SAFE_PATHS   colon-separated paths where recursive deletes are
#                           allowed silently, in addition to the built-in
#                           temp, build and dependency directories.
#
# Override: a command carrying CONFIRM_OK='<reason>' skips the ASK prompts. That
# token means a human already approved it. An agent must never add it itself.

input=$(cat)
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null) || command=""
[ -z "$command" ] && command="$input"
[ ${#command} -lt 5 ] && exit 0

ask_user() {
  jq -nc --arg r "$1" '{
    hookSpecificOutput: {
      hookEventName: "PreToolUse",
      permissionDecision: "ask",
      permissionDecisionReason: $r
    }
  }' 2>/dev/null
  exit 0
}

confirmed=0
printf '%s' "$command" | grep -qE "CONFIRM_OK=" && confirmed=1

# ============================================================
# Recursive delete: triage rather than a blanket rule.
# ============================================================
if printf '%s' "$command" | grep -qE '\brm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b'; then
  target=$(printf '%s' "$command" \
    | grep -oE 'rm[[:space:]]+-[rfRF]+([[:space:]]+-[a-zA-Z]+)*[[:space:]]+[^[:space:];&|]+' \
    | head -1 | sed -E 's/^rm[[:space:]]+-[rfRF]+([[:space:]]+-[a-zA-Z]+)*[[:space:]]+//')
  target=$(printf '%s' "$target" | tr -d '"'"'")

  # Catastrophic. No override applies: there is no verify-and-proceed story for
  # deleting a root, a home, or a target that may expand to nothing.
  if printf '%s' "$target" | grep -qE '^(/|/\*|~|~/?\*?|\$HOME/?\*?)$' \
     || printf '%s' "$target" | grep -qE '\$\{?[A-Za-z_]' \
     || printf '%s' "$target" | grep -qE '^/(etc|usr|var|bin|sbin|lib|lib64|boot|opt|sys|proc|dev|root|System|Library|Applications|Users)/?\*?$'; then
    {
      echo "BLOCKED: recursive delete targeting the filesystem root, a home directory, a bare system directory, or a variable that could expand to nothing."
      echo "This is unrecoverable. If you genuinely mean it, run it yourself outside this session."
    } >&2
    exit 2
  fi

  if [ "$confirmed" -eq 0 ]; then
    safe=0
    # Built-in scratch, build and dependency directories.
    printf '%s' "$target" | grep -qE '^((/private)?/tmp/|\./|[^/][^[:space:]]*/(node_modules|dist|build|\.next|\.cache|\.turbo|coverage|out)\b|(node_modules|dist|build|coverage|out)\b|[^[:space:]]*\.bak)' && safe=1
    # Paths the user configured.
    if [ "$safe" -eq 0 ] && [ -n "${BASH_GUARD_SAFE_PATHS:-}" ]; then
      IFS=':' read -r -a _paths <<< "$BASH_GUARD_SAFE_PATHS"
      for p in "${_paths[@]}"; do
        [ -n "$p" ] || continue
        case "$target" in "$p"|"$p"/*) safe=1; break ;; esac
      done
    fi
    [ "$safe" -eq 1 ] && exit 0

    ask_user "Recursive force-delete of: ${target}. There is no recovery. Check that the path is exactly what you mean, with no stray space and no unset variable that expands to nothing, and that it is not a working directory you did not create. Temp, build and dependency directories are allowed automatically; this one was not recognised."
  fi
fi

# ============================================================
# Dangerous regardless of target. Denied outright.
# ============================================================
if printf '%s' "$command" | grep -qE '\b(curl|wget)\b[^|]*\|[[:space:]]*(sudo[[:space:]]+)?(ba)?sh\b'; then
  echo "BLOCKED: piping a downloaded script straight into a shell. Download it, read it, then run it." >&2
  exit 2
fi
if printf '%s' "$command" | grep -qE '\bmkfs(\.[a-z0-9]+)?\b'; then
  echo "BLOCKED: formatting a filesystem destroys everything on the device." >&2
  exit 2
fi
if printf '%s' "$command" | grep -qE '\bdd\b[^|]*of=/dev/'; then
  echo "BLOCKED: writing raw output to a device overwrites the disk." >&2
  exit 2
fi
if printf '%s' "$command" | grep -qE '\bchmod\b[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*777\b'; then
  echo "BLOCKED: making a path world-writable. Grant the narrowest permission that works instead." >&2
  exit 2
fi
if printf '%s' "$command" | grep -qE '\bdocker\b[^|]*\b(system|image)[[:space:]]+prune\b[^|]*(-a|--all)\b'; then
  echo "BLOCKED: pruning all images or the whole system removes images and volumes that nothing is currently using but something still needs. Prune dangling images instead." >&2
  exit 2
fi

# ============================================================
# Destructive but legitimate. Ask, with the blast radius spelled out.
# ============================================================
if [ "$confirmed" -eq 0 ]; then

  # Databases: a statement that removes rows, tables or schemas.
  if printf '%s' "$command" | grep -qiE '\b(sqlite3|psql|mysql|mariadb)\b' \
     && printf '%s' "$command" | grep -qiE '\b(delete[[:space:]]+from|drop[[:space:]]+(table|database|schema)|truncate)\b'; then
    ask_user "Destructive database statement. Run the equivalent select first and read the row count, so you know exactly what disappears. Confirm you are pointed at the database you think you are."
  fi

  # Git: history, branches and the working tree.
  if printf '%s' "$command" | grep -qE '\bgit[[:space:]]+push[[:space:]]+([^|]*[[:space:]])?(--force|-f)\b'; then
    ask_user "Force push. This overwrites remote history and breaks every clone that has the old commits. Check the branch, and check that it is not one other people build on."
  fi
  if printf '%s' "$command" | grep -qE '\bgit[[:space:]]+reset[[:space:]]+--hard\b'; then
    ask_user "Hard reset. Every uncommitted change in the working tree is destroyed with no recovery. Stash first if there is any doubt."
  fi
  if printf '%s' "$command" | grep -qE '\bgit[[:space:]]+(checkout|restore)[[:space:]]+\.($|[[:space:]])'; then
    ask_user "Discarding the whole working tree. Same blast radius as a hard reset for tracked files. Confirm nothing unsaved is needed."
  fi
  if printf '%s' "$command" | grep -qE '\bgit[[:space:]]+branch[[:space:]]+-D\b'; then
    ask_user "Force-deleting a branch, even if it is unmerged. Its commits may become unreachable. Confirm the work is merged or genuinely unwanted."
  fi
  if printf '%s' "$command" | grep -qE '\bgit[[:space:]]+tag[[:space:]]+-d\b|\bgit[[:space:]]+push[[:space:]]+[^|]*--delete\b'; then
    ask_user "Deleting a tag or a remote reference. If anything depends on that release tag, this breaks it. Confirm it is safe to drop."
  fi
  if printf '%s' "$command" | grep -qE '\bgit[[:space:]]+filter-branch\b|\bgit[[:space:]]+update-ref[[:space:]]+-d\b'; then
    ask_user "Rewriting or deleting references across the repository. Irreversible, and it changes every commit id downstream. Confirm this is deliberate surgery."
  fi

  # Containers.
  if printf '%s' "$command" | grep -qE '\bdocker[[:space:]]+(volume|network)[[:space:]]+prune\b'; then
    ask_user "Pruning all unused volumes or networks. A volume that looks unused can still hold the only copy of some data. Confirm nothing live depends on it."
  fi

  # Anything outward-facing and hard to take back.
  if printf '%s' "$command" | grep -qE '\b(npm|yarn|pnpm)[[:space:]]+publish\b'; then
    ask_user "Publishing a package. A published version cannot truly be unpublished. Confirm the package, the version, and that it is meant to be public now."
  fi
  if printf '%s' "$command" | grep -qE '\bgh[[:space:]]+release[[:space:]]+(create|delete)\b'; then
    ask_user "Creating or deleting a public release. Deleting removes something other people may already have pulled. Confirm the intent."
  fi
  if printf '%s' "$command" | grep -qE '\bgh[[:space:]]+repo[[:space:]]+delete\b'; then
    ask_user "Deleting a repository. Effectively irreversible. Read the repository name twice."
  fi

  # Processes.
  if printf '%s' "$command" | grep -qE '\bkillall\b|\bpkill\b[^|]*-9\b|\bpkill[[:space:]]+-9\b'; then
    ask_user "Force-killing processes by name. The pattern can match more than you intend, including your own session. Confirm the pattern is narrow and the target is right."
  fi
fi

exit 0
```
