From 1e7001dc8c5206ef126c356d305f8d502cb5cdd6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 17:13:23 -0700 Subject: [PATCH] fix(verify-gate): trust before eval, re-check on re-entry, audit every grant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in Stop hook eval'd whatever command the first CLAUDE.md up the tree declared — any cloned repo got arbitrary shell at turn end. Now a per-repo trust store (path+command hash, 0600) gates execution: an untrusted or changed command never runs (exit 0 with the --trust invocation printed), stop_hook_active re-entry re-runs the trusted check instead of rubber-stamping (bounded at 3 blocks per episode), and every grant appends a forensic line to ~/.gstack/security/verify-gate-trust-grants.jsonl. 20 tests, red-first. --- bin/gstack-verify-gate | 182 +++++++++++++++++++++++++-- test/verify-gate.test.ts | 266 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 419 insertions(+), 29 deletions(-) diff --git a/bin/gstack-verify-gate b/bin/gstack-verify-gate index 512a16028..41f02cb5a 100755 --- a/bin/gstack-verify-gate +++ b/bin/gstack-verify-gate @@ -8,48 +8,206 @@ # 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_HOME:-$HOME/.gstack}/verify-gate-trust (flat "pathhash", +# 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 -INPUT="" -[ -t 0 ] || INPUT="$(cat)" +TAB="$(printf '\t')" +STORE="${GSTACK_HOME:-$HOME/.gstack}/verify-gate-trust" -# Claude Code re-runs Stop hooks after a block. Never gate the same turn twice. -if printf '%s' "$INPUT" | grep -q '"stop_hook_active"[[:space:]]*:[[:space:]]*true'; then - echo "verify-gate: gate already ran this turn, allowing." +_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 `` and bare `gstack:verify: cmd`. +_extract_cmd() { + CMD="$(sed -n 's/^[[:space:]]*\(*}" + 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_HOME:-$HOME/.gstack}/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 -ROOT="${CLAUDE_PROJECT_DIR:-$PWD}" -while [ ! -f "$ROOT/CLAUDE.md" ] && [ "$ROOT" != "/" ]; do - ROOT="$(dirname "$ROOT")" -done +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 -CMD="$(sed -n 's/^[[:space:]]*\(*}" -CMD="$(printf '%s' "$CMD" | tr -d '`' | sed 's/[[:space:]]*$//')" +_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_HOME:-$HOME/.gstack}/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 diff --git a/test/verify-gate.test.ts b/test/verify-gate.test.ts index 776895e02..99bf9670b 100644 --- a/test/verify-gate.test.ts +++ b/test/verify-gate.test.ts @@ -1,9 +1,11 @@ /** * gstack-verify-gate — Stop-hook enforcement tier. * - * Pins the three behaviours the gate exists for: - * block — declared check fails, exit 2, turn cannot end. - * allow — declared check passes, exit 0. + * Pins the behaviours the gate exists for: + * trust — a declared command NEVER runs until the user records it via + * `gstack-verify-gate --trust` (per-repo command trust store). + * block — trusted check fails, exit 2, turn cannot end. + * allow — trusted check passes, exit 0. * fail open — nothing declared, exit 0. Absence never blocks. * * Plus the two safety branches: the Stop re-entry guard, and the static @@ -15,49 +17,188 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { createHash } from 'crypto'; import { spawnSync } from 'child_process'; const ROOT = path.resolve(import.meta.dir, '..'); const GATE = path.join(ROOT, 'bin', 'gstack-verify-gate'); let dir: string; +let gstackHome: string; beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-')); + gstackHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-verify-gate-home-')); }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(gstackHome, { recursive: true, force: true }); }); -/** Declare a verification command in the project's CLAUDE.md. */ +/** Declare a verification command in the project's CLAUDE.md (comment form). */ function declareCheck(command: string): void { fs.writeFileSync(path.join(dir, 'CLAUDE.md'), `# Fixture\n\n\n`); } -/** Write the check script the declaration points at. */ +/** Write the check script the declaration points at. Touches `check-ran` when executed. */ function check(exitCode: number, message: string): void { const script = path.join(dir, 'check.sh'); - fs.writeFileSync(script, `#!/bin/sh\necho "${message}"\nexit ${exitCode}\n`); + fs.writeFileSync(script, `#!/bin/sh\ntouch check-ran\necho "${message}"\nexit ${exitCode}\n`); fs.chmodSync(script, 0o755); } -function runGate(stopHookActive = false): { code: number; stdout: string; stderr: string } { +/** Did the declared check actually execute? */ +function checkRan(): boolean { + return fs.existsSync(path.join(dir, 'check-ran')); +} + +interface RunOpts { + stopHookActive?: boolean; + cwd?: string; + /** When false, CLAUDE_PROJECT_DIR is removed from the child env (walk-up mode). */ + projectDirEnv?: boolean; + /** Hook-input session id, keys the re-entry attempt counter. */ + sessionId?: string; +} + +function gateEnv(projectDirEnv: boolean): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env, GSTACK_HOME: gstackHome }; + if (projectDirEnv) env.CLAUDE_PROJECT_DIR = dir; + else delete env.CLAUDE_PROJECT_DIR; + return env; +} + +function runGate(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } { + const input: Record = { stop_hook_active: opts.stopHookActive ?? false }; + if (opts.sessionId) input.session_id = opts.sessionId; const r = spawnSync(GATE, { - cwd: dir, - input: JSON.stringify({ stop_hook_active: stopHookActive }), + cwd: opts.cwd ?? dir, + input: JSON.stringify(input), encoding: 'utf-8', timeout: 15000, - env: { ...process.env, CLAUDE_PROJECT_DIR: dir }, + env: gateEnv(opts.projectDirEnv ?? true), }); return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' }; } -describe('gstack-verify-gate', () => { - test('blocks the turn when the declared check fails', () => { +/** Record the currently-declared command in the trust store. */ +function trust(opts: RunOpts = {}): { code: number; stdout: string; stderr: string } { + const r = spawnSync(GATE, ['--trust'], { + cwd: opts.cwd ?? dir, + encoding: 'utf-8', + timeout: 15000, + env: gateEnv(opts.projectDirEnv ?? true), + }); + return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' }; +} + +describe('gstack-verify-gate trust store', () => { + test('an untrusted declared command is NOT executed and does not block', () => { + declareCheck('touch sentinel-ran'); + + const r = runGate(); + + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false); + expect(r.stderr).toContain('--trust'); + expect(r.stderr).toContain('touch sentinel-ran'); + }); + + test('--trust records the command and prints a confirmation naming it', () => { + declareCheck('./check.sh'); + check(0, 'all good'); + + const t = trust(); + + expect(t.code).toBe(0); + expect(t.stdout).toContain('./check.sh'); + }); + + test('after --trust the hook executes the command and block semantics work', () => { declareCheck('./check.sh'); check(1, 'totals mismatch'); + expect(trust().code).toBe(0); + const r = runGate(); + + expect(checkRan()).toBe(true); + expect(r.code).toBe(2); + expect(r.stderr).toContain('FAILED'); + expect(r.stderr).toContain('totals mismatch'); + }); + + test('a changed command is not executed until re-trusted', () => { + declareCheck('true'); + expect(trust().code).toBe(0); + + // Attacker (or anyone) edits the declaration after trust was granted. + declareCheck('touch sentinel-ran'); + const r = runGate(); + + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false); + expect(r.stderr).toContain('--trust'); + + // Re-trusting the new command restores execution. + expect(trust().code).toBe(0); + const r2 = runGate(); + expect(r2.code).toBe(0); + expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(true); + }); + + test('walk-up: hook run from a nested subdir keys trust on the CLAUDE.md root', () => { + declareCheck('./check.sh'); + check(0, 'all good'); + const nested = path.join(dir, 'a', 'b'); + fs.mkdirSync(nested, { recursive: true }); + + // No CLAUDE_PROJECT_DIR: both trust and hook must walk up from $PWD. + expect(trust({ cwd: nested, projectDirEnv: false }).code).toBe(0); + const r = runGate({ cwd: nested, projectDirEnv: false }); + + expect(r.code).toBe(0); + expect(r.stdout).toContain('passed'); + expect(checkRan()).toBe(true); + }); + + test('non-comment declaration form (gstack:verify: cmd without ) is honored', () => { + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\ngstack:verify: ./check.sh\n'); + check(0, 'all good'); + + expect(trust().stdout).toContain('./check.sh'); + const r = runGate(); + + expect(r.code).toBe(0); + expect(r.stdout).toContain('passed'); + expect(checkRan()).toBe(true); + }); + + test('the trust store file is created 0600 under GSTACK_HOME', () => { + declareCheck('./check.sh'); + check(0, 'all good'); + expect(trust().code).toBe(0); + + const store = path.join(gstackHome, 'verify-gate-trust'); + expect(fs.existsSync(store)).toBe(true); + expect(fs.statSync(store).mode & 0o777).toBe(0o600); + }); + + test('--trust fails cleanly when nothing is declared', () => { + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n'); + + const t = trust(); + + expect(t.code).not.toBe(0); + }); +}); + +describe('gstack-verify-gate', () => { + test('blocks the turn when the trusted check fails', () => { + declareCheck('./check.sh'); + check(1, 'totals mismatch'); + expect(trust().code).toBe(0); + const r = runGate(); expect(r.code).toBe(2); @@ -65,9 +206,10 @@ describe('gstack-verify-gate', () => { expect(r.stderr).toContain('totals mismatch'); }); - test('allows the turn when the declared check passes', () => { + test('allows the turn when the trusted check passes', () => { declareCheck('./check.sh'); check(0, 'all good'); + expect(trust().code).toBe(0); const r = runGate(); @@ -100,14 +242,105 @@ describe('gstack-verify-gate', () => { expect(r.stdout).toContain("declares no 'gstack:verify:' command"); }); - test('re-entry guard: a failing check does not block twice in one turn', () => { +}); + +describe('gstack-verify-gate re-entry enforcement (no one-shot bypass)', () => { + test('re-entry with a still-failing trusted check is blocked again', () => { declareCheck('./check.sh'); check(1, 'still failing'); + expect(trust().code).toBe(0); - const r = runGate(true); + const r = runGate({ stopHookActive: true, sessionId: 'sess-refail' }); + + expect(r.code).toBe(2); + expect(r.stderr).toContain('FAILED'); + expect(r.stderr).toContain('still failing'); + expect(checkRan()).toBe(true); + }); + + test('re-entry after the check now passes is allowed', () => { + declareCheck('./check.sh'); + check(1, 'totals mismatch'); + expect(trust().code).toBe(0); + expect(runGate({ sessionId: 'sess-fixed' }).code).toBe(2); + + check(0, 'fixed now'); + const r = runGate({ stopHookActive: true, sessionId: 'sess-fixed' }); expect(r.code).toBe(0); - expect(r.stdout).toContain('already ran'); + expect(r.stdout).toContain('passed'); + }); + + test('attempt bound: repeated failing re-entries allow with a loud warning at the bound', () => { + declareCheck('./check.sh'); + check(1, 'never passing'); + expect(trust().code).toBe(0); + const sid = 'sess-bound'; + + // First entry blocks and resets the episode counter. + expect(runGate({ sessionId: sid }).code).toBe(2); + + // Re-entries: bounded number of blocks, then allow-with-warning. + const codes: number[] = []; + let final: { code: number; stdout: string; stderr: string } | null = null; + for (let i = 0; i < 6; i++) { + const r = runGate({ stopHookActive: true, sessionId: sid }); + codes.push(r.code); + if (r.code === 0) { + final = r; + break; + } + expect(r.code).toBe(2); + } + + expect(codes).toEqual([2, 2, 2, 0]); + expect(final).not.toBeNull(); + expect(final!.stdout + final!.stderr).toContain('WARNING'); + + // A fresh first-entry run starts a new episode: blocked again, not allowed. + expect(runGate({ sessionId: sid }).code).toBe(2); + }); + + test('re-entry with an untrusted command keeps the exit-0-with-hint path', () => { + declareCheck('touch sentinel-ran'); + + const r = runGate({ stopHookActive: true, sessionId: 'sess-untrusted' }); + + expect(r.code).toBe(0); + expect(fs.existsSync(path.join(dir, 'sentinel-ran'))).toBe(false); + expect(r.stderr).toContain('--trust'); + }); +}); + +describe('gstack-verify-gate trust-grant audit trail', () => { + test('every --trust grant appends a JSON audit line (right sha256, 0600, verbatim cmd)', () => { + declareCheck('./check.sh'); + check(0, 'all good'); + + const t = trust(); + expect(t.code).toBe(0); + // --trust prints the VERBATIM command being trusted. + expect(t.stdout).toContain('./check.sh'); + + const log = path.join(gstackHome, 'security', 'verify-gate-trust-grants.jsonl'); + expect(fs.existsSync(log)).toBe(true); + expect(fs.statSync(log).mode & 0o777).toBe(0o600); + + const lines = fs.readFileSync(log, 'utf-8').trim().split('\n'); + expect(lines.length).toBe(1); + const entry = JSON.parse(lines[0]); + expect(entry.cmd).toBe('./check.sh'); + expect(entry.cmd_sha256).toBe(createHash('sha256').update('./check.sh').digest('hex')); + expect(entry.root).toBe(fs.realpathSync(dir)); + expect(typeof entry.tty).toBe('boolean'); + expect(entry.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/); + + // A second grant appends, never truncates. + declareCheck('true'); + expect(trust().code).toBe(0); + const lines2 = fs.readFileSync(log, 'utf-8').trim().split('\n'); + expect(lines2.length).toBe(2); + expect(JSON.parse(lines2[1]).cmd).toBe('true'); }); }); @@ -124,4 +357,3 @@ describe('opt-in contract (adapted from the fork: NOT registered by default)', ( expect(gate).toContain('gstack:verify:'); }); }); -