forked from bchanot/claude
fix(push-guard): fail closed on token floods, git failures and quoted cd targets
Hardening after the security gate on a2ac018 (3 MEDIUM, all closed and
re-measured):
- dir tokens are deduplicated and capped: more than 20 distinct cd/-C
targets in one command denies before any git fork (20000 tokens: 0.15 s
against the 10 s hook timeout that used to turn a flood into an allow)
- a git or cd failure while reading gitflow.autopush denies instead of
reading as auto (git absent, usage error, unenterable dir); the key
being unset is the only "auto" answer; the decision is recorded only
after one candidate was evaluated cleanly, else the EXIT trap denies
- cd/pushd/-C targets that follow a quote or backtick (bash -c '…') are
extracted; quote characters are excluded from unquoted tokens
User decision (contract, gated): both fail-closed cases also fire in
auto mode on such pathological commands; silence in auto mode holds for
every ordinary push. Header limits list the residual misses (quotes or
backslashes inside a token, cumulative relative cd, unparseable payload)
backed by the soft_deny rule. Tests: 71 checks (T48–T50b added).
This commit is contained in:
@@ -145,6 +145,42 @@ nocwd=$(jq -n '{tool_input:{command:"git push"}}')
|
||||
out=$(cd "$M" && printf '%s' "$nocwd" | bash "$H" 2>/dev/null)
|
||||
check T39-no-cwd "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$out")" deny
|
||||
|
||||
# ── hardening: candidate cap, git failure, quote-prefixed cd ──
|
||||
cmd48=""; for i in $(seq 1 25); do cmd48="${cmd48}cd /x$i;"; done
|
||||
run "$cmd48 git push" "$WORK/auto"
|
||||
check T48-cap-deny "$(verdict)" deny
|
||||
check T48-cap-reason "$(grep -c 'too many directory tokens' <<<"$(reason)")" 1
|
||||
cmd48b=""; for i in 1 2 3 4 5; do cmd48b="${cmd48b}cd \"$M\";"; done
|
||||
run "$cmd48b git push" "$WORK/plain"
|
||||
check T48b-dedup-detect "$(verdict)" deny
|
||||
check T48b-manual-reason "$(grep -c 'manual push mode' <<<"$(reason)")" 1
|
||||
|
||||
# T49: git absent from PATH: the mode cannot be read, so deny (fail closed).
|
||||
mkdir -p "$WORK/nogit"
|
||||
for tool in bash cat grep sed tr jq dirname basename mktemp head sort wc; do
|
||||
real=$(command -v "$tool") || continue
|
||||
case "$real" in /*) ln -sf "$real" "$WORK/nogit/$tool" ;; esac
|
||||
done
|
||||
payload49=$(jq -n --arg c 'git push' --arg d "$M" \
|
||||
'{tool_input:{command:$c},cwd:$d}')
|
||||
out49=$(printf '%s' "$payload49" | PATH="$WORK/nogit" "$(command -v bash)" "$H" 2>/dev/null); rc49=$?
|
||||
check T49-rc "$rc49" 0
|
||||
check T49-deny "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$out49")" deny
|
||||
check T49-reason "$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$out49" | grep -cE 'git|internal error')" 1
|
||||
|
||||
# T49b: an existing but unenterable candidate dir fails closed.
|
||||
mkdir -p "$WORK/locked"; chmod 000 "$WORK/locked"
|
||||
if [ -r "$WORK/locked" ] || (cd "$WORK/locked" 2>/dev/null); then
|
||||
echo "SKIP T49b-unreadable (chmod 000 ineffective for this user)"
|
||||
else
|
||||
check T49b-unreadable "$(fire "cd \"$WORK/locked\" && git push" "$WORK/plain")" deny
|
||||
fi
|
||||
chmod 755 "$WORK/locked"
|
||||
|
||||
# T50: a cd that follows a quote is still extracted.
|
||||
check T50-bash-c-cd "$(fire "bash -c 'cd \"$M\" && git push'" "$WORK/plain")" deny
|
||||
check T50b-unquoted-arg "$(fire "bash -c 'cd $M && git push'" "$WORK/plain")" deny
|
||||
|
||||
# ── settings.json wiring (file content only) ──
|
||||
S="$ROOT/settings.json"
|
||||
check T40-wiring "$(jq -e '.hooks.PreToolUse[]
|
||||
|
||||
Reference in New Issue
Block a user