Files
gstack/test/verify-gate.test.ts
T
Garry TanandClaude Fable 5 9da6692930 v1.68.1.0 fix: phantom AskUserQuestion hooks — canonical-only registration + self-healing settings.json (#2631)
* fix(settings-hook): KNOWN_HOOKS identity healer — per-item ownership, mutation lock, fail-closed parse

Claude Code strips the unknown _gstack_source key when it rewrites
settings.json, so tag-based dedupe degraded to exact-command equality and
every Conductor worktree's setup appended a fresh hook entry; deleted
worktrees left dead hooks erroring on every AskUserQuestion fire.

- KNOWN_HOOKS identity table (shared JS prelude, single source of truth):
  ownership is intrinsic and PER HOOK ITEM — basename + relpath suffix +
  event (+ matcher where defined). Tags never claim foreign items.
- New `prune-stale [--repoint <root>] [--all]`: prune dead gstack items,
  re-point survivors at the stable install (tag restore from the table),
  exact-duplicate collapse, uninstall/no-team identity sweep. Explicit
  plan_tune_hooks:no is honored (dead pruned, live never re-pointed).
- add-event / remove-source become item-aware: replace/remove only the owned
  item; a user's co-located hook in the same entry is never collateral.
- Mutation safety: mkdir lock with owner token, ownership-checked release,
  atomic stale takeover; per-process-unique tmp + backup names;
  backup-on-change everywhere; fail-closed on parse failure (a corrupt
  settings.json is never overwritten — previously catch{} clobbered it);
  locked atomic rollback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(gstack-config): `has <key>` — key-presence provenance through STATE_DIR resolution

`get` returns the DEFAULTS value for absent keys, so callers that need to
know whether the USER decided something (vs inherited a default) had no
correct primitive — setup's consent logic was about to grep a hardcoded
~/.gstack/config.yaml, which misclassifies under GSTACK_STATE_ROOT /
GSTACK_HOME / GSTACK_STATE_DIR overrides. `has` exits 0 iff the key is
literally present in the resolved config file, with the same C-locale key
validation as get/set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup): canonical-only hook registration, heal-first, PT_EXPLICIT consent provenance

Three root causes of the phantom-AskUserQuestion-hooks class, all in the
registration path:

- Bug A: the Conductor auto-opt-in upgraded PT_DECISION "prompt" -> "yes"
  even when "prompt" was dev-setup's EXPLICIT --plan-tune-hooks=prompt pin,
  so every new Conductor workspace installed hooks. PT_EXPLICIT (flag/env/
  config-key-presence via `gstack-config has`) now gates the auto-opt-in to
  the true silent fall-through.
- Bug B: hook commands were baked from $SOURCE_GSTACK_DIR (`pwd -P` of the
  running tree — ephemeral for worktrees). Registration is now CANONICAL-ONLY
  via _hook_command_path (${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/gstack);
  missing canonical hook = skip + log, never a baked tree path. SessionStart
  moves to schema-aware add-event under its identity source; whitespace paths
  are quoted.
- Bug C: nothing ever pruned, and dead tagged entries blocked the
  "already installed" guards forever. Setup now heals FIRST on every run
  (prune-stale --repoint at the stable install), surfaces a one-line summary
  only when something changed, surfaces the plan_tune_hooks:no-vs-live-hooks
  contradiction, and --no-team tears down all three sources plus an identity
  sweep for untagged strays.

dev-setup's no-mutation guarantee gains its stated repair exception (prune
dead / re-point existing, never ADD).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(uninstall): run hook cleanup BEFORE install-root deletion + full identity sweep

SETTINGS_HOOK resolves via $(dirname "$0") INSIDE the install root, but the
cleanup ran after `rm -rf ~/.claude/skills/gstack` — a real global uninstall
(running the installed copy) silently no-op'd and orphaned every hook entry.
Tests masked it by running the uninstaller from the repo checkout.

The relocated block also removes the auq-error-fallback source (registered by
setup, previously never torn down) and finishes with a prune-stale --all
identity sweep so untagged strays (Claude Code strips _gstack_source) go too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: phantom-hooks heal coverage — incident facsimile, per-item safety, lock, canonical tripwires

- gstack-settings-hook-schema-aware: 16 new cases — identity re-point (tag
  restore), foreign-basename rejection, mixed-entry per-item safety for
  add-event/remove-source/--all, prune-stale modes incl. bash-prefix +
  Windows-backslash + spaced-path idempotence, duplicate collapse preferring
  the tagged twin, plan_tune_hooks:no split, backup-on-change no-churn,
  fail-closed corrupt-JSON for every mutator, stale-lock takeover,
  fresh-foreign-lock skip, two-writer concurrency smoke, and an INCIDENT
  FACSIMILE replaying the exact 2026-08-17 production damage (6/3/2 entries,
  mixed tags, live-ephemeral Stop) healing to 2/1/1 canonical.
- NEW setup-hook-canonical-paths: static tripwires — canonical-only resolver
  (no $SOURCE_GSTACK_DIR anywhere in it), heal-before-guards ordering,
  unsuppressed heal output, ${VAR:-0} counter idiom, shared-prelude
  concatenation at every bun call site, KNOWN_HOOKS completeness vs setup's
  registrations, uninstall cleanup-before-deletion ordering, defect-class
  warning present.
- setup-plan-tune-hooks-noninteractive: PT_EXPLICIT pins + `gstack-config
  has` provenance + has-subcommand behavior (env-resolution, malformed keys).
- auq-error-fallback-hook: registration + both-teardown wiring (previously
  untested).
- uninstall: behavioral ordering test running the INSTALLED copy from inside
  the root it deletes.
- setup-windows-fallback / gstack-config-key-locale: pins updated for the new
  HOOK_CMD shape and the third C-locale validator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(test): banner-tripwire exec used JSON.stringify as shell quoting — vacuous pass + stray artifact

JSON escaping is not shell escaping. Interpolating JSON.stringify(script)
into `bash -c ${...}` left every JSON "\n" as a literal backslash-n inside
shell double quotes, collapsing the extracted release-body tripwire block
onto one line: `then\n` parsed as the command word `thenn`, and
`>&2\nelse\n` parsed as the redirect `>&2nelsen` — so every full-suite run
littered a `2nelsen` file (containing "bash: thenn: command not found") in
the repo root, and the test's single not-contains assertion passed
VACUOUSLY because all output had been redirected into that file. The
"and it actually fires" functional check never verified anything.

Fix: pass the script as an argv element (spawnSync array form) and assert
both branches for real — ABORT case must print the leak message to stderr,
clean case must print "banner tripwire clean" to stdout.

Verified: `bun test test/binding-template-drift.test.ts` previously created
the artifact deterministically; the full free suite now runs artifact-free.
The other shell-interpolation sites (evidence, schema-aware concurrency,
empty-find-fallthrough, branch-slug-hygiene) already use correct quoting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: regression pin for legacy remove mixed-entry filtering + ownership negatives

Coverage-audit iron rule: the rewritten legacy `remove` action filters
per-item (pre-v1.67.2 it dropped the whole entry, destroying a user's
co-located SessionStart hook) — modified existing behavior, previously
untested. Also pins two ownership negatives: an owned basename+relpath under
the WRONG matcher stays foreign, and prune-stale on an absent settings file
exits 0 with removed 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: pre-landing review fixes — review-army findings hardened

Specialist review (testing, maintainability, security, performance,
data-migration) findings, each verified against code before fixing:

- legacy remove: preserve malformed/foreign entries (hooks absent, non-array,
  or pre-existing empty) — only entries THIS pass emptied are dropped
- add-event: never tag a mixed entry (old gstack versions in sibling
  worktrees treat tags as entry-level ownership and would destroy the user's
  co-located items); tag only single-item entries; prune-stale drops tags
  from mixed entries for the same reason
- prune-stale: within-entry twin collapse (two dead copies of one hook
  re-pointed to the same canonical command no longer double-fire); command
  quoting hardened via gsQuoteCmd (escapes \\ " $ backtick; gsStripWrap
  unescapes so identity round-trips); NUL bytes in the dedupe key replaced
  with a JSON.stringify key (bash silently dropped the NULs, degrading the
  separator; the file also read as binary to tooling)
- gsIsAlive: only provable absence (ENOENT/ENOTDIR) counts as dead —
  EACCES/EIO/unmounted volumes no longer prune (one-way-ratchet guard)
- gsWriteIfChanged: preserves the live settings.json mode across rewrites
  (a user-tightened 0600 carrying API keys was silently broadened to 0644);
  fresh files start 0600; backups rotate (keep 10)
- remove-source: command-less items default to foreign (gstack only writes
  type:command items); single-item stray claim requires a command
- rollback: pointer target must be a sibling settings.json.bak.* file
- uninstall + setup --no-team + SessionStart registration: stderr stays
  attached — a lock give-up or fail-closed parse during TEARDOWN must be
  visible ("the next setup retries" does not apply after uninstall)
- setup: team-mode banner no longer claims an auto-update hook when
  registration was skipped; heal log documents the rollback-pointer caveat;
  SESSION_UPDATE_CMD quoting mirrors gsQuoteCmd; lock constants named
- list-sources: corrupt settings.json reports to stderr instead of silently
  printing nothing (setup guards must not misread corrupt as no-hooks)
- tests: 10 new pins (malformed-entry preservation, mixed no-tag, twin
  collapse, 0600 mode, metachar escaping round-trip, backup rotation,
  rollback pointer refusal, held-lock uninstall warning, matcher-drift
  tripwire, ownership negatives)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: red-team findings — verify-gate identity, single quoting authority, Windows paths

Red-team pass over the hardened diff (several findings empirically verified
by the reviewer before reporting):

- KNOWN_HOOKS gains the sixth identity: gstack-verify-gate (README-documented
  opt-in Stop hook). A tag-stripped verify-gate entry previously survived
  prune-stale --all and errored at the end of EVERY turn after uninstall
  deleted the install root — the exact phantom-hook class this branch fixes.
  Uninstall also sweeps its tagged form.
- add-event is now the single quoting authority: every registered command is
  normalized through the same gsQuoteCmd/gsStripWrap round-trip the healer
  uses. Pre-fix, only SessionStart got caller-side quoting — a spaced/metachar
  canonical root registered broken plan-tune/AUQ/timeline hooks that the very
  next heal rewrote (the codebase disagreed with its own registrations).
- Windows: MSYS-form paths (/c/Users/...) are drive-translated for fs checks
  only (gsWinPath) — native bun resolved them drive-relative, so the heal
  judged every LIVE Windows hook dead and pruned it. The three AskUserQuestion
  hooks and the Stop hook now also get the mandatory 'bash ' prefix on
  Windows (previously only SessionStart did; extensionless bash shims
  otherwise hit the file-association dialog).
- CANONICAL_GSTACK_ROOT falls back to $HOME/.claude/skills/gstack when a
  CLAUDE_CONFIG_DIR-derived root was never installed (the installer hardcodes
  the home path — split-brain left such users permanently hookless).
- prune-stale preserves foreign entries that STARTED empty (they were
  silently deleted, uncounted, on every heal).
- The timeline Stop registration and its list-sources guard join the
  zero-silent-mutations contract (stderr attached).

Tests: verify-gate tag-stripped heal+sweep, started-empty preservation,
add-event quoting-authority round-trip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: bump version and changelog (v1.68.1.0)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: update project documentation for v1.68.1.0

README: document canonical-only hook registration + the prune-stale
self-heal in the setup hooks section; expand the manual-uninstall note
to cover every gstack hook identity, not just timeline-stop-hook.
CONTRIBUTING: record PT_EXPLICIT provenance (Conductor auto-opt-in
fires only on the true silent fall-through) and the heal-first repair
exception in the dev-setup paragraph.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings-hook): fail-loud hardening — gsMain umbrella, lock exit 5, prototype-safe ownership

bun in -e mode swallows uncaught exceptions thrown after a require() and
exits 0 (verified on 1.3.13; uncaughtException handlers never fire either),
so any runtime throw in a mutator was a SILENT SUCCESS. Every script body
now runs inside a gsMain try/catch that prints "internal error ... refusing
to mutate" and exits 4.

Also: lock give-up now exits 5 instead of 0 (callers must not report a
skipped mutation as registered); basename lookup uses hasOwnProperty so a
foreign hook named "toString"/"constructor" can't resolve to an inherited
Object.prototype member and abort the sweep; ownership-checked release also
clears an empty/missing owner file; backup rotation sorts by mtime, not
name; Windows-only backslash normalization (a legal Unix path containing a
backslash is no longer rewritten); GSTACK_SWEEP_EXCLUDE_SOURCES lets a
sweep spare named sources; lock tradeoffs documented at the lock helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(setup): honest hook-registration reporting + verify-gate sweep exclusion

_install_plan_tune_hooks now propagates per-add-event failures (lock
contention exits 5, fail-closed settings errors exit 3) and both caller
sites branch on it: success logs the installed message, failure logs a
visible "NOT registered — re-run ./setup" warning instead of claiming
success for a mutation that never happened.

--no-team's identity sweep runs with GSTACK_SWEEP_EXCLUDE_SOURCES=
verify-gate: turning team mode off must not delete the user-registered
verify-gate opt-in whose binary still exists (uninstall still sweeps it,
correctly, because there the binary itself is being removed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: adversarial regression pins — wrong-shape fail-loud, prototype basename, sweep exclusion, lock exit 5

New pins for the fail-loud hardening: a wrong-shape hooks value (object
where an array belongs) exits 4 with "refusing to mutate" and leaves the
file byte-identical (pre-gsMain this was a silent exit-0 no-op); a foreign
hook whose basename collides with Object.prototype ("toString") survives
an --all sweep that still removes gstack rows; GSTACK_SWEEP_EXCLUDE_SOURCES
preserves the verify-gate row during --all; the fresh-foreign-lock test now
asserts the loud exit 5 instead of a quiet skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(verify-gate): allow the --no-team sweep exclusion, keep registration banned

setup now legitimately mentions verify-gate once: the --no-team identity
sweep excludes it via GSTACK_SWEEP_EXCLUDE_SOURCES so team-mode teardown
can't delete a user-registered gate. The opt-in pin tightens from a blanket
not-contains to: every mention must be a comment or that exclusion, and no
mention may sit on an add-event line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings-hook): GNU-first stat in the lock stale check — Linux abort on held locks

On Linux, BSD-style `stat -f %m` prints a multi-line FILESYSTEM block to
stdout before exiting 1, so the BSD-first || chain captured that garbage
concatenated with the real `stat -c %Y` epoch. The non-numeric mtime made
`$(( now - mtime ))` a syntax error and set -e killed the binary with
exit 1 whenever a lock dir already existed — every contention path (stale
takeover, give-up, concurrent writers) broke on CI while staying green on
macOS, where BSD stat -f succeeds cleanly.

GNU `stat -c %Y` now goes first (BSD stat rejects -c with no stdout, so
macOS falls through cleanly), and a numeric guard blanks any residual
garbage so a future platform quirk degrades to the normal give-up path
instead of an arithmetic abort. Same defect class as gstack-repo-mode's
GNU-first ordering (#2195). Verified in an oven/bun Linux container:
the four CI-failing lock tests now pass (62/62 across both files).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(uninstall): 30s budgets for the two subprocess-heavy behavioral tests

Both tests spawn the copied uninstaller, which itself runs several
settings-hook bun -e children (the lock-contention one also waits out a
300ms give-up per call). On a loaded box those cold starts blow bun's
default 5s per-test timeout, and a timeout kill reports as a bare fail
with no assertion diff — observed at 5.6-8.5s under load avg 25+.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 16:53:19 -07:00

370 lines
12 KiB
TypeScript

/**
* gstack-verify-gate — Stop-hook enforcement tier.
*
* 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
* opt-in contract (our adaptation): ./setup never registers the gate; the
* settings-hook helper.
*/
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 (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. Touches `check-ran` when executed. */
function check(exitCode: number, message: string): void {
const script = path.join(dir, 'check.sh');
fs.writeFileSync(script, `#!/bin/sh\ntouch check-ran\necho "${message}"\nexit ${exitCode}\n`);
fs.chmodSync(script, 0o755);
}
/** 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: opts.cwd ?? dir,
input: JSON.stringify(input),
encoding: 'utf-8',
timeout: 15000,
env: gateEnv(opts.projectDirEnv ?? true),
});
return { code: r.status ?? 1, stdout: r.stdout || '', stderr: r.stderr || '' };
}
/** 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);
expect(r.stderr).toContain('FAILED');
expect(r.stderr).toContain('totals mismatch');
});
test('allows the turn when the trusted check passes', () => {
declareCheck('./check.sh');
check(0, 'all good');
expect(trust().code).toBe(0);
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('passed');
});
test('fails open when CLAUDE.md declares no check', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Fixture\n\nNothing declared here.\n');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
});
test('fails open when there is no CLAUDE.md at all', () => {
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain('no CLAUDE.md');
});
test('never invents a command: an empty declaration fails open', () => {
fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '<!-- gstack:verify: -->\n');
const r = runGate();
expect(r.code).toBe(0);
expect(r.stdout).toContain("declares no 'gstack:verify:' command");
});
});
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({ 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('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');
});
});
describe('opt-in contract (adapted from the fork: NOT registered by default)', () => {
const setup = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8');
const gate = fs.readFileSync(GATE, 'utf-8');
test('./setup does NOT register the gate — a Stop hook running the verify command after every turn is opt-in', () => {
// The only permitted mentions are comments and the --no-team sweep
// EXCLUSION (protecting a user-registered gate from team-mode teardown).
// A registration (add-event) referencing the gate stays banned.
const mentions = setup.split('\n').filter((l) => l.includes('verify-gate'));
expect(mentions.length).toBeGreaterThan(0); // the exclusion itself is pinned
for (const line of mentions) {
const t = line.trim();
const allowed = t.startsWith('#') || t.includes('GSTACK_SWEEP_EXCLUDE_SOURCES="verify-gate"');
expect(allowed).toBe(true);
expect(t).not.toContain('add-event');
}
});
test('the bin documents its own registration and removal commands', () => {
expect(gate).toContain('remove-source --source verify-gate');
expect(gate).toContain('gstack:verify:');
});
});