diff --git a/.claude/hooks/enforce-pr-target.sh b/.claude/hooks/enforce-pr-target.sh new file mode 100755 index 0000000..63465c0 --- /dev/null +++ b/.claude/hooks/enforce-pr-target.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# +# PreToolUse(Bash) hook — hard backstop against opening PRs on the upstream fork parent. +# +# This repo (and the sibling fork clones) is a FORK of greyhavens/*. `gh pr create` with no +# -R/--repo defaults its base repo to the fork PARENT, so a bare invocation opens a PR against +# greyhavens by mistake (it has, more than once). The CLAUDE.md guidance + `gh repo set-default` +# are soft — this hook is the enforced version: it DENIES any `gh pr create` not explicitly aimed +# at a claridtimo/* repo, and feeds the reason back so the model just re-runs correctly. +# +# WHY it parses argv instead of grepping raw text: earlier revisions matched the raw command +# string, which mis-read a `-R claridtimo/…` substring inside a --title/--body (false allow) and +# split on shell operators inside a quoted value (false deny). In THIS repo those aren't +# pathological — we routinely write PRs whose titles/bodies contain gh examples, shell snippets, +# and ordinary apostrophes ("don't"). So the precise path tokenizes the command the way a shell +# actually would, with Python's `shlex` (the reference POSIX shell lexer — it handles nested quote +# types, e.g. an apostrophe inside a double-quoted title, which a bash/xargs tokenizer cannot do +# portably). Only a real `-R`/`--repo` flag token — never text inside a quoted value — counts as a +# target, and clause boundaries are only real operator tokens. +# +# Dependency posture: a PreToolUse hook that ERRORS is treated as non-blocking, so a hard +# dependency would silently REMOVE the guard on a box that lacks it. The precise path uses python3 +# (stdlib only: json + shlex), guarded by `command -v`. If python3 is absent OR the command can't +# be parsed, the script DEGRADES to a conservative text check that never false-denies a targeted PR +# and still catches the common bare omission. It never exits non-zero; blocking is via the JSON +# "deny", not the exit code. + +INPUT=$(cat) + +# Cheap prefilter before anything else: a deny can only ever fire on a clause containing the +# tokens `gh` … `pr` … `create`, and no shell QUOTING can produce those tokens without the letters +# "gh" and "create" appearing verbatim in the raw input (JSON never escapes letters). So for the +# overwhelmingly common unrelated Bash call, skip the python3 spawn (and the greps) entirely. +# A deliberately backslash-escaped `crea\te` slips past this — deliberate evasion is out of the +# threat model (the hook guards against ACCIDENTAL omission), same class as shell aliases. +case "$INPUT" in *gh*) : ;; *) exit 0 ;; esac +case "$INPUT" in *create*) : ;; *) exit 0 ;; esac + +emit_deny() { + cat <<'JSON' +{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"PRs must target the claridtimo fork, never upstream greyhavens. This `gh pr create` has no explicit claridtimo target (as a real -R/--repo FLAG, not text inside a --title/--body), so gh would default its base repo to the upstream fork parent. Re-run with the flag aimed at claridtimo, e.g.:\n gh pr create -R claridtimo/ --base --head ...\nA target on a different &&-chained command, or one that only appears inside a quoted title/body, does NOT count."}} +JSON +} + +# ---- Precise path: python3 (stdlib json + shlex) -------------------------------------------- +# Emits exactly one word on stdout: DENY, ALLOW, or FALLBACK. Any crash / missing python / weird +# exit prints nothing, and we fall through to the degraded check below. Never trusts a partial parse. +if command -v python3 >/dev/null 2>&1; then + # Input goes via env var (HOOK_INPUT), leaving stdin free for the heredoc'd script itself. + verdict=$(HOOK_INPUT="$INPUT" python3 - <<'PY' 2>/dev/null +import json, re, shlex, sys, os +try: + cmd = json.loads(os.environ.get("HOOK_INPUT", "")).get("tool_input", {}).get("command", "") +except Exception: + print("FALLBACK"); sys.exit(0) +if not cmd: + print("FALLBACK"); sys.exit(0) +def scan_subst(s, i, opener): + # Scan a command-substitution / subshell body starting at s[i] (the char AFTER the opener). + # opener "(" ends at its nesting-matched ")" (honoring quotes and backslashes, the way bash + # re-parses the inside of $() as a fresh context); opener backtick ends at the next unescaped + # backtick (backticks don't nest unescaped). Unterminated bodies consume to end-of-string — + # that input is malformed shell that bash would refuse to run, so any verdict is safe. + # Returns (content, index_after_closer). + q2 = None + depth = 1 + buf = [] + j, n = i, len(s) + while j < n: + c = s[j] + if q2 is not None: + buf.append(c) + if c == q2: + q2 = None + elif c == "\\" and q2 == '"' and j + 1 < n: + buf.append(s[j + 1]); j += 2; continue + j += 1; continue + if c == "\\" and j + 1 < n: + buf.append(c); buf.append(s[j + 1]); j += 2; continue + if c in ("'", '"'): + q2 = c; buf.append(c); j += 1; continue + if opener == "(": + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + return "".join(buf), j + 1 + elif c == chr(96): + return "".join(buf), j + 1 + buf.append(c); j += 1 + return "".join(buf), n + +def to_separators(s, depth=0): + # shlex.split() is a quote-aware WORD splitter, not a shell control-operator parser: it only + # treats & | ; and newlines as separators when whitespace already surrounds them. Real bash + # splits on them regardless (`echo a&&echo b` is two commands). So a quote-aware pre-pass + # rewrites every shell metacharacter the way a shell lexer would: + # - unquoted control operators (& | ; newline) become " ; " — a clause boundary; + # - unquoted redirections (< >, and an & glued to one: 2>&1, &>f) become " " — a TOKEN + # boundary but NOT a clause boundary, since a redirect doesn't end the command + # (`gh pr create>out` must still be seen as a create, and a `-R` AFTER `2>&1` is still + # part of the same clause); + # - an unquoted # at word start drops the rest of the line (a comment; a mid-word # stays + # literal, matching shell) — a `-R claridtimo/…` living only in a comment can't count; + # - command substitutions and subshells — $(…), `…`, bare (…) — are EXTRACTED: the body is + # removed from the enclosing command (which stays contiguous, so a substitution used as a + # flag value can't split a targeted create away from its -R) and appended as its own + # " ; "-separated clause, recursively pre-passed, so an inner `gh pr create` is judged on + # its own. This applies inside DOUBLE quotes too — bash executes $()/backticks there + # (only single quotes are inert), so a create hidden in a --title "… $(gh pr create …)" + # is still caught (11th review round). + # Other quoted metacharacters (a --body/title) are preserved; a backslash-escaped one is + # preserved for shlex to handle. Remaining blind spots, all deliberate-evasion class (out of + # threat model — the guard is against ACCIDENTAL omission): shell aliases, backslash-escaped + # letters (`crea\te`), ${var@P}-style expansion tricks. Heredoc BODIES are not + # quote-delimited, so a bare `gh pr create` EXAMPLE inside a heredoc'd --body-file can + # false-DENY — the safe direction; re-run with the example inside a quoted --body or a file. + if depth > 10: + raise ValueError("substitution nesting too deep") # → caller falls back (conservative) + out = [] + extracted = [] + q = None + i, n = 0, len(s) + while i < n: + c = s[i] + if q is not None: # inside a quote + if q == '"': + # bash still runs $() and backticks inside double quotes — extract them + if c == "$" and i + 1 < n and s[i + 1] == "(": + body, i = scan_subst(s, i + 2, "(") + extracted.append(body); continue + if c == chr(96): + body, i = scan_subst(s, i + 1, chr(96)) + extracted.append(body); continue + if c == "\\" and i + 1 < n: + if s[i + 1] == "\n": # line continuation: bash removes both chars + i += 2; continue + out.append(c); out.append(s[i + 1]); i += 2; continue + out.append(c) + if c == q: + q = None + i += 1; continue + if c in ("'", '"'): + q = c; out.append(c); i += 1; continue + if c == "\\" and i + 1 < n: # unquoted backslash escapes the next char + if s[i + 1] == "\n": # line continuation: bash removes both chars, + i += 2; continue # joining the surrounding text (16th round) + out.append(c); out.append(s[i + 1]); i += 2; continue + if c == "#" and (i == 0 or s[i - 1] in " \t&|;()<>\n\r" or s[i - 1] == chr(96)): + while i < n and s[i] != "\n": # comment: shell ignores to end of line + i += 1 + continue + # The " __subst__ " placeholder keeps the token count intact: a substitution used as a + # flag VALUE (--title $(gen) -R …) must still occupy the value slot, or the value flag + # would consume the following -R as its value and false-deny a targeted create. + if c == "$" and i + 1 < n and s[i + 1] == "(": + body, i = scan_subst(s, i + 2, "(") + extracted.append(body); out.append(" __subst__ "); continue + if c == "(": # bare subshell / grouping + body, i = scan_subst(s, i + 1, "(") + extracted.append(body); out.append(" __subst__ "); continue + if c == chr(96): + body, i = scan_subst(s, i + 1, chr(96)) + extracted.append(body); out.append(" __subst__ "); continue + if c in "<>": + out.append(" ") # redirection: token boundary, not clause boundary + i += 1; continue + if c == "&" and ((i + 1 < n and s[i + 1] in "<>") or (i > 0 and s[i - 1] in "<>")): + out.append(" ") # & that is part of a redirect (2>&1, &>f, <&0) + i += 1; continue + if c == "|": + if i + 1 < n and s[i + 1] == "|": + out.append(" ; "); i += 2; continue # || is OR — a plain clause boundary + if i + 1 < n and s[i + 1] == "&": + out.append(" __pipe__ "); i += 2; continue # |& pipes stdout+stderr + out.append(" __pipe__ "); i += 1; continue # a real pipe: the next clause reads + # this clause's stdout as ITS stdin — judge() uses this to catch `echo … | bash` + out.append(" ; " if c in "&;)\n\r" else c) # stray ")" = malformed; split conservatively + i += 1 + for body in extracted: + out.append(" ; ") + out.append(to_separators(body, depth + 1)) + return "".join(out) + +# After the pre-pass every unquoted separator is a lone " ; " or " __pipe__ ", so these are the +# only clause-boundary tokens shlex can produce here (operator text inside quotes stays part of +# its value token). The pipe stays distinct because `echo "gh pr create …" | bash` EXECUTES the +# echoed text (17th review round) — judge() carries an echo/printf clause's payload across a +# pipe boundary and judges it when the receiving clause is a shell reading stdin (no -c). +OPS = {";", "__pipe__"} +# gh flags that consume the NEXT token as an opaque value; that value must never be read as an +# operator or a flag. -R/--repo are handled explicitly below (their value is what we inspect). +VALUE_FLAGS = {"-t","--title","-b","--body","-F","--body-file","-B","--base","-H","--head", + "-l","--label","-a","--assignee","-r","--reviewer","-m","--milestone", + "-p","--project","-T","--template","--recover"} + +# Shell-wrapper basenames whose `-c ` argument is itself a command (15th review round: +# `bash -c "gh pr create …"` is an ORDINARY idiom, inside the accidental-omission threat model — +# and shlex collapsing the string to one opaque token had silently ALLOWED it, a regression vs +# the old raw-grep hook). Such strings are recursively judged as commands, as is everything +# after `eval` (which concatenates its args and executes them). Not covered: `ssh host "…"` / +# `su -c` (remote/privileged contexts our agents never route gh through — and the degraded grep +# below still catches those textually) and non-shell interpreters (`python -c 'os.system(…)'`, +# deliberate-evasion class). +SHELLS = {"bash", "sh", "zsh", "dash", "ksh"} +# Wrappers that keep the following word at command position (their own options/durations are +# skipped by the dash/numeric rules at the use site). +PREFIXES = {"sudo", "doas", "env", "nohup", "setsid", "command", "exec", "time", "xargs", + "nice", "ionice", "stdbuf", "timeout", "strace", "ltrace"} + +def judge(cmd, depth=0): + # Returns True if any clause anywhere in cmd (including inside bash -c / eval strings) is an + # untargeted `gh pr create`. Raises ValueError on unparseable input → caller falls back. + if depth > 5: + raise ValueError("wrapper nesting too deep") + toks = shlex.split(to_separators(cmd)) # POSIX tokenization; unquoted newlines act as ";" + deny = False + gh = pr = create = targeted = False + cmd_pos = True # scanning the clause's COMMAND position (vs its arguments) + printed = None # args of the echo/printf clause just scanned (they were PRINTED) + piped = None # that payload, if the boundary we just crossed was a pipe + i, n = 0, len(toks) + while i < n: + t = toks[i] + if t in OPS: # clause boundary: judge the clause we just finished + if gh and pr and create and not targeted: + deny = True + # an echo/printf payload only survives across a PIPE — `echo … | bash` feeds it to + # the shell's stdin, while `echo …; bash` prints and moves on (17th review round) + piped = printed if t == "__pipe__" else None + printed = None + gh = pr = create = targeted = False + cmd_pos = True + i += 1 + continue + if cmd_pos: + if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", t): + i += 1; continue # leading VAR=val assignment — still at command position + base = t.rsplit("/", 1)[-1] + # command-position-preserving prefixes and their option/duration arguments: after + # `sudo`/`env`/`timeout 5`/`nice -n 10`/`xargs`/… the NEXT word is still the invoked + # command + if base in PREFIXES or re.match(r"^[0-9]+[smhd]?$", t): + i += 1; continue + if t.startswith("-"): + # a prefix option may take a VALUE (`sudo -u root`, `xargs -I {}`): consume the + # following plain word as that value so the wrapper AFTER it is still judged at + # command position (20th review round: `sudo -u root bash -c "…"` bypassed the + # wrapper check when `root` closed command position). The word is NOT consumed + # when it is itself a shell/eval — a no-value option directly before the command + # (`env -i bash -c "…"`) is likelier than a value named after a shell. + nxt = toks[i + 1] if i + 1 < n and toks[i + 1] not in OPS else None + if nxt and not nxt.startswith("-") and not re.match(r"^[0-9]+[smhd]?$", nxt) \ + and nxt.rsplit("/", 1)[-1] not in SHELLS and nxt != "eval" \ + and not re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", nxt): + i += 2; continue + i += 1; continue + cmd_pos = False + # this token IS the clause's invoked command. Wrapper/print semantics apply ONLY + # here: bash/eval/echo as a mere ARGUMENT (`grep eval -c "gh pr create test" f`, + # `… | grep bash`) neither executes nor prints anything (19th review round — those + # shapes were false-denied when wrappers matched anywhere in the clause). + if base in ("echo", "printf"): + # a print clause never EXECUTES its arguments — skip it whole, but REMEMBER + # them: if this clause pipes into a stdin-reading shell, the printed text + # becomes commands after all (16th/17th review rounds) + args = [] + i += 1 + while i < n and toks[i] not in OPS: + args.append(toks[i]); i += 1 + printed = args + continue + if base in SHELLS: + # scan this clause for -c (alone or in a cluster like -lc); its argument is a + # command in its own right + j = i + 1 + saw_c = False + while j < n and toks[j] not in OPS: + f = toks[j] + if f.startswith("-") and not f.startswith("--") and "c" in f: + saw_c = True + if j + 1 < n and toks[j + 1] not in OPS and judge(toks[j + 1], depth + 1): + deny = True + break + j += 1 + # no -c: the shell reads stdin — if an echo/printf payload was piped in, judge + # it (`cat file | bash` etc. remain unjudgeable: unknown content, degraded grep + # only; multi-hop pipes like `echo … | tee f | bash` drop the payload — accepted) + if not saw_c and piped: + if judge(" ".join(piped), depth + 1): + deny = True + piped = None + # fall through: the shell token itself still walks the generic checks below + if t == "eval": # eval concatenates its args and executes them + j = i + 1 + args = [] + while j < n and toks[j] not in OPS: + args.append(toks[j]); j += 1 + if args and judge(" ".join(args), depth + 1): + deny = True + i = j # the args were judged in recursion, not in this walk + continue + elif t in ("-exec", "-execdir", "-ok"): + cmd_pos = True # find(1): the token after -exec is an invoked command + i += 1 + continue + if t == "gh" or t.endswith("/gh"): # bare `gh` or a full/relative path like /usr/bin/gh + gh = True + elif t == "pr" and gh: + pr = True + elif t == "create" and pr: + create = True + # Each -R/--repo SETS the target verdict from its own value — last flag wins, matching gh's + # repeated-flag semantics (a later -R greyhavens/... after -R claridtimo/... must UN-target). + if t in ("-R", "--repo"): # target flag; value is the next token + if i + 1 < n and toks[i + 1] not in OPS: + targeted = toks[i + 1].lower().startswith("claridtimo/") + i += 2 + else: # dangling flag at a clause boundary: no value, and the + targeted = False # boundary token must still be processed (18th round) + i += 1 + continue + if t.startswith("-R=") or t.startswith("--repo="): + targeted = t.split("=", 1)[1].lower().startswith("claridtimo/") + elif t.startswith("-R") and len(t) > 2: # -Rclaridtimo/… glued short form + targeted = t[2:].lower().startswith("claridtimo/") + if t in VALUE_FLAGS: # next token is this flag's opaque value — skip it, + # unless it is a clause boundary (a dangling value flag must not swallow the OPS + # token — the clause-reset/deny-check there is what the state machine relies on) + i += 2 if (i + 1 < n and toks[i + 1] not in OPS) else 1 + continue + i += 1 + if gh and pr and create and not targeted: + deny = True + return deny + +try: + deny = judge(cmd) +except ValueError: + print("FALLBACK"); sys.exit(0) # unbalanced quotes / absurd nesting → degraded path decides +print("DENY" if deny else "ALLOW") +PY +) + case "$verdict" in + DENY) emit_deny; exit 0 ;; + ALLOW) exit 0 ;; + *) : ;; # FALLBACK / empty (python crashed or missing) → degraded path below + esac +fi + +# ---- Degraded path: no python3, or the command couldn't be parsed — conservative ------------- +# Deny only the clear-cut case: a `gh pr create` with no -R/--repo flag ADJACENT to a +# "claridtimo/" value anywhere in the input. The adjacency requirement matters: the hook stdin +# is the whole PreToolUse payload (cwd, transcript_path, …), so a bare "claridtimo/" substring +# test would false-ALLOW every bare create on a box whose checkout PATH contains "claridtimo" +# (10th review round) — and the degraded path exists precisely for such less-set-up boxes. +# Requiring the flag form still NEVER false-denies a targeted PR (every targeted create carries +# `-R claridtimo/…`, `-R=…`, `-Rclaridtimo/…`, or a --repo equivalent — all matched below). +# +# KNOWN, ACCEPTED under-blocks (12th review round) — this path prioritizes never-false-denying +# over completeness, and cannot have both without a real tokenizer: +# - a flag-shaped `-R claridtimo/…` inside a quoted --title/--body satisfies the check; +# - a real target on an UNRELATED chained clause (`gh pr view -R claridtimo/x; gh pr create`) +# satisfies a bare create elsewhere in the same input. +# Clause-scoping this fallback with sed/grep was TRIED (the v2 hook that #61 merged) and +# reverted: without a quote-aware tokenizer, operators inside quoted PR bodies split clauses +# wrongly and false-denied real targeted creates — the exact bug class this rework removes. +# Both shapes are pinned in test-enforce-pr-target.sh (degraded-ALLOW vs precise-DENY) so a +# future change flipping either direction fails the battery. Every box we actually use has +# python3; this is a last-resort backstop, and `gh repo set-default` (bin/setup-gh-defaults) +# remains the primary guard. +# [Cc]laridtimo: GitHub owner names are case-insensitive (the precise path lowercases the whole +# value; here only the realistic accidental variant, a capitalized C, is matched — the flag part +# stays case-sensitive so -r/--reviewer values are not read as targets). +# SEP: a separator between tokens can be REAL whitespace or its JSON-ESCAPED form — the hook +# stdin is a JSON document, so a newline/tab inside the command arrives as the two characters +# \n / \t and a shell line-continuation backslash as \\ (16th review round: a backslash- +# continued `gh pr \create` must still match on exactly the boxes this fallback +# protects; the same class must count as flag/value adjacency or a continued targeted create +# would false-deny). +SEP='([[:space:]]|\\n|\\t|\\r|\\\\)' +# Scope the greps to the "command" FIELD when extractable: the payload also carries description/ +# cwd/transcript_path, and a description that MENTIONS the intended target must not satisfy the +# check for a command that forgot the flag — nor should a description quoting `gh pr create` +# false-deny an unrelated command (18th review round). The ERE walks escaped chars inside the +# JSON string value; if extraction yields nothing (unexpected payload shape), fall back to the +# whole input, which errs toward DENY only for inputs that contain the create phrase anyway. +# (grep only — the degraded path must not depend on anything beyond bash/grep/printf/cat) +SCOPE=$(printf '%s' "$INPUT" | grep -oE '"command"[[:space:]]*:[[:space:]]*"(\\.|[^"\\])*"') +[ -n "$SCOPE" ] || SCOPE="$INPUT" +if printf '%s' "$SCOPE" | grep -qE "gh${SEP}+pr${SEP}+create" \ + && ! printf '%s' "$SCOPE" | grep -qE "(-R|--repo)(=|${SEP})*[Cc]laridtimo/"; then + emit_deny +fi + +exit 0 diff --git a/.claude/hooks/test-enforce-pr-target.sh b/.claude/hooks/test-enforce-pr-target.sh new file mode 100755 index 0000000..fa54297 --- /dev/null +++ b/.claude/hooks/test-enforce-pr-target.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# Regression battery for enforce-pr-target.sh — run after ANY edit to the hook: +# .claude/hooks/test-enforce-pr-target.sh +# Exercises every bypass/false-deny class found across the #61/#63 review rounds, plus the +# degraded (no-python3) path via a stripped PATH. Exits non-zero on any failure. +# (Test-only dependency on python3 for safe JSON construction; the DEGRADED section still +# tests the hook itself without python3 on PATH.) + +set -u +HOOK="$(cd "$(dirname "$0")" && pwd)/enforce-pr-target.sh" +pass=0; fail=0 + +run_case() { # expect(DENY|ALLOW) command [pathenv] + local expect="$1" cmd="$2" pathenv="${3:-$PATH}" + local input verdict out + input=$(python3 -c 'import json,sys; print(json.dumps({"tool_input":{"command":sys.argv[1]}}))' "$cmd") + out=$(printf '%s' "$input" | env PATH="$pathenv" bash "$HOOK") + _judge "$expect" "$out" "$cmd" +} + +run_case_raw() { # expect(DENY|ALLOW) raw-json-input label [pathenv] + local expect="$1" input="$2" label="$3" pathenv="${4:-$PATH}" + local out + out=$(printf '%s' "$input" | env PATH="$pathenv" bash "$HOOK") + _judge "$expect" "$out" "$label" +} + +_judge() { + local expect="$1" out="$2" label="$3" verdict + if printf '%s' "$out" | grep -q '"permissionDecision":"deny"'; then verdict=DENY; else verdict=ALLOW; fi + if [ "$verdict" = "$expect" ]; then + pass=$((pass+1)); echo "ok $expect $label" + else + fail=$((fail+1)); echo "FAIL want=$expect got=$verdict $label" + fi +} + +echo "=== DENY: untargeted creates, incl. every historical bypass class ===" +run_case DENY 'gh pr create --title foo --body bar' +run_case DENY 'gh pr create>out --title foo' # glued redirect (round 9) +run_case DENY 'gh pr create&1' # redirect combo, still untargeted +run_case DENY 'gh pr create --title foo # -R claridtimo/x' # target only in a comment +run_case DENY 'git push && gh pr create -t x' +run_case DENY 'git push&&gh pr create -t x' # glued operator (v6) +run_case DENY 'url=$(gh pr create -t x)' # command substitution (v9) +run_case DENY 'echo `gh pr create -t x`' # backtick substitution (v9) +run_case DENY 'gh pr create --title "use -R claridtimo/bang-game"' # target text inside a title (v3) +run_case DENY 'gh pr create -R greyhavens/bang-game -t x' +run_case DENY 'gh pr create -R claridtimo/x -R greyhavens/y' # last -R wins (v8) +run_case DENY '/usr/bin/gh pr create -t x' # gh by path (v7) +run_case DENY $'git status\ngh pr create -t x' # multi-line (v6) +run_case DENY 'gh pr create -R claridtimo/x && gh pr create -t y' # second clause untargeted + +echo "=== ALLOW: targeted creates + unrelated commands ===" +run_case ALLOW 'gh pr create -R claridtimo/bang-game -t x -b y' +run_case ALLOW 'gh pr create --repo claridtimo/bang-game -t x' +run_case ALLOW 'gh pr create --repo=claridtimo/bang-game -t x' +run_case ALLOW 'gh pr create -Rclaridtimo/bang-game -t x' # glued short form +run_case ALLOW "gh pr create -R claridtimo/x --body 'a && b; gh pr create'" # ops inside quoted body (v3) +run_case ALLOW "gh pr create -R claridtimo/x --title \"don't break\"" # apostrophe (v5) +run_case ALLOW 'gh pr create -R claridtimo/x > /tmp/out' # redirect after target +run_case ALLOW 'gh pr create > /tmp/out -R claridtimo/x' # redirect before target +run_case ALLOW 'gh pr create 2>&1 -R claridtimo/x' # &-in-redirect (was a v9 false-deny) +run_case ALLOW 'gh pr create>log -R claridtimo/x' # glued redirect, still targeted +run_case ALLOW 'gh pr list' +run_case ALLOW 'gh pr view 63 -R claridtimo/bang-game' +run_case ALLOW 'gh pr merge 63 -R claridtimo/bang-game --merge' +run_case ALLOW 'git commit -m "gh pr create later"' # words in a -m value +run_case ALLOW 'echo done' # prefilter early-exit (no gh) +run_case ALLOW './gradlew deploy && echo high create' # prefilter passes, no gh token +run_case ALLOW 'gh pr create --title=has#hash -R claridtimo/x' # mid-word # is NOT a comment +run_case ALLOW 'gh repo create claridtimo/new-repo' # repo create is not pr create + +echo "=== Substitution extraction (round 11): inner commands judged, outer kept contiguous ===" +run_case DENY 'gh pr create -R claridtimo/x -t "notes: $(gh pr create -t oops)"' # dq-hidden create +run_case DENY 'gh pr create -R claridtimo/x -t "notes: `gh pr create -t oops`"' # dq-hidden backtick +run_case DENY 'gh pr create -R claridtimo/x -b "$(echo $(gh pr create -t deep))"' # nested substitution +run_case ALLOW 'gh pr create --title "cost $(compute) done" -R claridtimo/x' # dq subst mid-command +run_case ALLOW 'gh pr create --title $(gen-title) -R claridtimo/x' # unquoted subst mid-command +run_case ALLOW 'gh pr create -R claridtimo/x --title "(parens) are fine"' # plain parens in dq +run_case DENY 'gh pr create --recover -Rclaridtimo/x.txt' # --recover value is opaque, not a -R + +echo "=== Shell wrappers (round 15): bash -c / eval strings are commands too ===" +run_case DENY 'bash -c "gh pr create -t x"' +run_case DENY "bash -lc 'gh pr create -t x'" # combined flag cluster +run_case DENY "sh -c 'gh pr create -t x'" +run_case DENY "/bin/bash -c 'gh pr create -t x'" # shell by path +run_case DENY 'eval "gh pr create -t x"' +run_case DENY 'eval gh pr create -t x' # eval with unquoted args +run_case DENY "nohup bash -c 'gh pr create -t x' &" +run_case ALLOW "bash -c 'gh pr create -R claridtimo/x -t y'" # targeted inside the wrapper +run_case ALLOW 'eval "gh pr view 63 -R claridtimo/x" && echo create' # wrapper runs no create +run_case ALLOW "bash -c 'echo ghost created'" # words, not tokens + +echo "=== Wrappers only at command position (round 19) ===" +run_case ALLOW 'grep eval -c "gh pr create test" file.txt' # eval as a grep ARG +run_case ALLOW 'echo "gh pr create -t x" | grep bash' # bash as a grep ARG +run_case ALLOW 'git log --grep eval -- "gh pr create notes.md"' # wrapper words in args +run_case DENY 'sudo bash -c "gh pr create -t x"' # prefix keeps command position +run_case DENY 'timeout 5 bash -c "gh pr create -t x"' # numeric prefix arg +run_case DENY 'xargs bash -c "gh pr create -t x"' +run_case DENY 'find . -name "*.md" -exec bash -c "gh pr create -t x" \;' # -exec re-arms +run_case DENY 'VAR=1 env bash -lc "gh pr create -t x"' +run_case DENY 'sudo -u root bash -c "gh pr create -t x"' # option VALUE before the shell (round 20) +run_case DENY 'xargs -I {} bash -c "gh pr create -t x"' # unglued option value +run_case DENY 'env -i bash -c "gh pr create -t x"' # no-value option directly before shell +run_case DENY 'sudo -u root gh pr create -t x' # generic detection through prefixes +run_case ALLOW 'sudo -u root bash -c "gh pr create -R claridtimo/x"' # targeted inside sudo-wrapped shell +run_case ALLOW 'sudo -u root gh pr create -R claridtimo/x' + +echo "=== echo/printf clauses print, not execute (round 16) ===" +run_case ALLOW 'echo bash -c "gh pr create -t x"' # echoed text, never run +run_case ALLOW 'echo gh pr create' # literal words to stdout +run_case ALLOW 'printf "%s\n" gh pr create' # printf variant +run_case DENY 'echo done && gh pr create -t x' # later clause still judged + +echo "=== Piped echo payloads (round 17): echo | bash executes the text ===" +run_case DENY 'echo "gh pr create -t x" | bash' +run_case DENY 'printf "gh pr create -t x" | sh' +run_case DENY 'echo gh pr create -t x | bash' # unquoted payload +run_case ALLOW 'echo "gh pr create -R claridtimo/x" | bash' # targeted payload +run_case ALLOW 'echo "gh pr create -t x" | grep create' # pipe into a non-shell +run_case ALLOW 'echo "gh pr create -t x" || bash' # OR, not a pipe: bash gets no stdin script +run_case ALLOW 'echo done | bash' +run_case ALLOW 'echo "gh pr create -t x" ; bash' # printed then interactive shell + +echo "=== Line continuations (round 16): JSON-escaped whitespace in the degraded greps ===" +run_case DENY $'gh pr \\\ncreate -t x' # continued bare create (precise) + +echo "=== Case-insensitive owner match (round 13): GitHub owners are case-insensitive ===" +run_case ALLOW 'gh pr create -R Claridtimo/bang-game -t x' # capitalized owner, targeted +run_case ALLOW 'gh pr create --repo=CLARIDTIMO/bang-game -t x' # any-case owner (precise path) +run_case DENY 'gh pr create -R Greyhavens/bang-game -t x' # case variance is not a pass + +echo "=== FALLBACK route (round 13): python3 present but tokenization fails ===" +# An unbalanced quote makes shlex raise → the precise path prints FALLBACK → the degraded grep +# decides. Distinct from the no-python3 route (PATH-stripped below): this exercises the +# ValueError branch inside the python script itself. +run_case DENY 'gh pr create -t "unbalanced' +run_case ALLOW 'gh pr create -R claridtimo/x -t "unbalanced' + +echo "=== Dangling value flags must not swallow a clause boundary (round 18) ===" +run_case DENY 'gh pr create -t && true -R claridtimo/x' # -t at boundary; later clause has the -R +run_case DENY 'gh pr create --title ; true -R claridtimo/x' +run_case DENY 'gh pr create -R && true' # dangling -R itself is no target + +echo "=== Payload-scoping: claridtimo in cwd/paths must NOT count as a target ===" +CWD_JSON='{"cwd":"/home/dev/claridtimo/bang-game","transcript_path":"/home/dev/claridtimo/t.jsonl","tool_input":{"command":"gh pr create -t x"}}' +run_case_raw DENY "$CWD_JSON" 'bare create + claridtimo-bearing cwd (precise)' +DESC_JSON='{"tool_input":{"command":"gh pr create -t x","description":"Open PR with -R claridtimo/bang-game"},"cwd":"/x"}' +DESC_JSON2='{"tool_input":{"command":"gh pr view 63 -R claridtimo/x","description":"docs mention gh pr create"},"cwd":"/x"}' +run_case_raw DENY "$DESC_JSON" 'bare create + target only in description (precise)' + +echo "=== Degraded path (no python3 on PATH) ===" +FAKEBIN="$(mktemp -d)" +trap 'rm -rf "$FAKEBIN"' EXIT +for t in bash cat grep printf env sh; do ln -sf "$(command -v $t)" "$FAKEBIN/$t"; done +run_case DENY 'gh pr create -t x' "$FAKEBIN" +run_case ALLOW 'gh pr create -R claridtimo/x -t y' "$FAKEBIN" +run_case ALLOW 'gh pr create --repo=claridtimo/x' "$FAKEBIN" +run_case ALLOW 'gh pr create -Rclaridtimo/x' "$FAKEBIN" +run_case ALLOW 'echo done' "$FAKEBIN" +run_case ALLOW 'gh pr view 63 -R claridtimo/x' "$FAKEBIN" +run_case ALLOW 'gh pr create -R Claridtimo/x -t y' "$FAKEBIN" # capitalized owner (degraded) +run_case DENY $'gh pr \\\ncreate -t x' "$FAKEBIN" # continued bare create (round 16) +run_case ALLOW $'gh pr \\\ncreate -R claridtimo/x' "$FAKEBIN" # continued targeted create +run_case ALLOW $'gh pr create -t x -R \\\nclaridtimo/x' "$FAKEBIN" # continuation inside flag adjacency +run_case_raw DENY "$CWD_JSON" 'bare create + claridtimo-bearing cwd (degraded, round 10)' "$FAKEBIN" +run_case_raw DENY "$DESC_JSON" 'bare create + target only in description (degraded, round 18)' "$FAKEBIN" +run_case_raw ALLOW "$DESC_JSON2" 'targeted view + create phrase in description (degraded, round 18)' "$FAKEBIN" + +echo "=== Degraded path: ACCEPTED under-blocks, pinned (round 12) ===" +# The tokenizer-free fallback deliberately allows these two shapes: scoping flags to clauses +# with sed/grep was the v2 approach and false-denied real targeted creates (quoted PR bodies +# containing shell text). The precise path DENIES both — asserted alongside so the asymmetry is +# pinned and a future "fix" that flips either direction fails here. +run_case DENY 'gh pr view -R claridtimo/x && gh pr create -t y' # precise: real deny +run_case ALLOW 'gh pr view -R claridtimo/x && gh pr create -t y' "$FAKEBIN" # degraded: accepted +run_case ALLOW 'gh pr create --title "see -R claridtimo/x docs"' "$FAKEBIN" # degraded: accepted + +echo +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ] diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4a6bb11 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/enforce-pr-target.sh", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/.github/workflows/hook-battery.yml b/.github/workflows/hook-battery.yml new file mode 100644 index 0000000..4b24c4d --- /dev/null +++ b/.github/workflows/hook-battery.yml @@ -0,0 +1,17 @@ +name: Hook battery +on: + push: + branches: [master, main] + pull_request: +jobs: + hook-battery: + name: PR-target hook battery + # The PreToolUse hook (.claude/hooks/enforce-pr-target.sh) is the enforced backstop against + # accidentally opening PRs on the upstream fork parent (fork of greyhavens/*). Verbatim copy + # of the bang-game hook (see claridtimo/bang-game#63 for the 21-round review history); the + # battery pins every bypass / false-deny class found there. Seconds-fast: bash+python3+grep. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run the enforce-pr-target regression battery + run: .claude/hooks/test-enforce-pr-target.sh