mirror of https://github.com/garrytan/gstack.git
fix(verify-gate): trust before eval, re-check on re-entry, audit every grant
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.
This commit is contained in:
parent
9488173b0a
commit
1e7001dc8c
|
|
@ -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 "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
|
||||
|
||||
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 `<!-- 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_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:]]*\(<!--[[:space:]]*\)\{0,1\}gstack:verify:[[:space:]]*\(.*\)$/\2/p' "$ROOT/CLAUDE.md" | head -1)"
|
||||
CMD="${CMD%%-->*}"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<!-- gstack:verify: ${command} -->\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<string, unknown> = { 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:');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue