Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"form": {
"type": "string",
"description": "The irreversible git form when blocked: \"push-force\" | \"reset-hard\" | \"clean-force\" | \"checkout-dot\" | \"restore-dot\" | \"checkout-force\", or \"too-long\" when the command exceeded the parse-length ceiling. Empty string when the command was allowed (status ok)."
"description": "The irreversible git form when blocked: \"push-force\" | \"reset-hard\" | \"clean-force\" | \"checkout-dot\" | \"restore-dot\" | \"checkout-force\", or \"too-long\" when the command exceeded the parse-length ceiling, or \"alias-traversal-cap\" when checking the command's git alias chain exceeded the re-expansion budget. Empty string when the command was allowed (status ok)."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
},
"form": {
"type": "string",
"description": "The non-canonical commit-message form when blocked (e.g. \"message-flag\" for a bare -m/-F not paired with the canonical marker). Empty string when the command was allowed (status ok)."
"description": "The non-canonical commit-message form when blocked (e.g. \"message-flag\" for a bare -m/-F not paired with the canonical marker), or \"alias-traversal-cap\" when checking the command's git alias chain exceeded the re-expansion budget. Empty string when the command was allowed (status ok)."
}
}
}
2 changes: 1 addition & 1 deletion plugins/guardrails/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "guardrails",
"version": "0.17.0",
"version": "0.17.1",
"description": "Twelve safety guards that block secret/credential writes, hardcoded machine-specific paths, git hook-bypass attempts, irreversible git operations (force-push, reset --hard, worktree-wide checkout/restore discards), Bash file-write workarounds that circumvent Write/Edit hooks, commit subjects and gh pr create titles that violate the repo's tracked team convention (when one is declared in .claude/source-control.md), (advisory) hallucinated CLI flags, (advisory) /plugin:skill references that do not resolve, (advisory) markdown citing a repo path the repo's own history shows was removed, (advisory) un-throttled Workflow fan-out that risks burst 529s, and (advisory) direct git commit/gh pr create calls bypassing this marketplace's own commit/pull-request skills — each independently toggleable.",
"author": {
"name": "Melodic Software",
Expand Down
257 changes: 257 additions & 0 deletions plugins/guardrails/CHANGELOG.md

Large diffs are not rendered by default.

133 changes: 120 additions & 13 deletions plugins/guardrails/hooks/block-dangerous-git.sh
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,66 @@ is_exclude_pathspec() {
esac
}

# Alias re-expansion is this guard's only recursive path, and it BRANCHES: every
# hop re-checks both alias spellings (`alias.<sub>` and `alias.<sub>.command`)
# independently, so a chain where each hop defines both walks 2^depth analysis
# paths — a benign 10-hop, 402-character command measured 5.4s, and a guard that
# stalls stops guarding. Every recursion is admitted through this one gate, which
# applies two bounds:
#
# MEMO — a verdict is a pure function of (analysis state, argv); every other input
# is invocation-constant (the payload's command, the repository's config and object
# format). A block is a process-wide `exit 2`, so a state reached a SECOND time
# while this process still runs provably did not block the first time and cannot
# decide differently now. Skipping the repeat is exact rather than a coverage
# trade, and it is what collapses the common blowup — both spellings of a hop
# expanding to the same thing — to one path per hop.
#
# BUDGET — memoization alone cannot bound a chain whose two spellings DIFFER: the
# splice carries each path's own trailing text forward, so every argv is distinct
# and the 2^depth walk survives (10 hops of `-c alias.aN='a(N+1) --xN'
# -c alias.aN.command='a(N+1) --yN'` measured 5.7s). A total re-expansion budget
# for the invocation caps the work, and exhausting it fails CLOSED — the guard
# could not finish deciding, so it must not allow.
#
# The ceiling counts ANALYSES rather than seconds, because a wall clock is host-
# and command-length-dependent. It is calibrated against the linear walk this guard
# already accepts: a memoized traversal spends one analysis per hop, and
# MAX_COMMAND_LEN admits chains of roughly 430 hops (a dual-spelling hop costs ~38
# characters), so this ceiling caps a branching walk at strictly less work than the
# longest non-branching chain the guard must already handle. It sits far above real
# usage — a chain deeper than a couple of hops is already exotic, and every
# legitimate command measured spends single digits.
HOOK_ALIAS_WORK_MAX=128

# Call as: alias_reexpand_admit <kind> <state-word>... — returns 1 when this exact
# state was already analyzed. The kind tag keeps a `!` reparse STRING from ever
# keying the same as a one-word argv, `%q` keeps a word containing a newline from
# merging into its neighbour, and the seen-set's length prefixes its own words so
# the set/argv boundary cannot shift. `printf -v` keeps the whole key build
# fork-free — a `$(printf …)` per word would cost more than the walk it bounds.
# shellcheck disable=SC2329 # reached via the hook::bash_parse_segments callback chain
alias_reexpand_admit() {
local kind="$1" key q w
shift
key="$kind"$'\n'"${#HOOK_ALIAS_SEEN[@]}"$'\n'
for w in ${HOOK_ALIAS_SEEN[@]+"${HOOK_ALIAS_SEEN[@]}"}; do
printf -v q '%q' "$w"
key+="$q"$'\n'
done
for w in "$@"; do
printf -v q '%q' "$w"
key+="$q"$'\n'
done
[[ -n "${HOOK_ALIAS_MEMO[$key]+x}" ]] && return 1
HOOK_ALIAS_MEMO["$key"]=1
((++HOOK_ALIAS_WORK <= HOOK_ALIAS_WORK_MAX)) && return 0
echo "BLOCKED: checking this command's git alias chain needs more than $HOOK_ALIAS_WORK_MAX re-expansions — failing closed rather than stalling the guard." >&2
echo "Run the subcommand directly, shorten the alias chain, or set the guardrails block_dangerous_git_enabled option to false to bypass." >&2
emit_tel "blocked" "alias-traversal-cap"
exit 2
}

# Inspect one already-tokenized segment (its argv words passed as "$@"). Blocks
# when the segment is a real git invocation carrying a default-blocked
# irreversible form. Parsing spine lives in hook-utils.sh; only the form
Expand Down Expand Up @@ -395,11 +455,21 @@ check_segment() {
# The SHAPE refusal must fire at EVERY recursion depth: a wrapping inline alias
# can expand to `--config-env=alias.<sub>=<envvar>` defining the invoked sub
# (`git -c alias.rh='--config-env=alias.foo=AV foo' rh`), which git runs. It is
# value-blind, cheap, and terminal, so it is NOT gated by HOOK_NO_ALIAS. Only the
# INLINE-alias re-expansion is one-level (HOOK_NO_ALIAS bounds the recursion —
# git does not re-expand the first word of an expansion as another alias).
local exp reparse a alias_rc
local -a expw=()
# value-blind, cheap, and terminal.
#
# git DOES chain aliases: when an expansion's first word is itself an alias git
# expands it again, until a non-alias subcommand is reached OR git detects a
# loop — a subcommand name it already expanded in this chain — and runs nothing.
# So the inline re-expansion recurses at EVERY hop, carrying the command-line
# -c/--config/--config-env globals into each hop (the splice starts at index 0
# through sub_idx, not gi+1, so no global between git and the subcommand is
# dropped). Recursion TERMINATES on HOOK_ALIAS_SEEN, a save/restore seen-set of
# resolved subcommand names: a repeat is git's own alias-loop stop (allow-safe),
# and finite distinct alias keys guarantee termination. Terminating is not the
# same as tractable — the walk branches per hop, and alias_reexpand_admit is what
# keeps its cost proportional to the chain's length.
local exp reparse a alias_rc s seen_hit=0
local -a expw=() saved_seen=() nextw=()
hook::git_alias_expansion "$sub"
alias_rc=$?
if ((alias_rc == 2)); then
Expand All @@ -414,32 +484,57 @@ check_segment() {
emit_tel "blocked" "config-env-alias"
exit 2
fi
if ((alias_rc == 0)) && ((${HOOK_NO_ALIAS:-0} == 0)); then
# git stops (runs nothing) if the resolved subcommand is one it already expanded
# in this chain, so skip the re-expansion on a repeat and let the plain scan
# decide. The set models git's IN-PROCESS alias-loop guard only: a `!` shell
# alias spawns a fresh git process whose loop guard starts empty, so its
# reparse below runs under an emptied set.
for s in ${HOOK_ALIAS_SEEN[@]+"${HOOK_ALIAS_SEEN[@]}"}; do
[[ "$s" == "$sub" ]] && {
seen_hit=1
break
}
done
if ((alias_rc == 0)) && ((seen_hit == 0)); then
# Inline alias (-c/--config): each spelling's expansion is literally present. Re-check
# EVERY spelling (plain and `.command`) independently so a benign expansion in one
# never suppresses a dangerous sibling in the other.
# never suppresses a dangerous sibling in the other. Save/restore the seen-set around
# the recursion so sibling segments and unwound hops start clean.
# shellcheck disable=SC2154 # HOOK_GIT_ALIAS_EXPS is set by hook::git_alias_expansion
saved_seen=(${HOOK_ALIAS_SEEN[@]+"${HOOK_ALIAS_SEEN[@]}"})
HOOK_ALIAS_SEEN+=("$sub")
for exp in ${HOOK_GIT_ALIAS_EXPS[@]+"${HOOK_GIT_ALIAS_EXPS[@]}"}; do
[[ -n "$exp" ]] || continue
if [[ "$exp" == '!'* ]]; then
# Shell alias: git runs the expansion as a shell command with the
# invocation's trailing args appended (positional), so append them
# (shell-quoted) before re-parsing the whole string as a command.
# That command runs in a NEW git process whose alias-loop guard starts
# empty, so the reparse must not inherit this chain's seen-set: a body
# that re-invokes a name from the outer chain (`git -c
# alias.a='!git -c alias.a="reset --hard" a' a`) is re-expanded there,
# not stopped. Termination stays text-bounded — this guard resolves
# only inline aliases, and every definition reachable from the reparse
# is a strict substring of the parent segment's text.
reparse="${exp#!}"
for a in "${w[@]:sub_idx+1}"; do reparse+=" $(printf '%q' "$a")"; done
hook::bash_parse_segments "$reparse" check_segment
HOOK_ALIAS_SEEN=()
alias_reexpand_admit shell "$reparse" &&
hook::bash_parse_segments "$reparse" check_segment
HOOK_ALIAS_SEEN=(${saved_seen[@]+"${saved_seen[@]}"} "$sub")
else
# Git alias: its expansion is dequoted with shell quoting rules
# (so `push "--force"` yields --force, not "--force"). Splice the
# dequoted words in place of the alias name and keep the trailing
# invocation args, which git appends to the expanded argv.
# dequoted words in place of the alias name, keeping every command-line
# global (indices 0..sub_idx) and the trailing invocation args, which
# git appends to the expanded argv.
hook::env_s_split "$exp"
expw=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"})
HOOK_NO_ALIAS=1
check_segment "${w[@]:0:gi+1}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}"
HOOK_NO_ALIAS=0
nextw=("${w[@]:0:sub_idx}" ${expw[@]+"${expw[@]}"} "${w[@]:sub_idx+1}")
alias_reexpand_admit git "${nextw[@]}" && check_segment "${nextw[@]}"
fi
done
HOOK_ALIAS_SEEN=(${saved_seen[@]+"${saved_seen[@]}"})
fi

case "$sub" in
Expand Down Expand Up @@ -985,6 +1080,18 @@ case $? in
*) COMMAND="$PS_SAFE_COMMAND" ;;
esac

# Resolved-subcommand names already expanded in the CURRENT alias chain — git's
# own alias-loop guard. Initialized here (not in check_segment, which recurses
# and would reset it) and save/restored around each recursion; a multi-command
# line runs check_segment once per top-level segment, each starting from empty.
HOOK_ALIAS_SEEN=()

# The alias-traversal bounds (alias_reexpand_admit). Both are invocation-wide and
# deliberately NOT save/restored: a state analyzed anywhere is analyzed, and the
# budget bounds the whole command's work rather than one path's.
declare -A HOOK_ALIAS_MEMO=()
HOOK_ALIAS_WORK=0

hook::bash_parse_segments "$COMMAND" check_segment

emit_tel "ok" ""
Expand Down
99 changes: 99 additions & 0 deletions plugins/guardrails/hooks/block-dangerous-git.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,105 @@ run "git restore --staged --no-w . (abbrev no-worktree, allowed)" "git restore -
run "git restore --no-worktree --worktree . (worktree re-armed, blocked)" "git restore --no-worktree --worktree ." 2
run "git restore --staged --no-staged . (staged cleared, worktree discard, blocked)" "git restore --staged --no-staged ." 2

# --- #964: git chains aliases — re-expansion recurses to the invoked op --------
# git expands an alias whose first word is itself an alias, so a dangerous op
# reached through a SECOND (or later) hop must still block. Every command-line
# -c/--config-env global rides into each hop (so a second-hop --config-env alias
# is refused by shape), and the recursion stops on git's own alias-loop.
# Case C — plain two-hop inline chain (rh -> foo -> reset --hard).
run "#964 case C: two-hop inline alias chain to reset --hard (blocked)" \
"git -c alias.rh=foo -c alias.foo='reset --hard' rh" 2
# H1 — second hop defined via --config-env (env-shaped, refused by shape); the
# global is carried into the nested hop by the widened splice.
run "#964 H1: inline first hop, --config-env second hop (blocked by shape)" \
"git -c alias.rh=foo --config-env=alias.foo=AV rh" 2 "AV=reset --hard"
# H2 — same defect with the --config-env global placed BEFORE the -c global.
run "#964 H2: --config-env before -c, chained to the invoked sub (blocked)" \
"git --config-env=alias.foo=AV -c alias.rh=foo rh" 2 "AV=reset --hard"
# Three inline hops.
run "#964 three-hop inline chain to reset --hard (blocked)" \
"git -c alias.a=b -c alias.b=c -c alias.c='reset --hard' a" 2
# Second hop spelled via the alias.<sub>.command subkey.
run "#964 .command-spelled second hop to reset --hard (blocked)" \
"git -c alias.rh=foo -c alias.foo.command='reset --hard' rh" 2
# Benign controls — a chain to a safe terminal op still ALLOWS, and an alias
# cycle terminates (git's alias-loop stop) and allows without hanging.
run "#964 benign two-hop chain to a safe subcommand (allowed)" \
"git -c alias.a=b -c alias.b=status a" 0
run "#964 alias cycle terminates and allows (no hang)" \
"git -c alias.a=b -c alias.b=a a" 0
# A `!` shell alias runs in a NEW git process whose alias-loop guard starts
# empty, so a body that re-invokes a name from the outer chain is re-expanded
# there — the reparse must not inherit the outer chain's seen-set.
run "#964 shell-alias body re-invoking the outer chain name (blocked)" \
"git -c alias.a='!git -c alias.a=\"reset --hard\" a' a" 2
run "#964 shell-alias body re-invoking an undefined inner name (allowed, no hang)" \
"git -c alias.a='!git a' a" 0

# --- alias-chain traversal stays proportional to the chain's LENGTH ------------
# Each hop re-checks BOTH alias spellings, so re-expansion branches 2x per hop
# unless equivalent states collapse: before the traversal bounds a 10-hop chain
# defining both spellings cost 5.4s, and each further hop doubled it. These cases
# therefore assert a hard wall-clock CEILING as well as the exit code — an
# exit-code-only assertion passes at any runtime and would not see the regression.
#
# run_bounded <label> <command> <expected-exit> <seconds>: `timeout` reports 124
# when the ceiling elapses, which must read as the failure it is rather than as
# an unexpected exit code.
run_bounded() {
local label="$1" command="$2" expected="$3" secs="$4" rc
(cd "$REPO_SHA1" && timeout "$secs" bash "$HOOK" <<<"$(command_json "$command")" >/dev/null 2>&1)
rc=$?
if ((rc == 124)); then
bad "$label: exceeded the ${secs}s ceiling — alias traversal is not bounded"
else
assert_exit "$label" "$expected" "$rc"
fi
}

# alias_chain <hops> <terminal> [diverge] — an N-hop chain where every hop
# defines both `alias.aN` and `alias.aN.command`. They expand identically by
# default (equivalent states, collapsed by memoization); `diverge` gives each
# spelling its own trailing word so no two analysis paths share a state and only
# the traversal budget can stop the walk.
alias_chain() {
local n="$1" terminal="$2" mode="${3:-same}" cmd="git" i
for ((i = 1; i < n; i++)); do
if [[ "$mode" == diverge ]]; then
cmd+=" -c alias.a$i='a$((i + 1)) --x$i' -c alias.a$i.command='a$((i + 1)) --y$i'"
else
cmd+=" -c alias.a$i=a$((i + 1)) -c alias.a$i.command=a$((i + 1))"
fi
done
printf '%s' "$cmd -c alias.a$n='$terminal' -c alias.a$n.command='$terminal' a1"
}

# The SAFE terminal is the timing case: it exhausts the whole tree, so before the
# bounds it ran past this ceiling (verified — 20 hops did not finish in 30s).
run_bounded "traversal: 20-hop dual-spelling chain to a safe op (allowed, bounded)" \
"$(alias_chain 20 status)" 0 30
# Coverage is not what the collapse trades away: the same depth still reaches a
# dangerous terminal op and blocks. (This one always returned fast — the walk exits
# on the first path that finds the op — so it asserts reach, not runtime.)
run_bounded "traversal: 20-hop dual-spelling chain to reset --hard (blocked, bounded)" \
"$(alias_chain 20 'reset --hard')" 2 30
# A long chain that does NOT branch (one spelling per hop) must stay allowed —
# the budget bounds branching, not depth. Ceiling-guarded too: a regression that
# made this one branch would otherwise hang the suite rather than fail it.
run_bounded "traversal: 60-hop single-spelling chain to a safe op (allowed)" \
"$(
cmd="git"
for ((i = 1; i < 60; i++)); do cmd+=" -c alias.a$i=a$((i + 1))"; done
printf '%s' "$cmd -c alias.a60=status a1"
)" 0 30
# Divergent spellings defeat state collapse, so the budget is what stops the walk:
# fail CLOSED rather than stall the hook.
run_bounded "traversal: divergent-spelling chain exhausts the budget (blocked, bounded)" \
"$(alias_chain 12 status diverge)" 2 30
budgetout=$(cd "$REPO_SHA1" && timeout 30 bash "$HOOK" <<<"$(command_json "$(alias_chain 12 status diverge)")" 2>&1)
assert_contains "traversal: budget block names the re-expansion ceiling" \
"$budgetout" "re-expansions"

# --- allow-list ---------------------------------------------------------------
run "allow-list push-force → allowed" "git push --force" 0 \
CLAUDE_PLUGIN_OPTION_BLOCK_DANGEROUS_GIT_ALLOW=push-force
Expand Down
Loading
Loading