mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 17:35:29 +02:00
fix(hooks): fail-closed freeze + shared extractor + careful HIGH tier
Freeze boundary hook had four verified bugs: the grep-first JSON extractor truncated at escaped quotes and failed OPEN on unparseable payloads; the deny JSON was printf-interpolated so a quote- or newline-bearing path silently no-oped the block; the freeze path read stripped INTERNAL spaces (a boundary like ~/My Project could never match); and the path resolver skipped the final component, letting an in-boundary symlink write through to an out-of-boundary target. Fixes, structurally: one shared sourced helper (careful/bin/hook-extract.sh) now owns JSON extraction and JSON-encoded decision envelopes for BOTH hooks -- the two-copy drift is how freeze kept a broken extractor after careful's was fixed. Freeze is now deny-tier fail-closed (unparseable payload denies, parsed-but-no-file_path still allows), trims only leading/trailing whitespace, and resolves symlinks through the final path component. Careful gains a HIGH tier (hard deny, simple commands only): recursive delete of /, ~, or $HOME, and force-push to the repo's default branch. Compound commands always fall through to the MEDIUM ask; --force-with-lease is never HIGH. Documented as a best-effort advisory hard-stop, not a policy boundary. Plus additive-only project patterns (~/.gstack/careful-patterns.txt + per-project file): config can only ADD warn rules, never suppress a baseline family. test/hook-scripts.test.ts: 89 tests incl. malformed-payload deny, parseable deny JSON for hostile paths, space-bearing boundaries, symlink escape, HIGH tier splits, additive invariant, invalid-regex resilience. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2be6c06ba8
commit
455c805125
+29
-1
@@ -64,6 +64,34 @@ The hook reads the command from the tool input JSON, checks it against the
|
||||
patterns above, and returns a `hookSpecificOutput` payload with
|
||||
`permissionDecision: "ask"` and a warning reason if a match is found (the
|
||||
decision must be nested under `hookSpecificOutput` — Claude Code ignores a
|
||||
top-level `permissionDecision`). You can always override the warning and proceed.
|
||||
top-level `permissionDecision`). You can always override a MEDIUM warning and
|
||||
proceed.
|
||||
|
||||
## HIGH tier (hard deny)
|
||||
|
||||
A tiny set of catastrophic commands is **denied outright** while /careful is
|
||||
active, not just warned:
|
||||
|
||||
- `rm -r`/`-R` targeting exactly `/`, `~`, or `$HOME`
|
||||
- `git push --force` / `-f` to the repo's **default branch**
|
||||
|
||||
HIGH only fires on SIMPLE commands (no `;`, `&&`, `||`, `|`, newline) — string
|
||||
matching cannot resolve what a compound command does, so compound shapes fall
|
||||
through to the ordinary MEDIUM ask. `--force-with-lease` is deliberately not
|
||||
matched (it's the safe force variant). This is a best-effort advisory
|
||||
hard-stop, not a policy boundary: /careful is opt-in and session-scoped, so
|
||||
the escape hatch is ending the /careful session.
|
||||
|
||||
## Project patterns (additive only)
|
||||
|
||||
Add your own warn rules — one POSIX ERE per line, `#` comments allowed — in:
|
||||
|
||||
- `~/.gstack/careful-patterns.txt` (all projects)
|
||||
- `~/.gstack/projects/<slug>/careful-patterns.txt` (this project)
|
||||
|
||||
Matching lines warn with `[careful] Project rule matched: <pattern>`. Config
|
||||
can only ADD rules: the files are consulted after the built-in families, so no
|
||||
file content can suppress or weaken a baseline warning. Invalid regex lines
|
||||
are skipped.
|
||||
|
||||
To deactivate, end the conversation or start a new one. Hooks are session-scoped.
|
||||
|
||||
+29
-1
@@ -59,6 +59,34 @@ The hook reads the command from the tool input JSON, checks it against the
|
||||
patterns above, and returns a `hookSpecificOutput` payload with
|
||||
`permissionDecision: "ask"` and a warning reason if a match is found (the
|
||||
decision must be nested under `hookSpecificOutput` — Claude Code ignores a
|
||||
top-level `permissionDecision`). You can always override the warning and proceed.
|
||||
top-level `permissionDecision`). You can always override a MEDIUM warning and
|
||||
proceed.
|
||||
|
||||
## HIGH tier (hard deny)
|
||||
|
||||
A tiny set of catastrophic commands is **denied outright** while /careful is
|
||||
active, not just warned:
|
||||
|
||||
- `rm -r`/`-R` targeting exactly `/`, `~`, or `$HOME`
|
||||
- `git push --force` / `-f` to the repo's **default branch**
|
||||
|
||||
HIGH only fires on SIMPLE commands (no `;`, `&&`, `||`, `|`, newline) — string
|
||||
matching cannot resolve what a compound command does, so compound shapes fall
|
||||
through to the ordinary MEDIUM ask. `--force-with-lease` is deliberately not
|
||||
matched (it's the safe force variant). This is a best-effort advisory
|
||||
hard-stop, not a policy boundary: /careful is opt-in and session-scoped, so
|
||||
the escape hatch is ending the /careful session.
|
||||
|
||||
## Project patterns (additive only)
|
||||
|
||||
Add your own warn rules — one POSIX ERE per line, `#` comments allowed — in:
|
||||
|
||||
- `~/.gstack/careful-patterns.txt` (all projects)
|
||||
- `~/.gstack/projects/<slug>/careful-patterns.txt` (this project)
|
||||
|
||||
Matching lines warn with `[careful] Project rule matched: <pattern>`. Config
|
||||
can only ADD rules: the files are consulted after the built-in families, so no
|
||||
file content can suppress or weaken a baseline warning. Invalid regex lines
|
||||
are skipped.
|
||||
|
||||
To deactivate, end the conversation or start a new one. Hooks are session-scoped.
|
||||
|
||||
+104
-30
@@ -1,14 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-careful.sh — PreToolUse hook for /careful skill
|
||||
# Reads JSON from stdin, checks Bash command for destructive patterns.
|
||||
# Returns a PreToolUse hookSpecificOutput with permissionDecision "ask" to warn,
|
||||
# or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude
|
||||
# Code ignores a top-level permissionDecision, which silently no-ops the warning.
|
||||
# Two tiers:
|
||||
# HIGH — a tiny set of catastrophic SIMPLE commands returns "deny"
|
||||
# (best-effort advisory hard-stop, not a policy boundary).
|
||||
# MEDIUM — the destructive families below return "ask" (always overridable).
|
||||
# The decision MUST be nested under hookSpecificOutput — Claude Code ignores a
|
||||
# top-level permissionDecision, which silently no-ops the warning.
|
||||
set -euo pipefail
|
||||
|
||||
# Read stdin (JSON with tool_input)
|
||||
INPUT=$(cat)
|
||||
|
||||
# Shared JSON helpers (extractor + encoder) — one copy for careful AND freeze.
|
||||
# See hook-extract.sh for the drift history that motivated the shared file.
|
||||
_HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=careful/bin/hook-extract.sh
|
||||
. "$_HOOK_DIR/hook-extract.sh"
|
||||
|
||||
# Extract the "command" field value from tool_input with a real JSON parser.
|
||||
#
|
||||
# The previous extractor was
|
||||
@@ -21,31 +30,17 @@ INPUT=$(cat)
|
||||
# bash -c "rm -rf /" -> CMD='bash -c \' -> allowed
|
||||
# echo "x"; rm -rf ~ -> CMD='echo \' -> allowed
|
||||
#
|
||||
# The python3 fallback never rescued these because CMD was non-empty, so the
|
||||
# `[ -z "$CMD" ]` guard did not fire. Parse the payload properly instead, and
|
||||
# fail CLOSED when it cannot be parsed at all — a hook that gates destructive
|
||||
# commands must not allow-by-default on unreadable input.
|
||||
#
|
||||
# python3 is tried first because it ships with macOS and most Linux distros and
|
||||
# is reliably on PATH in a hook environment; node is the fallback.
|
||||
extract_cmd() {
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
printf '%s' "$INPUT" | python3 -c 'import sys,json; d=json.loads(sys.stdin.read()); c=d.get("tool_input",{}).get("command",""); sys.stdout.write(c if isinstance(c,str) else "")' 2>/dev/null && return 0
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const c=(j&&j.tool_input&&j.tool_input.command)||"";process.stdout.write(typeof c==="string"?c:"")}catch(e){process.exit(3)}})' 2>/dev/null && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Parse the payload properly instead, and fail CLOSED when it cannot be parsed
|
||||
# at all — a hook that gates destructive commands must not allow-by-default on
|
||||
# unreadable input.
|
||||
set +e
|
||||
CMD=$(extract_cmd)
|
||||
CMD=$(gstack_hook_extract_field "$INPUT" command)
|
||||
EXTRACT_RC=$?
|
||||
set -e
|
||||
|
||||
# No parser available, or the payload is not parseable JSON. Fail closed.
|
||||
if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] Could not parse the tool payload to safety-check this command. Approve only if you know what it does."}}\n'
|
||||
gstack_hook_decision ask "[careful] Could not parse the tool payload to safety-check this command. Approve only if you know what it does."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -55,6 +50,12 @@ if [ -z "$CMD" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Log a hook fire event (pattern name only, never command content).
|
||||
_careful_log_fire() {
|
||||
mkdir -p ~/.gstack/analytics 2>/dev/null || true
|
||||
echo '{"event":"hook_fire","skill":"careful","pattern":"'"$1"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Normalize: lowercase for case-insensitive SQL matching
|
||||
CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
@@ -71,10 +72,54 @@ CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
|
||||
# primitives as a reason to ask: they are vanishingly rare in commands a human
|
||||
# actually means to run unattended.
|
||||
if printf '%s' "$CMD" | grep -qE '\$\{IFS\}|\$IFS|\$\(echo[^)]*base64[^)]*\)|base64[[:space:]]+(-d|--decode)[^|]*\|[[:space:]]*(sh|bash)' 2>/dev/null; then
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] Shell obfuscation detected (IFS word-splitting or base64-to-shell). Read the command carefully before approving."}}\n'
|
||||
gstack_hook_decision ask "[careful] Shell obfuscation detected (IFS word-splitting or base64-to-shell). Read the command carefully before approving."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- HIGH tier: hard deny (best-effort advisory hard-stop, NOT a policy boundary) ---
|
||||
# Only SIMPLE commands are eligible: string matching cannot resolve what a
|
||||
# compound command does (`cd X && git push --force` — whose cwd? which repo?),
|
||||
# so anything containing ; && || | or a newline falls through to the MEDIUM ask
|
||||
# families below — conservative failure = ask, never guess.
|
||||
# --force-with-lease is deliberately NOT matched here (it is the safe variant).
|
||||
# curl|sh stays MEDIUM/allow territory: hard-denying it would block legitimate
|
||||
# installer flows, including gstack's own setup pattern.
|
||||
_IS_SIMPLE=1
|
||||
case "$CMD" in
|
||||
*';'*|*'&&'*|*'||'*|*'|'*|*$'\n'*) _IS_SIMPLE=0 ;;
|
||||
esac
|
||||
if [ "$_IS_SIMPLE" -eq 1 ]; then
|
||||
# Recursive delete aimed at the filesystem root or the whole home directory.
|
||||
if printf '%s' "$CMD" | grep -qE '^[[:space:]]*(sudo[[:space:]]+)?rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*[[:space:]]+)+(/|~|\$HOME)/?[[:space:]]*$' 2>/dev/null; then
|
||||
_careful_log_fire "high_rm_root"
|
||||
gstack_hook_decision deny "[careful][HIGH] Recursive delete of / or the home directory is blocked while /careful is active. If you truly mean it, end the /careful session first."
|
||||
exit 0
|
||||
fi
|
||||
# Force-push to the repo's default branch (the shared history everyone pulls).
|
||||
if printf '%s' "$CMD" | grep -qE '^[[:space:]]*git[[:space:]]+push([[:space:]]|$)' 2>/dev/null \
|
||||
&& printf '%s' "$CMD" | grep -qE '(^|[[:space:]])(-f|--force)($|[[:space:]])' 2>/dev/null; then
|
||||
_DEFAULT_BRANCH=$(git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null | sed 's|.*/||' || true)
|
||||
if [ -n "$_DEFAULT_BRANCH" ]; then
|
||||
_TARGETS_DEFAULT=0
|
||||
if printf '%s' "$CMD" | grep -qE "(^|[[:space:]])${_DEFAULT_BRANCH}([[:space:]]|$)" 2>/dev/null; then
|
||||
_TARGETS_DEFAULT=1
|
||||
elif printf '%s' "$CMD" | grep -qE '^[[:space:]]*git[[:space:]]+push([[:space:]]+(-f|--force))*[[:space:]]*$' 2>/dev/null; then
|
||||
# Bare `git push --force` (force flags only, no remote/ref): it targets
|
||||
# the current branch's upstream, which is the default branch only when
|
||||
# we are ON it. Any other arg shape (explicit remote + feature ref,
|
||||
# exotic refspecs) falls through to the MEDIUM ask below.
|
||||
_CURRENT_BRANCH=$(git branch --show-current 2>/dev/null || true)
|
||||
[ -n "$_CURRENT_BRANCH" ] && [ "$_CURRENT_BRANCH" = "$_DEFAULT_BRANCH" ] && _TARGETS_DEFAULT=1
|
||||
fi
|
||||
if [ "$_TARGETS_DEFAULT" -eq 1 ]; then
|
||||
_careful_log_fire "high_force_push_default"
|
||||
gstack_hook_decision deny "[careful][HIGH] Force-push to the default branch ($_DEFAULT_BRANCH) is blocked while /careful is active. Use --force-with-lease on a feature branch, or end the /careful session if you truly mean it."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Check for safe exceptions (one standalone rm of build artifacts) ---
|
||||
# Match the complete command. Parsing only the last rm is unsafe because shell
|
||||
# syntax or comments can hide an earlier destructive command, for example:
|
||||
@@ -102,7 +147,7 @@ case "$CMD" in
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Destructive pattern checks ---
|
||||
# --- Destructive pattern checks (MEDIUM tier — always overridable) ---
|
||||
WARN=""
|
||||
PATTERN=""
|
||||
|
||||
@@ -154,14 +199,43 @@ if [ -z "$WARN" ] && printf '%s' "$CMD" | grep -qE 'docker\s+(rm\s+-f|system\s+p
|
||||
PATTERN="docker_destructive"
|
||||
fi
|
||||
|
||||
# --- Additive project patterns ---
|
||||
# Config can only ADD warn rules, never remove or weaken a baseline family:
|
||||
# these files are consulted AFTER the hardcoded checks and only when none of
|
||||
# them matched, so no file content can suppress a baseline warning. One POSIX
|
||||
# ERE per line; blank lines and #-comments skipped; an invalid regex is
|
||||
# skipped (never fatal — the hook must not break on a typo in config).
|
||||
if [ -z "$WARN" ]; then
|
||||
_GSTACK_HOME_DIR="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
_PATTERN_FILES="$_GSTACK_HOME_DIR/careful-patterns.txt"
|
||||
eval "$("$_HOOK_DIR/../../bin/gstack-slug" 2>/dev/null)" 2>/dev/null || true
|
||||
if [ -n "${SLUG:-}" ]; then
|
||||
_PATTERN_FILES="$_PATTERN_FILES
|
||||
$_GSTACK_HOME_DIR/projects/$SLUG/careful-patterns.txt"
|
||||
fi
|
||||
while IFS= read -r _PF; do
|
||||
[ -f "$_PF" ] || continue
|
||||
while IFS= read -r _PAT || [ -n "$_PAT" ]; do
|
||||
case "$_PAT" in ''|'#'*) continue ;; esac
|
||||
_PAT_RC=0
|
||||
printf '' | grep -qE "$_PAT" 2>/dev/null || _PAT_RC=$?
|
||||
[ "$_PAT_RC" -eq 2 ] && continue # invalid ERE — skip the line
|
||||
if printf '%s' "$CMD" | grep -qE "$_PAT" 2>/dev/null; then
|
||||
WARN="Project rule matched: $_PAT"
|
||||
PATTERN="project_rule"
|
||||
break
|
||||
fi
|
||||
done < "$_PF"
|
||||
[ -n "$WARN" ] && break
|
||||
done <<EOF_PATTERN_FILES
|
||||
$_PATTERN_FILES
|
||||
EOF_PATTERN_FILES
|
||||
fi
|
||||
|
||||
# --- Output ---
|
||||
if [ -n "$WARN" ]; then
|
||||
# Log hook fire event (pattern name only, never command content)
|
||||
mkdir -p ~/.gstack/analytics 2>/dev/null || true
|
||||
echo '{"event":"hook_fire","skill":"careful","pattern":"'"$PATTERN"'","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}' >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true
|
||||
|
||||
WARN_ESCAPED=$(printf '%s' "$WARN" | sed 's/"/\\"/g')
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] %s"}}\n' "$WARN_ESCAPED"
|
||||
_careful_log_fire "$PATTERN"
|
||||
gstack_hook_decision ask "[careful] $WARN"
|
||||
else
|
||||
echo '{}'
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# hook-extract.sh — SHARED JSON helpers for gstack PreToolUse hooks.
|
||||
# Sourced (never executed) by careful/bin/check-careful.sh and
|
||||
# freeze/bin/check-freeze.sh via a path relative to each hook script.
|
||||
#
|
||||
# ONE copy on purpose. These two hooks previously carried separate extractor
|
||||
# copies; the escaped-quote truncation bug got fixed in careful's copy while
|
||||
# freeze silently kept the broken one. Any future parsing fix lands here and
|
||||
# reaches both hooks by construction.
|
||||
|
||||
# gstack_hook_extract_field PAYLOAD FIELD
|
||||
# Prints tool_input.FIELD when PAYLOAD is valid JSON and the field is a
|
||||
# string ("" when absent or non-string). Returns 1 when no parser is
|
||||
# available or the payload is not parseable JSON — the CALLER decides the
|
||||
# polarity for that case (careful asks, freeze denies).
|
||||
#
|
||||
# python3 is tried first because it ships with macOS and most Linux distros
|
||||
# and is reliably on PATH in a hook environment; node is the fallback.
|
||||
gstack_hook_extract_field() {
|
||||
_ghef_payload="$1"
|
||||
_ghef_field="$2"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
printf '%s' "$_ghef_payload" | python3 -c 'import sys,json
|
||||
field = sys.argv[1]
|
||||
d = json.loads(sys.stdin.read())
|
||||
c = d.get("tool_input", {}).get(field, "")
|
||||
sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null && return 0
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
printf '%s' "$_ghef_payload" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);const c=(j&&j.tool_input&&j.tool_input[process.argv[1]])||"";process.stdout.write(typeof c==="string"?c:"")}catch(e){process.exit(3)}})' "$_ghef_field" 2>/dev/null && return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# gstack_hook_json_string TEXT
|
||||
# Prints TEXT as a JSON string literal (surrounding quotes included),
|
||||
# encoding quotes, backslashes, control characters and newlines. Never build
|
||||
# hook JSON with printf/sed interpolation: a path containing a quote or a
|
||||
# newline produces malformed JSON, and Claude Code silently ignores the
|
||||
# whole decision — a deny that no-ops exactly when it matters.
|
||||
gstack_hook_json_string() {
|
||||
_ghjs_text="$1"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
printf '%s' "$_ghjs_text" | python3 -c 'import sys,json; sys.stdout.write(json.dumps(sys.stdin.read()))' 2>/dev/null && return 0
|
||||
fi
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
printf '%s' "$_ghjs_text" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.stringify(s)))' 2>/dev/null && return 0
|
||||
fi
|
||||
# Last-resort fallback (no parser on PATH): strip to a safe charset so the
|
||||
# envelope stays valid JSON even if the message loses characters.
|
||||
printf '"%s"' "$(printf '%s' "$_ghjs_text" | tr -cd 'a-zA-Z0-9 ._/:@=+-' )"
|
||||
}
|
||||
|
||||
# gstack_hook_decision DECISION REASON
|
||||
# Emits the full PreToolUse hookSpecificOutput envelope with REASON safely
|
||||
# JSON-encoded. DECISION is "ask" or "deny". The decision MUST be nested
|
||||
# under hookSpecificOutput — Claude Code ignores a top-level
|
||||
# permissionDecision, which silently no-ops the block.
|
||||
gstack_hook_decision() {
|
||||
_ghd_decision="$1"
|
||||
_ghd_reason="$2"
|
||||
_ghd_encoded=$(gstack_hook_json_string "$_ghd_reason")
|
||||
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":%s}}\n' "$_ghd_decision" "$_ghd_encoded"
|
||||
}
|
||||
Reference in New Issue
Block a user