mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-12 07:59:02 +02:00
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:
+249
-17
@@ -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:');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user