#!/usr/bin/env bash
set -euo pipefail

# Generated by fallow setup-hooks.
# Installer version: @@FALLOW_INSTALLER_VERSION@@
# Requires bash and jq. On Windows run via git-bash or WSL.
# Blocks Claude Code and Codex git commit and git push when fallow audit
# returns verdict fail. Both harnesses send the PreToolUse payload on stdin and
# treat exit 2 as a block.
# Runtime errors fail open with a single stderr notice so skips stay visible.
# Set FALLOW_GATE_DEBUG=1 to also log when a command is skipped because it is
# not classified as a git commit or push.
#
# Version floor (FALLOW_GATE_MIN_VERSION, default 2.85.0). The gate passes
# --gate-marker agent (added in v2.85.0) so Impact can record containment;
# older binaries reject the flag entirely, which would make every audit fail
# open. The floor also covers the older uncommitted-changes inclusion fix
# (aabb8e1b, v2.46.0). Set the env var to the empty string to disable.
# Floor comparison uses `sort -V`; GNU and BSD agree on plain semver but
# diverge on prereleases (BSD sorts `2.48.0-alpha.1` ABOVE `2.48.0`, GNU below).
# If you set a prerelease floor explicitly, verify the behavior on the target OS.

if ! command -v jq >/dev/null 2>&1; then
  echo "fallow-gate: jq not on PATH, skipping audit." >&2
  exit 0
fi

INPUT="$(cat)"
CMD="$(jq -r '.tool_input.command // empty' <<<"$INPUT")"

# Tokenize instead of matching one regex so git-level options between `git`
# and the subcommand (git -c k=v commit, git -C dir push, git --no-pager
# commit, git --git-dir=/x push) still route into the audit, while subcommand
# lookalikes in arguments (git log commit-message.txt) do not. See issue #2106.
#
# The scan also collects GIT_WRITE_CANDIDATES: the directories that a git
# write may target (`cd <dir>` before it, `-C`, `--work-tree`, the parent of a
# `--git-dir` that ends in .git, and the same two as `GIT_WORK_TREE` and
# `GIT_DIR` prefixes). The word split is simple, so a candidate can
# be wrong. A candidate only adds an audit: the session tree is audited too,
# unless gate_allowlisted_target below proves the target.
GIT_WRITE_CANDIDATES=()

gate_unquote() {
  local word="$1"
  case "$word" in
    \'*\' | \"*\") word="${word:1:${#word}-2}" ;;
  esac
  printf '%s\n' "$word"
}

gate_join() {
  case "$2" in
    /*) printf '%s\n' "$2" ;;
    *) printf '%s\n' "${1:+$1/}$2" ;;
  esac
}

is_git_write_command() {
  local cmd="$1" segment cd_dir="" dir value found=1
  GIT_WRITE_CANDIDATES=()
  # Control operators separate simple commands; each becomes its own line.
  # shellcheck disable=SC2020 # Each operator character becomes a new line.
  while IFS= read -r segment; do
    # Intentional word splitting; globbing is disabled below.
    # shellcheck disable=SC2086
    set -- $segment
    if [ "$#" -ge 2 ] && [ "$1" = cd ]; then
      cd_dir="$(gate_join "$cd_dir" "$(gate_unquote "$2")")"
    fi
    while [ "$#" -gt 0 ]; do
      if [ "$1" != "git" ]; then
        # A git location prefix names a candidate too.
        case "$1" in
          GIT_WORK_TREE=?*)
            GIT_WRITE_CANDIDATES+=("$(gate_join "$cd_dir" "$(gate_unquote "${1#*=}")")")
            ;;
          GIT_DIR=?*)
            value="$(gate_unquote "${1#*=}")"
            value="${value%/}"
            if [ "${value##*/}" = .git ]; then
              GIT_WRITE_CANDIDATES+=("$(gate_join "$cd_dir" "$(dirname "$value")")")
            fi
            ;;
        esac
        shift
        continue
      fi
      shift
      dir="$cd_dir"
      while [ "$#" -gt 0 ]; do
        value="${2:-}"
        case "$1" in
          commit | push)
            found=0
            [ -n "$dir" ] && GIT_WRITE_CANDIDATES+=("$dir")
            break
            ;;
          -C | --work-tree | --git-dir)
            value="$(gate_unquote "$value")"
            ;;
          --work-tree=* | --git-dir=*)
            value="$(gate_unquote "${1#*=}")"
            set -- "${1%%=*}" "$value" "${@:2}"
            ;;
          -c | --namespace | --config-env | --super-prefix | --exec-path | --list-cmds | --attr-source)
            # Global option whose value arrives as the next word.
            shift
            [ "$#" -gt 0 ] && shift
            continue
            ;;
          -*)
            # Value-less global option (--no-pager) or inline-value form
            # (-cuser.name=x).
            shift
            continue
            ;;
          *)
            # A different subcommand; resume scanning for a later `git` word.
            break
            ;;
        esac
        case "$1" in
          -C) dir="$(gate_join "$dir" "$value")" ;;
          --work-tree) GIT_WRITE_CANDIDATES+=("$(gate_join "$dir" "$value")") ;;
          --git-dir)
            value="${value%/}"
            if [ "${value##*/}" = .git ]; then
              GIT_WRITE_CANDIDATES+=("$(gate_join "$dir" "$(dirname "$value")")")
            fi
            ;;
        esac
        shift
        [ "$#" -gt 0 ] && shift
      done
      [ "$#" -gt 0 ] && shift
    done
  done < <(printf '%s\n' "$cmd" | tr ';|&()' '\n\n\n\n\n')
  return "$found"
}

# A coarse second check for a command with quotes, escapes or expansions,
# which the scan above can split wrong (`git -C "a b" commit`, `bash -c 'git
# push'`, `eval "git commit"`). It drops the quote and escape characters and
# counts a `git` word followed later by a `commit` or `push` word in the same
# simple command. Parentheses become spaces, so `git -C "$(pwd)" commit`
# stays one command. It can count a command that does not write: that only adds
# an audit.
gate_loose_write() {
  local text="$1" segment word seen_git line_join=$'\\\n'
  case "$text" in
    *[\'\"\\\$\`]*) ;;
    *) return 1 ;;
  esac
  text="${text//"$line_join"/ }"
  # shellcheck disable=SC2020 # Each operator character becomes a new line.
  while IFS= read -r segment; do
    seen_git=0
    # Intentional word splitting; globbing is disabled below.
    # shellcheck disable=SC2086
    for word in $segment; do
      if [ "$word" = git ]; then
        seen_git=1
        continue
      fi
      # A subcommand from an expansion (`git $cmd`) can be a write too.
      [ "$seen_git" -eq 1 ] && [ "${word:0:1}" = '$' ] && return 0
      [ "$seen_git" -ne 0 ] && seen_git=2
      if [ "$seen_git" -eq 2 ] && { [ "$word" = commit ] || [ "$word" = push ]; }; then
        return 0
      fi
    done
  done < <(printf '%s\n' "$text" | tr -d "'\"\\\\" | tr ';|&`()' '\n\n\n\n  ')
  return 1
}

set -f
if is_git_write_command "$CMD" || gate_loose_write "$CMD"; then
  GIT_WRITE=1
else
  GIT_WRITE=0
fi
set +f
if [ "$GIT_WRITE" -eq 0 ]; then
  if [ -n "${FALLOW_GATE_DEBUG:-}" ]; then
    echo "fallow-gate: not a git commit/push, skipping audit." >&2
  fi
  exit 0
fi

# The hook process can start in a directory that is not the session directory.
# For example, a session in a nested git worktree can get a hook process in the
# main checkout. The audit must run against the tree of the session. The hook
# input gives the session directory in its `cwd` field. The gate below finds
# the start directory: the session directory, or for a git write in a strict
# grammar, the tree that the write targets. From a start directory the audit
# root is:
#   1. The nearest directory at or above the start directory that holds this
#      script at the same relative location (.claude/hooks/fallow-gate.sh or
#      .codex/hooks/fallow-gate.sh). The walk stops at the first .git entry,
#      the same rule as the generated handler. The install root can be below
#      the git root (`--root packages/app`), so this step comes first. The walk
#      ignores $HOME, because a script there is the user-scope install.
#   2. Else, when the session directory is in a git work tree: the directory of
#      the hook process when it is in that work tree, as before. A package
#      directory with its own fallow config thus stays the audit root. When the
#      process directory is in another work tree (outside it, or a nested
#      worktree or submodule below it), the audit root is the git top level. A git top level equal to $HOME does
#      not count, so a home directory under git never becomes the audit root.
#   3. Else the session directory.
# All paths are physical (`pwd -P`), so a symlink cannot move the walk into the
# parents of the link. When `cwd` is missing, empty or not a directory, the
# audit runs in the directory of the process, as before.
physical_dir() {
  CDPATH='' cd "$1" 2>/dev/null && pwd -P
}

gate_relative_path() {
  local script_dir harness_dir
  script_dir="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" || return 0
  [ "$(basename "$script_dir")" = hooks ] || return 0
  harness_dir="$(basename "$(dirname "$script_dir")")"
  case "$harness_dir" in
    .claude | .codex)
      printf '%s\n' "$harness_dir/hooks/fallow-gate.sh"
      ;;
  esac
}

resolve_audit_root() {
  local session_dir="$1" gate_rel="$2" process_dir="$3" home_dir="$4" dir parent top process_top
  dir="$session_dir"
  if [ -n "$gate_rel" ]; then
    while :; do
      if [ -f "$dir/$gate_rel" ] && [ "$dir" != "$home_dir" ]; then
        printf '%s\n' "$dir"
        return 0
      fi
      [ -e "$dir/.git" ] && break
      parent="$(dirname "$dir")"
      # A path that is its own parent is the file system root.
      [ "$parent" = "$dir" ] && break
      dir="$parent"
    done
  fi
  if top="$(git -C "$session_dir" rev-parse --show-toplevel 2>/dev/null)" && [ -n "$top" ] &&
    top="$(physical_dir "$top")" && [ "$top" != "$home_dir" ]; then
    # A path under the top level is not enough: a nested worktree, submodule
    # or nested repository below it is another work tree. Keep the process
    # directory only when its own git top level is the same work tree.
    process_top="$(git -C "$process_dir" rev-parse --show-toplevel 2>/dev/null || true)"
    if [ -n "$process_top" ] && process_top="$(physical_dir "$process_top")" &&
      [ "$process_top" = "$top" ]; then
      printf '%s\n' "$process_dir"
    else
      printf '%s\n' "$top"
    fi
    return 0
  fi
  printf '%s\n' "$session_dir"
}

PROCESS_DIR="$(pwd -P 2>/dev/null || pwd)"
SESSION_DIR=""
SESSION_CWD="$(jq -r '.cwd // empty' <<<"$INPUT" 2>/dev/null || true)"
if [ -n "$SESSION_CWD" ] && [ -d "$SESSION_CWD" ]; then
  # `cd` and `pwd` give an absolute path, also for a Windows path in git-bash.
  SESSION_DIR="$(physical_dir "$SESSION_CWD" || true)"
fi
HOME_DIR=""
if [ -n "${HOME:-}" ]; then
  HOME_DIR="$(physical_dir "$HOME" || true)"
fi
GATE_REL="$(gate_relative_path)"
BASE_DIR="${SESSION_DIR:-$PROCESS_DIR}"
# The directory of the shell as it names it, for a logical `cd`.
LOGICAL_BASE="$PROCESS_DIR"
if [ -n "$SESSION_DIR" ]; then
  LOGICAL_BASE="$SESSION_CWD"
fi
GATE_NL=$'\n'

# Prints three lines for a directory, as git finds them: the work tree top
# level, the git directory and the common git directory. Linked worktrees of
# one repository have their own top level and git directory but share the
# common git directory, with its refs. Git also skips an empty `.git`
# directory and follows a gitfile.
gate_git_identity() {
  local out top git_dir common
  out="$(git -C "$1" rev-parse --show-toplevel --absolute-git-dir --git-common-dir 2>/dev/null)" || return 1
  top="${out%%"$GATE_NL"*}"
  out="${out#*"$GATE_NL"}"
  git_dir="${out%%"$GATE_NL"*}"
  common="${out#*"$GATE_NL"}"
  [ -n "$top" ] && [ -n "$git_dir" ] && [ -n "$common" ] && [ "$git_dir" != "$out" ] || return 1
  # An older git prints the common directory relative to the directory.
  case "$common" in
    /* | [A-Za-z]:[\\/]*) ;;
    *) common="$1/$common" ;;
  esac
  top="$(physical_dir "$top")" && git_dir="$(physical_dir "$git_dir")" &&
    common="$(physical_dir "$common")" || return 1
  printf '%s\n%s\n%s\n' "$top" "$git_dir" "$common"
}
SESSION_IDENTITY="$(gate_git_identity "$BASE_DIR" || true)"

# Returns 0 when git reports another work tree for a directory than for the
# session directory: both the top level and the git directory differ. With
# `push` as the second argument, the common git directory must differ too,
# because linked worktrees share refs: a push from one of them can send the
# branch or tags of the session. A directory in the same work tree, or a git
# error, returns 1.
gate_other_work_tree() {
  local identity top git_dir common session_top session_git_dir session_common rest
  [ -n "$SESSION_IDENTITY" ] || return 1
  identity="$(gate_git_identity "$1")" || return 1
  top="${identity%%"$GATE_NL"*}"
  rest="${identity#*"$GATE_NL"}"
  git_dir="${rest%%"$GATE_NL"*}"
  common="${rest#*"$GATE_NL"}"
  session_top="${SESSION_IDENTITY%%"$GATE_NL"*}"
  rest="${SESSION_IDENTITY#*"$GATE_NL"}"
  session_git_dir="${rest%%"$GATE_NL"*}"
  session_common="${rest#*"$GATE_NL"}"
  [ "$top" != "$session_top" ] && [ "$git_dir" != "$session_git_dir" ] || return 1
  if [ "${2:-}" = push ] && [ "$common" = "$session_common" ]; then
    return 1
  fi
  return 0
}

# Prints the subcommand and the target directory of a command in a strict
# form, in which each
# word is inert and the target is certain:
#   [cd <dir> &&] git [-C <dir>]... (commit|push) [<option or name>]...
# A directory is one plain word, or one pair of single or double quotes
# without a new line. A plain word holds only letters, digits and `-_./=:,@+%`
# (no glob, `~` or `{`). A double-quoted word cannot hold `$`, a backquote or
# a backslash. A `-C` directory cannot start with `-`, `+` or `=`, and a `cd`
# directory must start with `/`, `./` or `../`, or be `.` or `..`. After the
# subcommand, only the options in
# gate_inert_option are allowed; a quoted word is allowed only as the value of
# `-m` or `--message`. Nothing else is allowed: no other operator, expansion,
# redirection, environment prefix or git option such as `-c`. Each directory
# must exist. Returns 1 for any other command.
gate_allowlisted_target() {
  local cmd="$1" rest dir="$BASE_DIR" subcommand word
  # The letters are listed, not given as a range, so that no locale can make
  # the class match a non-ASCII character.
  local plain='[ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_./:@%+,=-]+'
  local sq_dir=$'\'[^\'\n]*\'' dq_dir=$'"[^"$`\\\\\n]*"'
  local sq_arg="'[^']*'" dq_arg='"[^"$`\\]*"'
  local blank=$'[ \t]' sp re word_re dir_re arg_re
  local -a words=()
  sp="$blank+"
  dir_re="($plain|$sq_dir|$dq_dir)"
  arg_re="($plain|$sq_arg|$dq_arg)"
  re="^${blank}*(cd${sp}${dir_re}${sp}[&][&]${sp})?git(${sp}-C${sp}${dir_re})*${sp}(commit|push)(${sp}${arg_re})*${blank}*\$"
  [[ "$cmd" =~ $re ]] || return 1
  word_re="^${blank}*(${plain}|${sq_arg}|${dq_arg}|[&][&])"
  rest="$cmd"
  while [[ "$rest" =~ $word_re ]]; do
    words+=("${BASH_REMATCH[1]}")
    rest="${rest:${#BASH_REMATCH[0]}}"
  done
  set -- "${words[@]}"
  if [ "$1" = cd ]; then
    dir="$(gate_cd_step "$LOGICAL_BASE" "$dir" "$2")" || return 1
    shift 3
  fi
  shift
  while [ "$1" = -C ]; do
    dir="$(gate_c_step "$dir" "$2")" || return 1
    shift 2
  done
  subcommand="$1"
  shift
  while [ "$#" -gt 0 ]; do
    word="$1"
    shift
    case "$word" in
      \'* | \"*) return 1 ;;
    esac
    case "$subcommand:$word" in
      commit:-m | commit:--message | commit:-am)
        # The message: any one word of the grammar, quoted or plain.
        [ "$#" -gt 0 ] || return 1
        shift
        ;;
      *:-*) gate_inert_option "$subcommand" "$word" || return 1 ;;
    esac
  done
  printf '%s\n%s\n' "$subcommand" "$dir"
}

# Options of `git commit` and `git push` that do not run a command, read a
# file, or change the repository or the directory.
gate_inert_option() {
  case "$1:$2" in
    commit:--message=* | commit:-a | commit:--all | commit:--amend | commit:--no-edit | \
      commit:-s | commit:--signoff | commit:--allow-empty | commit:-v | commit:--verbose | \
      commit:-S | commit:-S* | commit:--gpg-sign | commit:--gpg-sign=* | commit:--no-gpg-sign | \
      commit:-q | commit:--quiet | commit:-n | commit:--no-verify) return 0 ;;
    push:-u | push:--set-upstream | push:-f | push:--force | push:--force-with-lease | \
      push:--force-with-lease=* | push:--tags | push:--follow-tags | push:--dry-run | \
      push:-n | push:-v | push:--verbose | push:-q | push:--quiet | push:--no-verify | \
      push:-d | push:--delete) return 0 ;;
  esac
  return 1
}

# Follows the directory word of a `cd`. Bash resolves `..` from the logical
# path, so the step counts only when the logical and the physical result are
# the same directory. Only an explicit path form is allowed, so CDPATH and
# directory stack forms such as `-` or `+1` never apply.
gate_cd_step() {
  local word logical physical
  word="$(gate_unquote "$3")"
  case "$word" in
    /* | ./* | ../* | . | ..) ;;
    *) return 1 ;;
  esac
  logical="$(CDPATH='' cd -L "$1" 2>/dev/null && CDPATH='' cd -L "$word" 2>/dev/null && pwd -P)" || return 1
  physical="$(CDPATH='' cd -P "$2" 2>/dev/null && CDPATH='' cd -P "$word" 2>/dev/null && pwd -P)" || return 1
  [ "$logical" = "$physical" ] || return 1
  printf '%s\n' "$physical"
}

# Follows the directory word of a `git -C`. Git calls chdir(), which resolves
# `..` physically.
gate_c_step() {
  local word
  word="$(gate_unquote "$2")"
  case "$word" in
    "" | -* | +* | =*) return 1 ;;
  esac
  (CDPATH='' cd -P "$1" 2>/dev/null && CDPATH='' cd -P "$word" 2>/dev/null && pwd -P)
}

# One audit root for each different tree, in order. Without a usable `cwd`,
# an empty root keeps the audit in the process directory without a `cd`,
# exactly as before.
AUDIT_ROOTS=()
add_audit_root() {
  local root="$1" known
  if [ -n "$SESSION_DIR" ]; then
    root="$(resolve_audit_root "$1" "$GATE_REL" "$PROCESS_DIR" "$HOME_DIR")"
  elif [ "$1" = "$PROCESS_DIR" ]; then
    root=""
  else
    root="$(resolve_audit_root "$1" "$GATE_REL" "$PROCESS_DIR" "$HOME_DIR")"
  fi
  if [ "${#AUDIT_ROOTS[@]}" -gt 0 ]; then
    for known in "${AUDIT_ROOTS[@]}"; do
      [ "$known" = "$root" ] && return 0
    done
  fi
  if [ -n "${FALLOW_GATE_DEBUG:-}" ] && [ -n "$SESSION_DIR" ]; then
    if [ "$1" = "$SESSION_DIR" ]; then
      echo "fallow-gate: auditing $root (session directory $SESSION_DIR)." >&2
    else
      echo "fallow-gate: auditing $root (command target $1, session directory $SESSION_DIR)." >&2
    fi
  fi
  AUDIT_ROOTS+=("$root")
}

# A strict command audits its target alone only when git reports another work
# tree for it. Every other git write audits the session directory exactly as
# before. It also audits each candidate directory that git reports in another
# work tree, so a command that the scan reads wrong can only add audits.
STRICT_SUBCOMMAND=""
TARGET_DIR=""
if STRICT="$(gate_allowlisted_target "$CMD")"; then
  STRICT_SUBCOMMAND="${STRICT%%"$GATE_NL"*}"
  TARGET_DIR="${STRICT#*"$GATE_NL"}"
fi
if [ -n "$TARGET_DIR" ] && gate_other_work_tree "$TARGET_DIR" "$STRICT_SUBCOMMAND"; then
  add_audit_root "$TARGET_DIR"
else
  add_audit_root "$BASE_DIR"
  # A strict push from a linked worktree of the same repository can send the
  # refs of the session, so it audits both trees.
  if [ -n "$TARGET_DIR" ] && gate_other_work_tree "$TARGET_DIR"; then
    add_audit_root "$TARGET_DIR"
  fi
  if [ "${#GIT_WRITE_CANDIDATES[@]}" -gt 0 ]; then
    for CANDIDATE in "${GIT_WRITE_CANDIDATES[@]}"; do
      CANDIDATE="$(gate_join "$BASE_DIR" "$CANDIDATE")"
      if CANDIDATE_DIR="$(CDPATH='' cd -P "$CANDIDATE" 2>/dev/null && pwd -P)" &&
        gate_other_work_tree "$CANDIDATE_DIR"; then
        add_audit_root "$CANDIDATE_DIR"
      fi
    done
  fi
fi

run_audit() {
  # A real installed Git hook keeps its caller's PATH and does not add
  # node_modules/.bin, so a project-local install is invisible to `command -v`.
  # The arms below cover the layouts a project-local install can take, in the
  # order they are cheapest to probe. Keep them in step with the Lefthook job
  # `fallow init --hooks` prints: the two must resolve the same installs.
  if command -v fallow >/dev/null 2>&1; then
    RUNNER=(fallow)
    BIN_DESC="$(command -v fallow)"
  elif [ -x ./node_modules/.bin/fallow ]; then
    RUNNER=(./node_modules/.bin/fallow)
    BIN_DESC="./node_modules/.bin/fallow"
  elif command -v yarn >/dev/null 2>&1 && YARN_BIN="$(yarn bin fallow 2>/dev/null)" && [ -n "$YARN_BIN" ]; then
    # Yarn Plug'n'Play has no node_modules/.bin at all, so neither the launcher
    # check above nor npx can see the install.
    RUNNER=(yarn exec fallow --)
    BIN_DESC="yarn exec fallow"
  elif command -v npx >/dev/null 2>&1 && VER_PROBE="$(npx --no-install fallow --version 2>/dev/null || true)" && [[ "$VER_PROBE" == fallow* ]]; then
    RUNNER=(npx --no-install fallow)
    BIN_DESC="npx --no-install fallow"
  else
    echo "fallow-gate: fallow binary not found (tried PATH, node_modules/.bin, yarn and npx --no-install), skipping audit." >&2
    exit 0
  fi

  VERSION_RAW="$("${RUNNER[@]}" --version 2>/dev/null || true)"
  VERSION="${VERSION_RAW#fallow }"
  VERSION="${VERSION%% *}"

  MIN_VERSION="${FALLOW_GATE_MIN_VERSION-2.85.0}"
  if [ -n "$MIN_VERSION" ] && [ -n "$VERSION" ]; then
    LOWER="$(printf '%s\n%s\n' "$MIN_VERSION" "$VERSION" | sort -V | head -n1)"
    if [ "$LOWER" != "$MIN_VERSION" ]; then
      {
        echo "fallow-gate: blocked: $BIN_DESC is fallow $VERSION, below required $MIN_VERSION."
        echo "fallow-gate: older binaries reject the --gate-marker flag this gate passes"
        echo "fallow-gate: (added in fallow v2.85.0), so the audit cannot run."
        echo "fallow-gate: upgrade the fallow on PATH (e.g. npm install -g fallow@latest or"
        echo "fallow-gate: cargo install fallow-cli), or set FALLOW_GATE_MIN_VERSION= to disable."
      } >&2
      exit 2
    fi
  fi

  TMP_JSON="$(mktemp)"
  TMP_ERR="$(mktemp)"
  # shellcheck disable=SC2329 # Called by the EXIT trap.
  cleanup() {
    rm -f "$TMP_JSON" "$TMP_ERR"
  }
  trap cleanup EXIT

  if "${RUNNER[@]}" audit --format json --quiet --explain --gate-marker agent >"$TMP_JSON" 2>"$TMP_ERR"; then
    STATUS=0
  else
    STATUS=$?
  fi

  VERDICT="$(jq -r '.verdict // empty' <"$TMP_JSON" 2>/dev/null || true)"
  IS_ERROR="$(jq -r '.error // false' <"$TMP_JSON" 2>/dev/null || echo false)"

  if [ "$VERDICT" = "fail" ]; then
    echo "fallow-gate: blocked by fallow ${VERSION:-unknown} at $BIN_DESC" >&2
    cat "$TMP_JSON" >&2
    exit 2
  fi

  if [ "$STATUS" -eq 2 ] || [ "$IS_ERROR" = "true" ]; then
    MSG="$(jq -r '.message // empty' <"$TMP_JSON" 2>/dev/null || true)"
    if [ -n "$MSG" ]; then
      echo "fallow-gate: fallow audit runtime error ($MSG), skipping." >&2
    else
      echo "fallow-gate: fallow audit runtime error, skipping." >&2
    fi
    exit 0
  fi

  if [ "$STATUS" -ne 0 ]; then
    ERR_LINE="$(sed -n '1p' "$TMP_ERR" 2>/dev/null || true)"
    if [ -n "$ERR_LINE" ]; then
      echo "fallow-gate: fallow audit exited $STATUS ($ERR_LINE), skipping." >&2
    else
      echo "fallow-gate: fallow audit exited $STATUS, skipping." >&2
    fi
    exit 0
  fi
}

# Each audit runs in a subshell, so its `cd`, `exit` and cleanup trap stay
# local to that audit root. Each root gets the same block rule as a single
# audit: exit 2 blocks. Any other failure of one root does not stop the
# audits of the other roots, and the gate exits with it at the end.
FINAL_STATUS=0
for AUDIT_ROOT in "${AUDIT_ROOTS[@]}"; do
  set +e
  (
    set -e
    if [ -n "$AUDIT_ROOT" ] && ! cd "$AUDIT_ROOT" 2>/dev/null; then
      echo "fallow-gate: cannot enter $AUDIT_ROOT, auditing $(pwd) instead." >&2
    fi
    run_audit
  )
  AUDIT_STATUS=$?
  set -e
  if [ "$AUDIT_STATUS" -eq 2 ]; then
    exit 2
  fi
  if [ "$AUDIT_STATUS" -ne 0 ] && [ "$FINAL_STATUS" -eq 0 ]; then
    FINAL_STATUS="$AUDIT_STATUS"
  fi
done

exit "$FINAL_STATUS"
