#!/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 # 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=, 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 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 < # /src/-worktrees/ ~/.config/mosaic/tools/git/mosaic-worktree.sh path # show where it would go ~/.config/mosaic/tools/git/mosaic-worktree.sh rm # 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 < %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 < 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 -c 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 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