mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-04 07:47:27 +01:00
Bins, careful/freeze hooks, setup, upgrade migrations, browse/src, design, ios-qa daemon, lib and scripts resolve the state root through bin/gstack-state-root.sh (bash) or lib/state-root.ts (TS). Bins source the twin and stop with a reinstall message when it is missing; hooks source it and never spawn gstack-paths. browse/src/config.ts and lib/cso/state.ts delegate to resolveStateRoot. Analytics writers and readers move together so the usage log stays one file. gstack-uninstall deletes state only at ~/.gstack, refuses (exit 2) when it resolves to /, $HOME or an ancestor, the checkout or the git root, and leaves any other resolved root in place with the removal command. Fixtures that copy single bins now copy the twin.
223 lines
7.5 KiB
Bash
Executable File
223 lines
7.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# gstack-verify-gate — Stop hook. Blocks the turn from ending until the
|
|
# project's declared verification command passes.
|
|
#
|
|
# Declare the command on one line in the project's CLAUDE.md:
|
|
# <!-- gstack:verify: bun test -->
|
|
#
|
|
# Read-or-ask: gstack never invents this command. No declaration, no gate.
|
|
# Fails open on every absence (no CLAUDE.md, no declaration, empty value).
|
|
#
|
|
# Trust boundary: hooks bypass the permission system, so a declared command
|
|
# NEVER runs until the user records it in the per-repo trust store:
|
|
# gstack-verify-gate --trust (run from inside the repo)
|
|
# The store maps realpath(repo root) -> sha256(command) at
|
|
# $GSTACK_STATE_ROOT/verify-gate-trust (flat "path<TAB>hash",
|
|
# 0600, atomic rewrite). Any edit to the declared command invalidates trust
|
|
# until --trust is run again. Untrusted commands never block the turn.
|
|
#
|
|
# Exit 0 = allow the turn to end, one-line reason on stdout.
|
|
# Exit 2 = block, Claude Code feeds stderr back to the agent.
|
|
#
|
|
# Remove with: gstack-settings-hook remove-source --source verify-gate
|
|
set -uo pipefail
|
|
|
|
TAB="$(printf '\t')"
|
|
# Hook: source the state-root twin (never spawn gstack-paths). A broken
|
|
# install fails open, like every other absence.
|
|
_VG_TWIN="$(cd "$(dirname "$0")" && pwd)/gstack-state-root.sh"
|
|
if [ ! -f "$_VG_TWIN" ] || ! . "$_VG_TWIN" 2>/dev/null; then
|
|
echo "verify-gate: $_VG_TWIN missing; gate skipped (reinstall with ./setup or /gstack-upgrade)"
|
|
exit 0
|
|
fi
|
|
GSTACK_STATE_ROOT="$(gstack_state_root; printf x)"; GSTACK_STATE_ROOT="${GSTACK_STATE_ROOT%x}"
|
|
STORE="$GSTACK_STATE_ROOT/verify-gate-trust"
|
|
|
|
_sha256() {
|
|
if command -v shasum >/dev/null 2>&1; then
|
|
printf '%s' "$1" | shasum -a 256 | cut -d' ' -f1
|
|
elif command -v sha256sum >/dev/null 2>&1; then
|
|
printf '%s' "$1" | sha256sum | cut -d' ' -f1
|
|
else
|
|
printf '%s' "$1" | openssl dgst -sha256 | awk '{print $NF}'
|
|
fi
|
|
}
|
|
|
|
# Resolve the project root: CLAUDE_PROJECT_DIR, else walk up from $PWD to
|
|
# the first directory containing CLAUDE.md. Sets ROOT (may lack CLAUDE.md).
|
|
_resolve_root() {
|
|
ROOT="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
while [ ! -f "$ROOT/CLAUDE.md" ] && [ "$ROOT" != "/" ]; do
|
|
ROOT="$(dirname "$ROOT")"
|
|
done
|
|
}
|
|
|
|
# Extract the declared command from $ROOT/CLAUDE.md into CMD (may be empty).
|
|
# Accepts both `<!-- gstack:verify: cmd -->` and bare `gstack:verify: cmd`.
|
|
_extract_cmd() {
|
|
CMD="$(sed -n 's/^[[:space:]]*\(<!--[[:space:]]*\)\{0,1\}gstack:verify:[[:space:]]*\(.*\)$/\2/p' "$ROOT/CLAUDE.md" | head -1)"
|
|
CMD="${CMD%%-->*}"
|
|
CMD="$(printf '%s' "$CMD" | tr -d '`' | sed 's/[[:space:]]*$//')"
|
|
}
|
|
|
|
# Symlink-stable store key for the root.
|
|
_trust_key() {
|
|
(cd "$ROOT" 2>/dev/null && pwd -P) || printf '%s' "$ROOT"
|
|
}
|
|
|
|
# Print the stored hash for key $1, or return 1 when absent.
|
|
_trusted_hash() {
|
|
[ -f "$STORE" ] || return 1
|
|
local p h
|
|
while IFS="$TAB" read -r p h; do
|
|
if [ "$p" = "$1" ]; then
|
|
printf '%s' "$h"
|
|
return 0
|
|
fi
|
|
done <"$STORE"
|
|
return 1
|
|
}
|
|
|
|
# Record key $1 -> hash $2, replacing any prior entry. Atomic, 0600.
|
|
_record_trust() {
|
|
local store_dir tmp p h
|
|
store_dir="$(dirname "$STORE")"
|
|
mkdir -p "$store_dir"
|
|
tmp="$STORE.tmp.$$"
|
|
: >"$tmp"
|
|
chmod 600 "$tmp"
|
|
if [ -f "$STORE" ]; then
|
|
while IFS="$TAB" read -r p h; do
|
|
[ "$p" = "$1" ] || printf '%s\t%s\n' "$p" "$h" >>"$tmp"
|
|
done <"$STORE"
|
|
fi
|
|
printf '%s\t%s\n' "$1" "$2" >>"$tmp"
|
|
mv -f "$tmp" "$STORE"
|
|
}
|
|
|
|
# Minimal JSON string escaping (backslash + double quote). CMD and paths are
|
|
# single-line by construction, so control characters never appear.
|
|
_json_escape() {
|
|
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g'
|
|
}
|
|
|
|
# Forensic audit of trust grants. --trust stays agent-runnable (guardrail
|
|
# posture: catch accidents, not determined actors — same as the redaction
|
|
# guard), but a grant is never invisible: append {ts, root, cmd_sha256,
|
|
# cmd verbatim, tty} to a 0600 JSONL under GSTACK_HOME/security/.
|
|
# Args: $1 = root key, $2 = cmd sha256, $3 = cmd verbatim.
|
|
_log_trust_grant() {
|
|
local sec_dir log tty ts
|
|
sec_dir="$GSTACK_STATE_ROOT/security"
|
|
log="$sec_dir/verify-gate-trust-grants.jsonl"
|
|
tty=false
|
|
[ -t 0 ] && tty=true
|
|
ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
mkdir -p "$sec_dir"
|
|
[ -f "$log" ] || : >"$log"
|
|
chmod 600 "$log" 2>/dev/null || true
|
|
printf '{"ts":"%s","root":"%s","cmd_sha256":"%s","cmd":"%s","tty":%s}\n' \
|
|
"$ts" "$(_json_escape "$1")" "$2" "$(_json_escape "$3")" "$tty" >>"$log"
|
|
}
|
|
|
|
if [ "${1:-}" = "--trust" ]; then
|
|
_resolve_root
|
|
if [ ! -f "$ROOT/CLAUDE.md" ]; then
|
|
echo "verify-gate: no CLAUDE.md above $PWD, nothing to trust." >&2
|
|
exit 1
|
|
fi
|
|
_extract_cmd
|
|
if [ -z "$CMD" ]; then
|
|
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, nothing to trust." >&2
|
|
exit 1
|
|
fi
|
|
KEY="$(_trust_key)"
|
|
HASH="$(_sha256 "$CMD")"
|
|
_record_trust "$KEY" "$HASH"
|
|
_log_trust_grant "$KEY" "$HASH" "$CMD"
|
|
echo "verify-gate: trusted '$CMD' for $ROOT."
|
|
exit 0
|
|
fi
|
|
|
|
INPUT=""
|
|
[ -t 0 ] || INPUT="$(cat)"
|
|
|
|
# Claude Code re-runs Stop hooks after a block (stop_hook_active=true). A
|
|
# re-entry is NOT a free pass: the gate re-runs the trusted check so an agent
|
|
# can't clear a red verification by simply stopping again. Re-entry blocks are
|
|
# bounded per episode (MAX_REENTRY_BLOCKS) so a stuck check can't loop forever;
|
|
# at the bound the gate allows with a loud warning.
|
|
REENTRY=0
|
|
if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then
|
|
REENTRY=1
|
|
fi
|
|
|
|
_resolve_root
|
|
|
|
if [ ! -f "$ROOT/CLAUDE.md" ]; then
|
|
echo "verify-gate: no CLAUDE.md above $PWD, no check declared, allowing."
|
|
exit 0
|
|
fi
|
|
|
|
_extract_cmd
|
|
|
|
if [ -z "$CMD" ]; then
|
|
echo "verify-gate: $ROOT/CLAUDE.md declares no 'gstack:verify:' command, allowing."
|
|
exit 0
|
|
fi
|
|
|
|
# Trust gate: never execute a declared command the user has not recorded.
|
|
# Applies on re-entry too — untrusted commands keep the exit-0-with-hint path.
|
|
if [ "$(_trusted_hash "$(_trust_key)" || true)" != "$(_sha256 "$CMD")" ]; then
|
|
echo "verify-gate: found '$CMD' in $ROOT/CLAUDE.md but it is not trusted yet, skipping; enable with: cd $ROOT && $0 --trust" >&2
|
|
echo "verify-gate: declared command not trusted, allowing."
|
|
exit 0
|
|
fi
|
|
|
|
# Episode-scoped re-entry attempt counter. Keyed by the hook-input session_id
|
|
# when present, else ppid+root — stale entries are fine to overwrite.
|
|
MAX_REENTRY_BLOCKS=3
|
|
_session_key() {
|
|
local sid
|
|
sid="$(printf '%s' "$INPUT" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1)"
|
|
[ -n "$sid" ] || sid="ppid-$PPID"
|
|
_sha256 "$sid|$(_trust_key)"
|
|
}
|
|
ATTEMPTS_DIR="$GSTACK_STATE_ROOT/verify-gate-attempts"
|
|
COUNTER="$ATTEMPTS_DIR/$(_session_key)"
|
|
|
|
# A first entry (stop_hook_active=false) starts a fresh blocking episode.
|
|
if [ "$REENTRY" -eq 0 ]; then
|
|
rm -f "$COUNTER" 2>/dev/null || true
|
|
fi
|
|
|
|
OUT="$(cd "$ROOT" && eval "$CMD" 2>&1)"
|
|
STATUS=$?
|
|
|
|
if [ "$STATUS" -eq 0 ]; then
|
|
rm -f "$COUNTER" 2>/dev/null || true
|
|
echo "verify-gate: declared check passed ($CMD)."
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$REENTRY" -eq 1 ]; then
|
|
COUNT="$(cat "$COUNTER" 2>/dev/null || echo 0)"
|
|
case "$COUNT" in
|
|
''|*[!0-9]*) COUNT=0 ;;
|
|
esac
|
|
if [ "$COUNT" -ge "$MAX_REENTRY_BLOCKS" ]; then
|
|
rm -f "$COUNTER" 2>/dev/null || true
|
|
WARN="verify-gate: WARNING — allowing after $MAX_REENTRY_BLOCKS blocked re-entries but the declared check is still FAILING ($CMD). Verification is RED; do not treat this turn as verified."
|
|
echo "$WARN"
|
|
echo "$WARN" >&2
|
|
exit 0
|
|
fi
|
|
mkdir -p "$ATTEMPTS_DIR"
|
|
echo $((COUNT + 1)) >"$COUNTER"
|
|
fi
|
|
|
|
echo "verify-gate: declared check FAILED with exit $STATUS: $CMD" >&2
|
|
printf '%s\n' "$OUT" | tail -20 >&2
|
|
echo "Fix the failure, or drop the gstack:verify line from $ROOT/CLAUDE.md." >&2
|
|
exit 2
|