#!/usr/bin/env bash
# wrapper-guard.sh — PreToolUse hook on Bash.
#
# Blocks three specific, mechanically-detectable mistakes that prose has
# repeatedly failed to prevent:
#
#   1. A checkout (git clone / git worktree add) targeting $HOME.
#      Root cause of a fleet host's /home filling to 100% — 255 GB, 842 dirs.
#
#   2. A raw provider API WRITE against an endpoint that already has a Mosaic
#      wrapper. Constitution gate 7 requires the wrapper; the wrapper knows
#      provider dialect, identity, and queue-guard ordering that raw curl does
#      not. Reads are untouched — they are how you gather evidence.
#
#   3. The literal review event "APPROVE". Gitea's vocabulary is APPROVED;
#      it accepts APPROVE with HTTP 200, silently files the review PENDING,
#      and then 422s on submit. This one is unconditionally wrong on Gitea and
#      is what a verdict silently failing to land looks like.
#
# Design constraint: this hook must not become something agents route around.
# It blocks WRITES to endpoints with a known wrapper, and nothing else. Raw
# curl for reads, for registry/manifest calls, and for endpoints with no
# wrapper (there are many) all pass untouched.
#
# One consequence is worth knowing before it surprises you: it judges the
# payload, not the caller, so a command that merely QUOTES such a write is
# refused as well. See the long note at section 2 for why that trade was made.
#
# Break-glass, for a genuine gap where no wrapper can express the call:
#   MOSAIC_WRAPPER_OVERRIDE=1 <command>
# Using it means "no wrapper covers this" — if that is wrong, the fix is to
# extend the wrapper, not to keep typing the override.
#
# Exit codes (Claude Code PreToolUse): 0 = allow, 2 = block with message.

set -euo pipefail

INPUT="$(cat)"
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
[ -z "$CMD" ] && exit 0

# Read the command the SHELL will run, not the text as typed. A backslash before
# a newline is removed before anything else happens, so
#   curl -d@b https://host/api/v1/repos/a/b/iss\
#   ues/1/comments
# executes the comments endpoint while the literal token `issues` never appears
# in the text. Every check below — position, URL, body, endpoint — reads the
# joined form, because that is the command.
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"

# A second reading of the SAME command, used by every check below that has to
# recognize a program by NAME. It applies shell word formation without executing
# expansions, so a name the shell will resolve is a word here regardless of how
# it was dressed:
#
#   "/usr/bin/curl" --config /tmp/req      quoted absolute path
#   './curl' --config /tmp/req             quoted relative path
#   $(which curl) --config /tmp/req        command substitution
#   `which curl` --config /tmp/req         the older spelling of the same thing
#   /usr/bin/gh api -X POST repos/a/b/...  absolute path to a provider CLI
#   cu"rl" --config /tmp/req               quotes INSIDE the word
#   /usr/bin/cu\rl --config /tmp/req       a backslash inside the word
#   g"h" api -X POST repos/a/b/...         the same, in the provider-CLI name
#
# Every one of them executed the real program and every one returned ALLOW, at
# each of this file's three previous heads. The repairs went bare word, then
# unquoted basename, then quotes-as-separators; each fixed a PRESENTATION and
# left the class, and the third is worth spelling out because it is the subtlest
# and it was mine: replacing quote characters with whitespace is token
# SEPARATION, not quote removal. A shell removes a quote WITHOUT splitting the
# word around it, so `cu"rl"` is one word naming curl, while whitespace made it
# two words naming neither. `"/usr/bin/curl"` blocked under that version only
# because the whitespace happened to land after a slash — a passing case that
# established nothing.
#
# Quote removal and escape handling are separate operations. Outside quotes, a
# backslash escapes the next character. Inside single quotes it is literal.
# Inside double quotes it escapes only $, `, ", backslash, or newline; before
# anything else both the backslash and following character remain literal. Quote
# characters themselves are dropped without splitting the word. The existing
# substitution flattening remains: unquoted and double-quoted $, (, ), and ` are
# dropped, so `$(which curl)` becomes `which curl`, where the name is a word on
# its own.
#
# This does NOT try to be a shell. It cannot see a name that is absent from the
# text — assembled from variables, or reached through a wrapper script that
# execs the program — and those remain stated limits of inspecting a command
# string, not defects a pattern closes. What it removes is the class where the
# name IS present and merely punctuated.
#
# The cost is the one this file already chose and documented for the payload
# check further down: quoting an example no longer exempts it, so writing one of
# these commands inside quotes on a Bash line is refused too. Applying that same
# rule here keeps the file coherent — the alternative is a guard where the
# payload arm treats quotes as text and the name arms treat them as armour.
normalize_command_words() {
  local flatten_substitutions="${1:-1}"
  local protect_path_literals="${2:-0}"
  awk -v flatten_substitutions="$flatten_substitutions" \
      -v protect_path_literals="$protect_path_literals" '
    BEGIN {
      state = "outside"; out = ""; word_start = 1
      redirection = sprintf("%c", 25)
      command_boundary = sprintf("%c", 26)
      word_boundary = sprintf("%c", 27)
      literal_dollar = sprintf("%c", 28)
      literal_tilde = sprintf("%c", 29)
    }
    {
      if (NR > 1) {
        if (protect_path_literals) out = out command_boundary
        else out = out "\n"
        word_start = 1
      }
      for (i = 1; i <= length($0); i++) {
        c = substr($0, i, 1)
        # A raw marker byte is ordinary word content. Encode it visibly so only
        # this machine can manufacture an internal boundary marker.
        if (protect_path_literals && c == redirection) {
          out = out "\\x19"; word_start = 0; continue
        }
        if (protect_path_literals && c == command_boundary) {
          out = out "\\x1a"; word_start = 0; continue
        }
        if (protect_path_literals && c == word_boundary) {
          out = out "\\x1b"; word_start = 0; continue
        }
        if (state == "single") {
          if (c == "\047") {
            state = "outside"
          } else {
            if (protect_path_literals && c == "$") out = out literal_dollar
            else if (protect_path_literals && c == "~") out = out literal_tilde
            else out = out c
            word_start = 0
          }
          continue
        }
        if (state == "double") {
          if (c == "\"") {
            state = "outside"
          } else if (c == "\\") {
            if (i == length($0)) {
              out = out c
            } else {
              nextc = substr($0, i + 1, 1)
              if (nextc == "$" || nextc == "`" || nextc == "\"" || nextc == "\\") {
                if (protect_path_literals && nextc == "$") out = out literal_dollar
                else out = out nextc
              } else {
                out = out c
                if (protect_path_literals && nextc == "~") out = out literal_tilde
                else out = out nextc
              }
              word_start = 0
              i++
            }
          } else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) {
            if (protect_path_literals && c == "~") out = out literal_tilde
            else out = out c
            word_start = 0
          }
          continue
        }
        if (protect_path_literals && c ~ /[[:space:]]/) {
          out = out word_boundary
          word_start = 1
        } else if (protect_path_literals && c ~ /[|&;()]/) {
          out = out command_boundary
          word_start = 1
        } else if (protect_path_literals && c ~ /[<>]/) {
          out = out word_boundary redirection word_boundary
          word_start = 1
        } else if (c == "\\") {
          if (i == length($0)) {
            out = out c
          } else {
            nextc = substr($0, i + 1, 1)
            if (protect_path_literals && nextc == "$") out = out literal_dollar
            else if (protect_path_literals && nextc == "~") out = out literal_tilde
            else out = out nextc
            i++
          }
          word_start = 0
        } else if (c == "\047") {
          state = "single"
        } else if (c == "\"") {
          state = "double"
        } else if (!flatten_substitutions || (c != "$" && c != "(" && c != ")" && c != "`")) {
          if (protect_path_literals && c == "~" && !word_start) out = out literal_tilde
          else out = out c
          if (c ~ /[[:space:]|;&]/) word_start = 1
          else word_start = 0
        }
      }
    }
    END { printf "%s", out }
  '
}
CMD_NAMES="$(printf '%s' "$CMD" | normalize_command_words 1 0)"
# Paths need the same quote and escape handling, but not name-mode substitution
# flattening: an expansion-capable `$HOME` spelling must remain visible to the
# checkout check. Unquoted POSIX shell metacharacters become an internal word-
# boundary marker; quoted/escaped metacharacters remain content. Shell-literal
# dollar/tilde characters become separate nonmatching markers; otherwise quote
# removal would create a HOME spelling the shell never expands. A path assembled
# from a different variable or command substitution is absent from the literal text and
# remains outside a text guard's visibility.
CMD_PATHS="$(printf '%s' "$CMD" | normalize_command_words 0 1)"
PATH_REDIRECTION=$'\031'
PATH_COMMAND_BOUNDARY=$'\032'
PATH_WORD_BOUNDARY=$'\033'

# The one place the shape of a program NAME is written down. Every name consumer
# below uses it, so the next fix to this class lands in a single location instead of
# being applied to whichever arm review happened to probe. The prefix must end
# at a slash: `mycurl` and `curl-wrapper` are different programs, and blocking
# them is the over-block that gets a guard routed around instead of repaired.
NAME_PREFIX='(^|[[:space:]|;&])([^[:space:]|;&]*/)?'

# Honour the override only where a shell would actually TREAT it as one: the
# environment-assignment run at the head of the command, or this process's own
# environment. The first version asked whether the token appeared ANYWHERE in the
# command text. That is not a test of what the shell does; it is a test of what
# the string contains, and three shapes turned the whole guard off silently, each
# with exit 0 and no message:
#
#   curl -d '{"body":"... MOSAIC_WRAPPER_OVERRIDE=1 ..."}' .../issues/1/comments
#       a quoted BODY disabling the guard for its own write — and the bodies most
#       likely to carry the token are this file's own documentation, a relayed
#       block message, or a commit message quoting a previous refusal;
#   NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl ...
#       the token as another variable's VALUE, which sets nothing;
#   ... MOSAIC_WRAPPER_OVERRIDE=10 ...
#       `*=1*` matched `=10`, `=1x`, `=123`; the glob never bounded the value.
#
# A control that is off is worse than no control, because the block message is
# what tells an agent the control exists. So the override is now read
# POSITIONALLY, by the rule a shell uses: leading assignments only, up to the
# first token that is not one. A body can never occupy that position, and the
# value must be exactly 1.
#
# Deliberate cost, stated rather than discovered: `cd /x && MOSAIC_WRAPPER_OVERRIDE=1
# curl ...` is NOT honoured — only the head of the command is, and only its first
# line, because an override applies to the command it prefixes and not to a later
# one. Putting the override first is the remedy, and this direction fails closed.
override_prefixed() {
  local first tok
  first="${CMD%%$'\n'*}"
  local IFS=$' \t'
  set -f
  # shellcheck disable=SC2086
  set -- $first
  set +f
  for tok in "$@"; do
    case "$tok" in
      MOSAIC_WRAPPER_OVERRIDE=1) return 0 ;;
      [A-Za-z_]*=*) ;;
      *) return 1 ;;
    esac
  done
  return 1
}
override_prefixed && exit 0
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0

# The wrappers this guard points at are its own siblings. Resolving relative to
# this file — rather than to a hardcoded $HOME/.config/mosaic — means the guard
# names the wrappers from the same install it was launched from, and that it
# still works from a repo checkout with no installed mosaic home (which is how it
# is exercised in CI). $HOME remains the fallback for a guard invoked by an
# absolute path from somewhere unusual.
# $HOME is resolved HERE, once, before anything expands it. The previous attempt
# fixed the checkout arm's use of $HOME and left this one, four lines earlier,
# reading it raw — so under `set -u` a seat with no HOME still died before
# reaching the adjudication that was supposed to handle exactly that. Moving a
# fail-open earlier in the file is not closing it. There is now exactly one
# expansion of HOME in this script and it is guarded; every later use reads
# HOME_DIR / home_known instead, so a new use cannot reintroduce the abort
# without going through this block.
#
# Empty and unset are different values and neither one is a home directory. '/'
# is rejected for the same reason as '': every path is under it, so comparing
# against it stops discriminating at all.
HOME_DIR=""
home_known=0
case "${HOME-}" in
  /?*) HOME_DIR="$HOME"; home_known=1 ;;
esac

# Resolve shell-known HOME spellings without evaluating arbitrary expansions.
# Path mode already encoded quoted/escaped dollar and tilde markers, so only
# expansion-capable spellings reach these token replacements.
expand_known_home() {
  local path="$1"
  awk -v path="$path" -v home="$HOME_DIR" '
    function replace_home_token(value, token, bounded,    out, pos, rest, nextc) {
      out = ""
      while ((pos = index(value, token)) > 0) {
        rest = substr(value, pos + length(token))
        nextc = substr(rest, 1, 1)
        if (!bounded || nextc == "" || nextc !~ /[[:alnum:]_]/) {
          out = out substr(value, 1, pos - 1) home
          value = rest
        } else {
          out = out substr(value, 1, pos + length(token) - 1)
          value = rest
        }
      }
      return out value
    }
    BEGIN {
      path = replace_home_token(path, "${HOME}", 0)
      path = replace_home_token(path, "$HOME", 1)
      if (path == "~" || substr(path, 1, 2) == "~/") {
        path = home substr(path, 2)
      }
      printf "%s", path
    }
  '
}

# Collapse repeated separators and dot segments after filesystem resolution.
lexically_normalize_absolute_path() {
  local path="$1"
  awk -v path="$path" '
    BEGIN {
      if (substr(path, 1, 1) != "/") {
        printf "%s", path
        exit
      }
      count = split(path, component, "/")
      depth = 0
      for (i = 1; i <= count; i++) {
        if (component[i] == "" || component[i] == ".") continue
        if (component[i] == "..") {
          if (depth > 0) depth--
          continue
        }
        normalized[++depth] = component[i]
      }
      printf "/"
      for (i = 1; i <= depth; i++) {
        if (i > 1) printf "/"
        printf "%s", normalized[i]
      }
    }
  '
}

# Resolve the longest existing directory prefix physically, then append and
# normalize the nonexistent suffix. Resolving before collapsing `..` matters:
# the kernel follows a symlink first, then applies the parent segment. This is a
# pre-execution check, so a concurrent symlink replacement remains an inherent
# TOCTOU residual; existing aliases are nevertheless adjudicated correctly.
canonicalize_placement_path() {
  local expanded candidate suffix leaf physical
  expanded="$(expand_known_home "$1")"
  case "$expanded" in
    /*) ;;
    *) printf '%s' "$expanded"; return 0 ;;
  esac

  candidate="$expanded"
  suffix=""
  while [ "$candidate" != "/" ] && [ "${candidate%/}" != "$candidate" ]; do
    candidate="${candidate%/}"
  done
  while [ ! -d "$candidate" ]; do
    [ "$candidate" = "/" ] && break
    leaf="${candidate##*/}"
    suffix="/$leaf$suffix"
    candidate="${candidate%/*}"
    [ -n "$candidate" ] || candidate="/"
  done

  physical="$(cd -P -- "$candidate" 2>/dev/null && pwd -P)" || return 1
  lexically_normalize_absolute_path "$physical$suffix"
}

HOME_CANON=""
if [ "$home_known" -eq 1 ]; then
  if ! HOME_CANON="$(canonicalize_placement_path "$HOME_DIR")"; then
    HOME_CANON=""
    home_known=0
  fi
fi

W="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
[ -x "$W/pr-review.sh" ] || W="$HOME_DIR/.config/mosaic/tools/git"

# ---- 1. checkout into $HOME ------------------------------------------------
# $HOME has to be RESOLVED before anything can be compared against it, and the
# first version interpolated it directly into the pattern. Both ways of it being
# absent were wrong, in OPPOSITE directions, which is why neither showed up as a
# simple "it stopped working":
#
#   HOME unset   under `set -u` the expansion aborts the script. A PreToolUse
#                hook exiting nonzero-but-not-2 is a non-blocking error, so the
#                checkout it was asked about is ALLOWED. The guard failed open in
#                precisely the case where it could not answer the question.
#   HOME=''      the alternation gained an EMPTY branch — (~|\$HOME|)/ — which
#                matches any slash at all, so a legitimate /src checkout was
#                refused. Unusable in the other direction.
#
# Empty and unset are different values and neither one is a home directory. '/'
# is rejected for the same reason as '': every path is under it, so the
# comparison stops discriminating at all. Where the literal path cannot be
# established the `~` and `$HOME` spellings are still checked, and a checkout
# left unresolved BLOCKS rather than clears — a guard may not clear a question it
# was unable to ask. The blast radius of that fail-closed arm is exactly one
# command shape (clone / worktree add), not the session.
# Braces are a pair, not independently optional: `$HOME}` expands HOME and
# appends a literal `}`, while `${HOME` is not a valid expansion. Treating either
# as `${HOME}` would create a home path the shell never resolves.
home_re='~|\$HOME|\$\{HOME\}'
if [ "$home_known" -eq 1 ]; then
  home_re="$home_re|$(printf '%s' "$HOME_CANON" | sed 's/[][\.*^$+?(){}|]/\\&/g')"
fi

# Emit only paths the checkout syntax can PLACE. The previous whole-command
# match treated a HOME-valued environment assignment, reference, or template as
# the destination. Conversely, dropping `=` entirely lost clone's
# --separate-git-dir=<path>, which really does create repository state there.
#
# The path normalizer supplies three collision-safe lexical markers. Command
# markers bound each simple command; word markers split arguments without
# splitting quoted whitespace; redirection markers let this scanner discard
# redirection operands rather than mistake them for clone destinations.
#
# Git options are classified by the small, closed grammar that consumes a
# SEPARATE value. Boolean flags are deliberately not listed: Git generates a
# `--no-` spelling for every boolean option, so that family is defined by a rule
# and cannot be completed by enumeration. Any option not in the separate-value
# grammar is one option token and leaves the next word in positional context.
#
# Git also accepts unique long-option abbreviations and bundled short options.
# Prefix matching models the former. For a short bundle, the first value-taking
# letter consumes the rest of that token; it consumes the next word only when it
# is the bundle's final letter.
#
# Deliberate residual: a future value-taking option absent from these closed
# lists defaults to flag grammar, so its following word remains positional. For
# clone, that can fail open if the future option itself places repository state.
# For worktree, it can shift which word is read as the path. Every value-taking
# and placement option Git supports today is classified (including accepted
# abbreviations of `--separate-git-dir`). Accepting this hypothetical future
# ambiguity avoids failing closed on Git's unbounded present-day boolean and
# generated-negation family.
checkout_placements() {
  printf '%s' "$CMD_PATHS" | awk \
    -v wb="$PATH_WORD_BOUNDARY" \
    -v cb="$PATH_COMMAND_BOUNDARY" \
    -v rb="$PATH_REDIRECTION" '
    BEGIN {
      RS = cb; FS = wb
      clone_value_count = split("--origin --branch --upload-pack --template --reference --reference-if-able --depth --shallow-since --shallow-exclude --filter --server-option --jobs --config --bundle-uri --revision --ref-format", clone_value_name, " ")
      worktree_value_count = split("--reason", worktree_value_name, " ")
      clone_placement_name = "--separate-git-dir"
    }

    function is_git(word) {
      return word ~ /(^|\/)git$/
    }
    function clone_separate_value_option(word,    i, c) {
      if (word ~ /^-[^-]/) {
        for (i = 2; i <= length(word); i++) {
          c = substr(word, i, 1)
          if (c ~ /[obujc]/) return i == length(word)
        }
        return 0
      }
      if (word !~ /^--/ || word ~ /^--no-/ || index(word, "=") > 0) return 0
      for (i = 1; i <= clone_value_count; i++) {
        if (index(clone_value_name[i], word) == 1) return 1
      }
      return 0
    }
    function clone_placement_option(word) {
      return word ~ /^--/ && word !~ /^--no-/ && index(clone_placement_name, word) == 1
    }
    function worktree_separate_value_option(word,    i, c) {
      if (word ~ /^-[^-]/) {
        for (i = 2; i <= length(word); i++) {
          c = substr(word, i, 1)
          if (c ~ /[bB]/) return i == length(word)
        }
        return 0
      }
      if (word !~ /^--/ || word ~ /^--no-/ || index(word, "=") > 0) return 0
      for (i = 1; i <= worktree_value_count; i++) {
        if (index(worktree_value_name[i], word) == 1) return 1
      }
      return 0
    }
    function emit_clone(start, count,    i, j, word, options, positions, equals, value) {
      delete positional
      options = 1; positions = 0
      for (i = start; i <= count; i++) {
        word = token[i]
        if (options && word == "--") { options = 0; continue }
        equals = index(word, "=")
        if (options && equals > 0 && clone_placement_option(substr(word, 1, equals - 1))) {
          value = substr(word, equals + 1)
          if (value != "") print value
          continue
        }
        if (options && clone_placement_option(word)) {
          if (i < count) print token[++i]
          continue
        }
        if (options && clone_separate_value_option(word)) { i++; continue }
        if (options && word ~ /^-/) continue
        positional[++positions] = word
      }
      # clone positional 1 is the source; every later positional can only be an
      # explicit destination (or invalid excess input, which remains fail closed).
      for (j = 2; j <= positions; j++) print positional[j]
    }
    function emit_worktree(start, count,    i, word, options) {
      options = 1
      for (i = start; i <= count; i++) {
        word = token[i]
        if (options && word == "--") { options = 0; continue }
        if (options && worktree_separate_value_option(word)) { i++; continue }
        if (options && word ~ /^-/) continue
        # Only the first positional is placement; the optional second one is
        # commit-ish metadata and must not impersonate the worktree path.
        print word
        return
      }
    }
    {
      delete raw; delete token
      raw_count = 0
      for (i = 1; i <= NF; i++) if ($i != "") raw[++raw_count] = $i

      # Remove redirection operators and their operands. A numeric fd attached
      # before the operator is not an argument either.
      count = 0
      for (i = 1; i <= raw_count; i++) {
        if (i < raw_count && raw[i + 1] == rb && raw[i] ~ /^[0-9]+$/) {
          i += 2
          continue
        }
        if (raw[i] == rb) { i++; continue }
        token[++count] = raw[i]
      }

      for (i = 1; i <= count; i++) {
        if (!is_git(token[i])) continue
        if (token[i + 1] == "clone") emit_clone(i + 2, count)
        else if (token[i + 1] == "worktree" && token[i + 2] == "add") {
          emit_worktree(i + 3, count)
        }
      }
    }
  '
}

if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}git[[:space:]]+[^|;&]*(clone([[:space:]]|$)|worktree[[:space:]]+add([[:space:]]|$))"; then
  if [ "$home_known" -eq 0 ]; then
    cat <<EOF
BLOCKED: this is a checkout, and \$HOME is unset or unusable in this shell.

The guard's job here is to answer one question — does this check out under
\$HOME — and it cannot answer it without a usable \$HOME. An unanswerable
question is not a cleared one, so this refuses rather than guesses. (\$HOME
empty, unset, and "/" are all treated the same way: none of them is a home
directory, and "/" would match every path there is.)

Set HOME to the account's home directory and re-run, or use the helper, which
derives the path and never consults \$HOME at all:

  $W/mosaic-worktree.sh new <branch>
EOF
    exit 2
  fi
  # A placement resolves to HOME when it is the exact home token or a descendant.
  # Matching an extracted argument rather than the whole command is what keeps a
  # HOME-valued source, option, or environment assignment from impersonating it.
  placement_blocked=0
  while IFS= read -r placement; do
    [ -n "$placement" ] || continue
    if ! normalized_placement="$(canonicalize_placement_path "$placement")"; then
      placement_blocked=1
      break
    fi
    if printf '%s' "$normalized_placement" | grep -Eq "^($home_re)(/|$)"; then
      placement_blocked=1
      break
    fi
  done < <(checkout_placements)
  if [ "$placement_blocked" -eq 1 ]; then
    cat <<EOF
BLOCKED: this checks a repository out under \$HOME.

\$HOME holds configuration, credentials, state and caches. It does not hold
checkouts, worktrees, scratch files, or build output. One fleet host's /home hit
100% (394 G) with 255 GB of agent workspaces accumulated exactly this way.

Use the helper, which derives the path so you do not have to choose one:

  ~/.config/mosaic/tools/git/mosaic-worktree.sh new <branch>     # /src/<repo>-worktrees/<slug>
  ~/.config/mosaic/tools/git/mosaic-worktree.sh path <branch>    # show where it would go
  ~/.config/mosaic/tools/git/mosaic-worktree.sh rm <branch>      # removal is part of the task

Worktrees, not clones: they share the object store, and \`git worktree list\`
makes every one of them enumerable — which is the only reason cleanup can
ever be safe.
EOF
    exit 2
  fi
fi

# ---- 2/3. provider API writes ---------------------------------------------
# A raw provider write this guard cares about is two things: a WRITE, and a URL
# naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
# are how you gather evidence — and the many endpoints with no wrapper flow
# through.
#
# It deliberately does NOT ask which program makes the call, or whether that
# program sits at shell command position. It used to, and that is the whole
# history of this file. Answering "is this code or is this data" from the text
# of a shell command required a skeleton with quoted spans and heredoc bodies
# removed, an invoker list for the forms where a shell executes quoted text, a
# prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
# backslash-newline joining. Five rounds of adversarial review put nineteen
# writes straight through it, and every one had the same shape: the client was
# ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
# command prefixes, option values, pipes into a shell, and finally command
# substitution inside the very quotes the skeleton was discarding:
#   echo "$(curl -d@b .../issues/1/comments)"
#   msg="$(curl -d@b .../issues/1/comments)"
# Classifying code against data in shell text with sed and awk is not a hard
# problem, it is the wrong problem. It was not even portable: under CI's busybox
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
# and the guard started refusing ordinary prose instead — which is the other way
# a control like this dies.
#
# So the client detection is gone, and with it that entire failure class: what
# is left cannot fail open by hiding the caller, because it never looks for one.
# It looks for the payload. Something that names a wrapped endpoint and carries
# a body is refused however it is spelled — curl, wget, `python -c`, or a form
# nobody has thought of yet.
#
# The cost is real and belongs in the open, because over-blocking is how a hook
# gets switched off: QUOTING one of these calls on a Bash command line now
# blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
# an example into a file. There is no textual way to tell a quoted example from
# a quoted command — that is exactly the finding above — so the rule is the one
# an agent can hold in mind without a parser:
#
#   do not put a raw write to a wrapped forge endpoint on a Bash command line,
#   not even inside quotes.
#
# Write the example with a file-writing tool, or leave the body flag out of it.
# That is a deliberate narrowing of scope, not an oversight. This hook stops
# mistakes; it is not a sandbox, and pretending otherwise is how you get a
# control nobody can trust the boundaries of.
#
# Scoped to commands that are provider-API-shaped, so nothing else is even
# considered. The first version of this scope gate asked only for `https?://`,
# and review found the absence shape had simply moved to the new boundary:
#   gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE
#   tea api -X POST repos/a/b/issues/1/comments -f body=x
#   curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues
# all carry a real write to a wrapped endpoint and none carries a scheme, so the
# guard never asked the write question at all. Gate 7 covers raw provider CLIs,
# so these are in scope and the gate now names the shapes they come in.
#
# Then review found the same absence at the same boundary a second time, in the
# one provider whose paths carry no version marker at all:
#   curl -X POST -d x api.github.com/repos/a/b/issues
# GitHub's API is `api.github.com/repos/...`; Gitea's is `/api/v1/repos/...`.
# Asking for `/api/v[0-9]` therefore admitted the schemeless Gitea write and
# excluded the schemeless GitHub one — a gate calibrated to one dialect's
# spelling rather than to what identifies a provider API. So the gate names both
# markers: a version segment, and the `/repos/` path that every forge API uses
# to address a repository.
#
# Adding alternatives to a scope gate can only make it stricter — it cannot
# create a new allow — which is why this is a list of triggers rather than a
# model of any one caller. Downstream, a block still requires a body flag AND
# either a mapped endpoint or an unreadable one, so widening the gate widens
# what is CONSIDERED, not what is refused.
#
# Residual, stated rather than implied: a caller who splits `/repos/` itself in
# a schemeless GitHub URL (`h=api.github.com/rep; q=os/a/b/issues`) leaves no
# literal marker anywhere and is out of scope. That is the same boundary as
# splitting the hostname — no longer a mistake anyone makes by accident.
#
# Boundary, deliberate and worth stating: this covers the `api` subcommand,
# which is a raw API call wearing a CLI. Provider PORCELAIN (`tea pulls create`,
# `gh pr merge`) is NOT covered — catching that means modelling every CLI's verb
# grammar, which is the parser mistake again in a new costume. Porcelain is a
# gate-7 gap for prose and review to hold, not this hook.
# ---- curl whose request lives in a file ------------------------------------
# curl takes its options from a file with -K/--config, and that file may carry
# the method, the body, the headers AND THE URL. That last one is why this test
# cannot live inside the API-shape gate below, which is where it was first put:
#
#   curl --config /tmp/provider-write.cfg
#
# has no URL, no /repos/, no `gh api` — nothing API-shaped in the text at all —
# so it never entered the branch that was supposed to refuse it, and the guard
# reported clean on the exact capability the check exists to deny. The check was
# guarded by a condition the thing it guards against defeats. It is now asked of
# any curl, because "is this a provider call" is not answerable about a command
# whose URL is in a file, and a question that cannot be asked is not a question
# that came back clean.
#
# The spelling is deliberately loose. curl accepts the value attached to the
# short flag (`-K/tmp/req`, verified) and inside a bundle (`-sK /tmp/req`), and a
# guard that recognizes only the space- and equals-separated forms is defeated by
# deleting one character. Scoped to curl so that `eslint --config .eslintrc.json`
# and every other tool with a --config flag are untouched.
#
# curl is recognized as a NAME, through $CMD_NAMES and $NAME_PREFIX — see the
# comment on those at the top of the file for why matching the bare word, and
# then matching the unquoted basename, were both the same mistake at different
# depths. A wrapper script that execs curl on the operator's behalf is still
# invisible here, because neither the name nor the request appears in the
# command text at all. That is a limit of inspecting a command string rather
# than a defect this pattern can close, and it is stated rather than left for
# the next reader to find.
# The FLAG is read from $CMD_NAMES too. It is the same recognition problem as
# the name — `--con"fig"` is one word spelling --config — and no review has
# raised it yet only because the name was the easier half to reach. Reading both
# halves the same way is the point of having one normalization.
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \
   && printf '%s' "$CMD_NAMES" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
  cat <<EOF
BLOCKED: curl invocation whose request is supplied from a --config/-K file.

curl reads the request method, body, headers and even the URL from that file.
None of them appear in the command, so this guard cannot tell whether the call
is a read or a write, what endpoint it reaches, or whether it is a provider call
at all. An unreadable request is not a cleared one.

  $W/  <- the wrappers; use the one for the endpoint you are calling

If this is a read, spell the request on the command line so it is legible. If it
is a write to a wrapped endpoint, use the wrapper. For a genuine gap no wrapper
can express, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first thing in the
command — it is read positionally, not matched as text).
EOF
  exit 2
fi

# The scope gate comes in two halves because its two triggers are different
# kinds of thing, and collapsing them into one regex over one string is what
# hid the second instance of the caller-name defect for three review rounds.
#
# The URL markers are TEXT: a scheme, a version segment, a `/repos/` path. They
# are read from the command as written.
API_SHAPED='https?://|/api/v[0-9]|/repos/'
#
# The provider-CLI marker is a NAME, so it is read the way names are read here —
# through $CMD_NAMES, with $NAME_PREFIX. Asking for a bare `gh api` let every
# path spelling through the gate that decides whether write detection runs AT
# ALL, so `/usr/bin/gh api -X POST repos/a/b/issues -f title=x` was never even
# considered. No URL marker rescued it: provider CLI endpoints are spelled
# `repos/...` with no leading slash, so `/repos/` does not match them either.
# A scope gate that fails to admit is indistinguishable from an allow.
PROVIDER_CLI="${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)"
if printf '%s' "$CMD" | grep -Eq "$API_SHAPED" \
   || printf '%s' "$CMD_NAMES" | grep -Eq "$PROVIDER_CLI"; then

  # Write detection, now client-agnostic. Every spelling curl accepts, because
  # the guard is defeated by the one spelling it does not know: `-d@body` (no
  # space) and `--request=POST` (equals form) both slipped past the first
  # version. Plus wget's forms and a library call, which a client-shaped test
  # could not have seen at all.
  #
  # A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
  # .../issues/1/comments` is therefore refused, which is a read wearing a
  # write's flag. That direction is the acceptable one: it costs an override on
  # a rare command, where the reverse costs a silent raw write.
  is_write=0
  printf '%s' "$CMD" | grep -Eq -- \
    '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
  # curl sends POST implicitly when handed a body, in any of these forms.
  printf '%s' "$CMD" | grep -Eq -- \
    '(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
  # The provider CLIs POST implicitly the same way curl does, when handed a
  # field. Matched only in `-f key=value` shape, so the far more common `rm -f`
  # and `grep -f` cannot be read as a body. The trailing `[]` is the array
  # spelling the provider CLIs use for repeated fields (`-f labels[]=bug`), and
  # without it the key class stopped at the bracket and the field was not seen
  # as a body at all — found while pinning the labels/assignees repros, both of
  # which carry it.
  printf '%s' "$CMD" | grep -Eq -- \
    '(^|[[:space:]])(-f|--field|--raw-field)[[:space:]]+[A-Za-z_][A-Za-z0-9_.-]*(\[\])?=|--input[[:space:]=]' && is_write=1
  # ...and a library call is a write without any flag at all.
  #
  # Second documented over-block, and broader than the body flags because it
  # needs no flag: any text carrying `.post(` near a wrapped URL is refused,
  # including prose that merely quotes it. That follows from the same rule as
  # the quoted-curl cost above — the payload is judged, not the caller — and it
  # is stated here so it is a known boundary rather than a surprise.
  printf '%s' "$CMD" | grep -Eq -- \
    '\.(post|put|patch|delete)\(' && is_write=1

  if [ "$is_write" -eq 1 ]; then
    # A percent-escape makes the endpoint unreadable HERE and perfectly readable
    # to the PROVIDER, which is the whole hazard. Measured against the live
    # forge, read-only: GET /repos/mosaicstack/stack/issues/1174 and
    # GET /repos/mosaicstack/stack/iss%75es/1174 both returned HTTP 200 with the
    # same object. So `iss%75es` IS the wrapped endpoint by the only authority
    # that gets a vote, while every literal comparison below sees a segment that
    # matches nothing and clears the write.
    #
    # This refuses rather than decodes. A decoder has to be exactly right about
    # depth (%2569 -> %69 -> i) and about which characters the provider
    # normalizes, and being exactly right about someone else's parser is the
    # mistake this file declines everywhere else. Refusing is correct at every
    # depth at once.
    #
    # Scoped to WRITES. Reads are never blocked by this guard, and a query
    # string carrying %20 is not a hazard — it is an ordinary URL. Putting this
    # test on the whole API-shaped branch would have refused those too, which is
    # how a guard earns being routed around.
    if printf '%s' "$CMD" | grep -Eq '%[0-9A-Fa-f][0-9A-Fa-f]'; then
      cat <<EOF
BLOCKED: provider API WRITE containing a percent-escape.

The provider decodes the path before routing; this guard compares it literally.
An encoded segment therefore reaches a wrapped endpoint while reading, here, as
a segment that matches nothing — /iss%75es/ and /issues/ were measured returning
the same object from the same repository.

  $W/  <- the wrappers; use the one for the endpoint you are calling

Spell the path literally and re-run. If the escape is genuinely required and no
wrapper can express the call, prefix MOSAIC_WRAPPER_OVERRIDE=1 (as the first
thing in the command — it is read positionally, not matched as text).
EOF
      exit 2
    fi

    # The endpoint map, and the rule that keeps it honest: an arm exists here
    # ONLY because a wrapper in this directory owns that call. It is an
    # inventory, not a model — read off `ls tools/git/*.sh` and the flags each
    # script accepts, so it can be re-derived and checked rather than believed.
    # Second column is what the wrapper SPANS; a partial span must be stated in
    # the block message, never rounded up to ownership of the whole endpoint.
    #
    #   /pulls/{n}/reviews          pr-review.sh          full
    #   /pulls/{n}/merge            pr-merge.sh           full (-m method, -d)
    #   /issues/{n}/comments        issue-comment.sh      create only (POST)
    #   /issues|pulls/{n}/assignees issue-assign.sh       full (-a, -r)
    #   /issues|pulls/{n}/labels    issue-edit.sh         sets the whole list;
    #                               issue-assign.sh -l    same
    #   /milestones/{n}             milestone-close.sh    CLOSE ONLY — title,
    #                                                     description, due date
    #                                                     are a wrapper gap
    #   /issues/{n}                 issue-edit.sh         title/body/labels/
    #                                                     milestone; close/reopen
    #                                                     for state; assignee is
    #                                                     issue-assign.sh
    #   /pulls/{n}                  pr-close.sh           state only — title and
    #                                                     body are a wrapper gap
    #   /pulls, /issues,            the create wrappers   full
    #   /milestones
    #
    # Owned by nothing, so they flow through: /issues/comments/{id} (a comment
    # EDIT), /pulls/{n}/requested_reviewers, and the residue caught by
    # subtraction below.
    #
    # Round seven had a single arm allowing EVERY path under a numbered issue or
    # PR, on the reasoning that no wrapper owned any of them. Review showed that
    # was false in this tree — issue-edit.sh takes --title/--body/--labels/
    # --milestone and issue-assign.sh takes assignee/labels/milestone — so the
    # guard was answering "allow" because wrapper ownership had been ASSUMED
    # absent instead of looked up. That is the same absence-driven allow the
    # whole file exists to remove, committed inside the fix for it. The lesson
    # is not "block more"; it is that ownership is an inventory question and an
    # inventory has to be read.
    #
    # Wrong advice remains its own defect — a block an agent cannot comply with
    # teaches that the hook is broken and the override is routine, and a routine
    # override is a guard that is off. So the fix is precision in BOTH
    # directions: every arm names the wrapper that actually owns the call, and
    # anything genuinely unowned still flows through (below).
    #
    # Round eight got the inventory right and the SPAN wrong, which review caught
    # on /milestones/{n}: milestone-close.sh takes only -t <title> and hardcodes
    # state=closed, so it cannot express a title, description or due-date edit,
    # and naming it there told an agent to use a wrapper that cannot make the
    # call. "Which wrapper touches this endpoint" is the wrong question; "does
    # the wrapper SPAN this endpoint" is the right one. Where a wrapper owns only
    # a slice, `alsoown` must say which slice and name the rest as a gap — the
    # treatment /pulls/{n} already had, and that two other arms did not, so this
    # was a consistency failure rather than a missing idea. Auditing every arm
    # for span (not just the reported one) is what found requested_reviewers.
    endpoint=""; wrapper=""; alsoown=""
    case "$CMD" in
      # Requesting a reviewer is not submitting one. pr-review.sh takes
      # -a <action> -c <comment> and files a verdict; nothing in the tree adds a
      # requested reviewer. Unowned, so it flows through — placed above the
      # reviews arm so it cannot be refused with "use pr-review.sh".
      *"/pulls/"*"/requested_reviewers"*) : ;;
      *"/pulls/"*"/reviews"*) endpoint="pull-request review"; wrapper="pr-review.sh" ;;
      *"/pulls/"*"/merge"*)   endpoint="pull-request merge";  wrapper="pr-merge.sh"  ;;
      # A comment EDIT/DELETE lives at /issues/comments/{id} — a sibling of the
      # numbered issue, not a child of it. issue-comment.sh only creates, so
      # nothing owns this one. Placed above the create arm so it cannot be
      # refused with "use issue-comment.sh", which would be the wrong call.
      *"/issues/comments/"*)  : ;;
      *"/issues/"*"/comments"*) endpoint="issue comment";     wrapper="issue-comment.sh" ;;
      *"/issues/"*"/assignees"*|*"/pulls/"*"/assignees"*)
                              endpoint="issue assignee";      wrapper="issue-assign.sh" ;;
      *"/issues/"*"/labels"*|*"/pulls/"*"/labels"*)
                              endpoint="issue label";         wrapper="issue-edit.sh"
                              alsoown="issue-assign.sh -l sets labels too (and the milestone)." ;;
      *"/milestones/"[0-9]*)  endpoint="milestone";            wrapper="milestone-close.sh"
                              alsoown="milestone-close.sh owns the CLOSE only — it takes -t <title>
and sends state=closed. A milestone's title, description or due date is a real
wrapper gap: no tool in this tree edits them, and the override exists for it." ;;
      # The numbered object itself. These two arms are the fuzzy ones — they
      # match a number and then anything — so they are refined immediately
      # below rather than trusted as written.
      *"/issues/"[0-9]*)      endpoint="issue edit";          wrapper="issue-edit.sh"
                              alsoown="issue-close.sh and issue-reopen.sh own the state change, and
issue-assign.sh owns the assignee, labels and milestone fields at this same
number — issue-edit.sh does not set an assignee." ;;
      *"/pulls/"[0-9]*)       endpoint="pull-request edit";   wrapper="pr-close.sh"
                              alsoown="pr-close.sh owns state=closed. A PR's labels, assignee and
milestone are the ISSUE object on both providers, so issue-edit.sh and
issue-assign.sh own those at the same number. Nothing wraps a PR title/body
edit — that one is a real wrapper gap, and the override exists for it." ;;
      *"/pulls"*)             endpoint="pull request";        wrapper="pr-create.sh" ;;
      *"/issues"*)            endpoint="issue";               wrapper="issue-create.sh" ;;
      *"/milestones"*)        endpoint="milestone";           wrapper="milestone-create.sh" ;;
    esac

    # Refine the two fuzzy arms, and note WHY this is a regex and not another
    # case arm: `case` globs cannot express a path SEGMENT, so an allow arm
    # written as *"/issues/"[0-9]*"/"* would clear
    #   gh api -X PATCH repos/a/b/issues/1 -f body="see /docs"
    # on the strength of a slash inside the body. An allow decided by a glob
    # over the whole command is exactly the fail-open shape this file keeps
    # finding; the regex pins the segment to the number.
    #
    # The residue is defined by SUBTRACTION rather than by listing provider API
    # surface: every subresource a wrapper owns was consumed by an arm above, so
    # whatever still carries /issues|pulls/{n}/<segment> here is owned by
    # nothing — times, stopwatch, reactions, subscriptions, dependencies, a PR's
    # files or commits. Listing them instead would rot the moment a provider
    # adds one, and rot in the blocking direction with wrong advice.
    # The refinement is an ALLOW, and an allow decided by a test over the whole
    # command is the fail-open shape this file keeps rediscovering — the comment
    # above says exactly that about `case` globs, and then the regex it replaced
    # them with made the same mistake one level down. Asking "does a subresource
    # appear ANYWHERE in this command" cleared a write on the strength of text
    # that was not the endpoint:
    #
    #   gh api -X PATCH repos/a/b/issues/1 -f body="see /pulls/2/files"
    #   gh api -X PATCH repos/a/b/issues/1 -f body="cf /issues/3/reactions"
    #
    # Both PATCH the numbered issue that issue-edit.sh owns, and both were
    # allowed because a subresource appeared in the BODY. Same class as the
    # backslash-newline case at the top of the file: the guard read the text as
    # typed instead of the call being made.
    #
    # Extracting "the endpoint token" is the parser problem this file already
    # refused to take on, so the test is inverted instead, which needs no parser:
    # clear ONLY when every numbered-object occurrence in the command carries a
    # subresource. One bare /issues/{n} anywhere means a wrapped call may be in
    # play, and the block stands. Cost, in the same direction as every other
    # trade here: writing to /issues/1/reactions while quoting /issues/2 is
    # refused. Over-blocking costs an override on a rare command; the reverse
    # cost is a silent raw write to a wrapped endpoint.
    case "$endpoint" in
      "issue edit"|"pull-request edit")
        occ="$(printf '%s' "$CMD" | grep -oE '/(issues|pulls)/[0-9]+(/[A-Za-z_])?' || true)"
        if [ -n "$occ" ] && ! printf '%s' "$occ" | grep -q '[0-9]$'; then
          endpoint=""; wrapper=""; alsoown=""
        fi ;;
    esac

    # An endpoint the guard cannot READ is an endpoint the guard must not CLEAR.
    #
    # Round one fixed one spelling of this and review immediately produced the
    # general form: split the endpoint token itself across two variables —
    #   a=/api/v1/repos/o/r/iss; b=ues/1/comments
    #   curl -d@body "https://host${a}${b}"
    # — and no fragment above ever appears contiguously. Chasing that with more
    # fragments is unwinnable: the endpoint does not exist until the shell
    # expands it, and this hook runs before that.
    #
    # So stop pretending to read it. If a write's endpoint contains an expansion,
    # the guard has no endpoint to judge, and "no endpoint" must not mean
    # "allowed" — that is the same absence-driven allow as the missing-wrapper
    # case, wearing different clothes.
    #
    # SPAN, and the defect review found here: a fail-closed rule must cover the
    # same surface as the block it guards. This test asked only for `https?://`
    # while the scope gate above had already been widened to three shapes, so
    #   p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x
    #   p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}
    # were in scope to be blocked, produced no readable endpoint, and then fell
    # through to ALLOW — while the identical split behind a literal `https://`
    # blocked. Same shape as the milestone arm one round earlier: the correct
    # treatment already existed and was applied to one of the surfaces it
    # covered. A control is only as wide as its narrowest arm.
    #
    # Three arms, one per shape the scope gate admits:
    #   A  a scheme-bearing URL token carrying an expansion
    #   B  a schemeless token carrying BOTH a forge fragment and an expansion
    #   C  the endpoint argument of a provider-CLI `api` call carrying one
    #
    # Stated limits, because a control may not claim more than it measures. B
    # requires the fragment and the expansion in the SAME shell token, so a
    # caller who splits the hostname and `/api/` as well gets through. C reads
    # the endpoint positionally — the first bare token after `api` and its option
    # run — so an endpoint pushed past an option whose value itself contains
    # whitespace is not seen. Both are deliberate: this hook stops mistakes, it
    # is not a sandbox, and pretending otherwise is how you get a control nobody
    # can trust the boundaries of.
    #
    # C's option run also accepts the bare `--` end-of-options marker, because
    # review found that `gh api -X POST -- ${p}${q} -f title=x` walked straight
    # past an option class that required a letter after the dashes. The marker is
    # the one "option" that is not spelled like one, and a scanner that skips
    # options had to be told that.
    #
    # Note what is NOT unreadable: an expansion in a BODY (`-d "$BODY"`,
    # `-f sha=$SHA`) leaves the endpoint perfectly legible, and blocking it would
    # punish the safest way to pass a payload. Only the endpoint region counts.
    URLTOK='[^[:space:]"'"'"'|;&)]*'
    FORGE='(/api/v[0-9]|/repos/|git\.|gitea|github\.com|gitlab|forgejo)'
    unreadable=0
    if printf '%s' "$CMD" | grep -Eq "https?://$URLTOK"'[$`]' \
       && printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi
    printf '%s' "$CMD" | grep -Eq \
      "$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1
    # THIRD name consumer, and the one that proves the point about a single
    # site: while the scope gate above was repaired for path- and quote-dressed
    # provider CLIs, this fail-closed refinement kept its own bare-name copy of
    # the same regex against raw $CMD. A caller could therefore enter the scope
    # gate through the fixed check and then fail to be recognized by the arm
    # that refuses unreadable endpoints — name recognition differing between a
    # gate and its own refinement, which is the defect one layer downstream.
    # It reads $CMD_NAMES through $NAME_PREFIX like every other name check.
    #
    # The endpoint tail still asks $CMD, deliberately: this arm fires on an
    # endpoint the guard CANNOT READ, and the expansion markers that make it
    # unreadable are exactly the characters $CMD_NAMES removes. Reading the tail
    # from the normalized copy would erase the evidence the check exists to find.
    #
    # The tail carries NO name of its own. Leaving one there was the same defect
    # a third time in the same edit — the name gate would recognize `g"h" api`
    # while the tail still demanded the undressed spelling, so the two halves
    # disagreed about the same caller and the refinement failed open. Each half
    # now asks exactly one question: the name gate asks WHO, from the normalized
    # copy; the tail asks whether the ENDPOINT is readable, from the raw text.
    if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)" \
       && printf '%s' "$CMD" | grep -Eq \
      '(^|[[:space:]])api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]'; then
      unreadable=1
    fi

    if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then
      cat <<EOF
BLOCKED: raw provider API write whose endpoint this guard cannot read.

The endpoint is assembled from shell expansions, so the path it names does not
exist until the shell builds it — after this check runs. The guard cannot tell
whether it is a wrapped endpoint, and an unreadable endpoint is not a cleared
one.

  $W/  <- the wrappers; use the one for the endpoint you are calling

If you are calling a wrapped endpoint (reviews, merges, comments, pulls,
issues, milestones), use the wrapper — it also resolves identity explicitly,
which matters on a host whose default provider login is an admin account.

If this is genuinely not a provider endpoint, either write the endpoint
literally so the guard can see what it is, or prefix MOSAIC_WRAPPER_OVERRIDE=1.
A variable in the BODY is fine and does not trigger this; only the endpoint
itself has to be legible.
EOF
      exit 2
    fi

    # Block on the ENDPOINT, never on whether the wrapper file happens to exist.
    # The previous version required `[ -x "$W/$wrapper" ]`, which meant a host
    # with a broken or absent install allowed exactly the raw writes the guard
    # exists to stop — an absence-driven allow, and the second one found in this
    # file. A missing wrapper is a broken install; it is not a licence to bypass
    # gate 7. Say so, and say which is which.
    if [ -n "$endpoint" ]; then
      if [ -x "$W/$wrapper" ]; then
        remedy="Use the wrapper the Constitution (gate 7) requires:

  $W/$wrapper

Run \`$wrapper --help\` for the flags."
        # Several wrappers can own one endpoint (labels are settable from both
        # issue-edit.sh and issue-assign.sh; state has its own pair). Naming
        # only one of them is how a correct block still ends up reading as
        # wrong advice, so say which wrapper owns which part of the call.
        [ -n "$alsoown" ] && remedy="$remedy

$alsoown"
      else
        remedy="The wrapper that covers this endpoint is \`$wrapper\`, and it is NOT
present or not executable at:

  $W/$wrapper

That is a broken or incomplete install, not permission to send the call raw.
Repair the install (\`mosaic doctor\`) and use the wrapper."
        [ -n "$alsoown" ] && remedy="$remedy

$alsoown"
      fi
      cat <<EOF
BLOCKED: raw provider API write to the $endpoint endpoint.

$remedy

The wrappers are not a formality. They carry provider-dialect differences that
raw curl silently gets wrong — Gitea's review event is APPROVED, GitHub's is
APPROVE, and Gitea accepts the wrong one with HTTP 200 while filing the review
as PENDING. They also resolve identity explicitly, which matters on a host
where the default login is an admin account.

If no wrapper flag can express this call, that is a wrapper gap: extend the
wrapper. To proceed anyway for a genuine gap, prefix MOSAIC_WRAPPER_OVERRIDE=1.
EOF
      exit 2
    fi
  fi
fi

# ---- 3. the APPROVE/APPROVED trap, wherever it appears ---------------------
# Both spellings the trap arrives in: the JSON body `"event": "APPROVE"` and the
# provider-CLI field `-f event=APPROVE`. The trailing [^A-Z] is what keeps the
# correct value out of it — APPROVED must never match.
if printf '%s' "$CMD" | grep -Eq 'event"?[[:space:]]*[=:][[:space:]]*"?APPROVE([^A-Z]|$)'; then
  cat <<EOF
BLOCKED: review event "APPROVE" is not valid on Gitea.

Gitea's vocabulary is "APPROVED". It accepts "APPROVE" with HTTP 200, silently
files the review as PENDING, and then fails the submit endpoint with
422 "review stay pending" — so the verdict looks placed and is not.

("REQUEST_CHANGES" is spelled identically on both providers; only the approve
path carries this trap.)

Use $W/pr-review.sh, which sends the correct token for the detected provider.
Whatever you use, re-read GET /pulls/{n}/reviews and assert state==APPROVED
before reporting a verdict placed.
EOF
  exit 2
fi

exit 0
