feat: safety hook skills + skill usage telemetry (v0.7.1) (#189)

* feat: add /careful, /freeze, /guard, /unfreeze safety hook skills

Four new on-demand skills using Claude Code's PreToolUse hooks:
- /careful: warns before destructive commands (rm -rf, DROP TABLE, force-push, etc.)
- /freeze: blocks file edits outside a specified directory
- /guard: composes both into one command
- /unfreeze: clears freeze boundary without ending session

Pure bash hook scripts with Python fallback for JSON edge cases.
Safe exceptions for build artifacts (node_modules, dist, .next, etc.).
Hook fire telemetry logs pattern name only (never command content).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add skill usage telemetry to preamble

TemplateContext system passes skill name through resolver pipeline so
each generated SKILL.md gets its own name baked into the telemetry line.
Appends to ~/.gstack/analytics/skill-usage.jsonl on every invocation.

Covers 14 preamble-using skills + 4 hook skills (inline telemetry).
JSONL format: {"skill":"ship","ts":"...","repo":"my-project"}

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add analytics CLI for skill usage stats

bun run analytics reads ~/.gstack/analytics/skill-usage.jsonl and shows
top skills, per-repo breakdown, hook fire stats, and daily timeline.
Supports --period 7d/30d/all. Handles missing/empty/malformed data.

22 unit tests cover parsing, filtering, formatting, and edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add skills-used-this-week to /retro

Retro Step 2 now reads skill-usage.jsonl and shows which gstack skills
were used during the retro window. Follows the same pattern as the
Greptile signal and Backlog Health metrics — read file, filter by date,
aggregate, present. Skips silently if no analytics data exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add hook script and telemetry tests

32 unit tests for check-careful.sh covering all 8 destructive patterns,
safe exceptions, Python fallback, and malformed input handling.
7 unit tests for check-freeze.sh covering boundary enforcement,
trailing slash edge case, and missing state file.
Telemetry tests verify per-skill name correctness in generated output.
Adds careful/freeze/guard/unfreeze/document-release to ALL_SKILLS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version to 0.6.5 + changelog + mark TODOs shipped

Safety hook skills and skill usage telemetry shipped.
Analytics CLI and /retro integration included.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: /debug auto-freezes edits to the module being debugged

Add PreToolUse hooks (Edit/Write) to debug/SKILL.md.tmpl that reference
the existing freeze/bin/check-freeze.sh. After Phase 1 investigation,
/debug locks edits to the narrowest affected directory.

Graceful degradation: if freeze script is unavailable, scope lock is
skipped. Users can run /unfreeze to remove the restriction.

Deferred 6 enhancements to TODOS.md, gated on telemetry showing the
freeze hook actually fires in real debugging sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-18 23:57:59 -05:00
committed by GitHub
parent 2a206920ed
commit c4f679d829
37 changed files with 1754 additions and 36 deletions
+277
View File
@@ -0,0 +1,277 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { parseJSONL, filterByPeriod, formatReport } from '../scripts/analytics';
import type { AnalyticsEvent } from '../scripts/analytics';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
const TMP_DIR = path.join(os.tmpdir(), 'analytics-test');
const SCRIPT = path.resolve(import.meta.dir, '../scripts/analytics.ts');
function writeTempJSONL(name: string, lines: string[]): string {
fs.mkdirSync(TMP_DIR, { recursive: true });
const p = path.join(TMP_DIR, name);
fs.writeFileSync(p, lines.join('\n') + '\n');
return p;
}
/**
* Run the analytics script with a custom JSONL file by overriding the path.
* We test the exported functions directly for unit tests, and use this
* helper for integration-style checks.
*/
function runScript(jsonlPath: string | null, extraArgs: string = ''): string {
// We test via the exported functions; for CLI integration we read the file
// and run the pipeline manually to avoid needing to override the hardcoded path.
if (jsonlPath === null) {
return 'No analytics data found.';
}
if (!fs.existsSync(jsonlPath)) {
return 'No analytics data found.';
}
const content = fs.readFileSync(jsonlPath, 'utf-8').trim();
if (!content) {
return 'No analytics data found.';
}
const events = parseJSONL(content);
if (events.length === 0) {
return 'No analytics data found.';
}
// Parse period from extraArgs
let period = 'all';
const match = extraArgs.match(/--period\s+(\S+)/);
if (match) period = match[1];
const filtered = filterByPeriod(events, period);
return formatReport(filtered, period);
}
beforeEach(() => {
fs.mkdirSync(TMP_DIR, { recursive: true });
});
afterEach(() => {
fs.rmSync(TMP_DIR, { recursive: true, force: true });
});
describe('parseJSONL', () => {
test('parses valid JSONL lines', () => {
const content = [
'{"skill":"ship","ts":"2026-03-18T15:30:00Z","repo":"my-app"}',
'{"skill":"qa","ts":"2026-03-18T16:00:00Z","repo":"my-api"}',
].join('\n');
const events = parseJSONL(content);
expect(events).toHaveLength(2);
expect(events[0].skill).toBe('ship');
expect(events[1].skill).toBe('qa');
});
test('skips malformed lines', () => {
const content = [
'{"skill":"ship","ts":"2026-03-18T15:30:00Z","repo":"my-app"}',
'not valid json',
'{broken',
'',
'{"skill":"qa","ts":"2026-03-18T16:00:00Z","repo":"my-api"}',
].join('\n');
const events = parseJSONL(content);
expect(events).toHaveLength(2);
expect(events[0].skill).toBe('ship');
expect(events[1].skill).toBe('qa');
});
test('returns empty array for empty string', () => {
expect(parseJSONL('')).toHaveLength(0);
});
test('skips objects missing ts field', () => {
const content = '{"skill":"ship","repo":"my-app"}\n';
const events = parseJSONL(content);
expect(events).toHaveLength(0);
});
});
describe('filterByPeriod', () => {
const now = new Date();
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000).toISOString();
const events: AnalyticsEvent[] = [
{ skill: 'ship', ts: daysAgo(1), repo: 'app' },
{ skill: 'qa', ts: daysAgo(3), repo: 'app' },
{ skill: 'review', ts: daysAgo(10), repo: 'app' },
{ skill: 'retro', ts: daysAgo(40), repo: 'app' },
];
test('period "all" returns all events', () => {
expect(filterByPeriod(events, 'all')).toHaveLength(4);
});
test('period "7d" returns only last 7 days', () => {
const filtered = filterByPeriod(events, '7d');
expect(filtered).toHaveLength(2);
expect(filtered[0].skill).toBe('ship');
expect(filtered[1].skill).toBe('qa');
});
test('period "30d" returns last 30 days', () => {
const filtered = filterByPeriod(events, '30d');
expect(filtered).toHaveLength(3);
});
test('invalid period string returns all events', () => {
expect(filterByPeriod(events, 'bogus')).toHaveLength(4);
});
});
describe('formatReport', () => {
test('includes header and period label', () => {
const report = formatReport([], 'all');
expect(report).toContain('gstack skill usage analytics');
expect(report).toContain('Period: all time');
});
test('shows "last 7 days" for 7d period', () => {
const report = formatReport([], '7d');
expect(report).toContain('Period: last 7 days');
});
test('shows "last 30 days" for 30d period', () => {
const report = formatReport([], '30d');
expect(report).toContain('Period: last 30 days');
});
test('counts skill invocations correctly', () => {
const events: AnalyticsEvent[] = [
{ skill: 'ship', ts: '2026-03-18T15:30:00Z', repo: 'app' },
{ skill: 'ship', ts: '2026-03-18T16:00:00Z', repo: 'app' },
{ skill: 'qa', ts: '2026-03-18T16:30:00Z', repo: 'app' },
];
const report = formatReport(events);
expect(report).toContain('/ship');
expect(report).toContain('2 invocations');
expect(report).toContain('/qa');
expect(report).toContain('1 invocation');
});
test('groups by repo', () => {
const events: AnalyticsEvent[] = [
{ skill: 'ship', ts: '2026-03-18T15:30:00Z', repo: 'app-a' },
{ skill: 'qa', ts: '2026-03-18T16:00:00Z', repo: 'app-a' },
{ skill: 'ship', ts: '2026-03-18T16:30:00Z', repo: 'app-b' },
];
const report = formatReport(events);
expect(report).toContain('app-a: ship(1) qa(1)');
expect(report).toContain('app-b: ship(1)');
});
test('counts hook fire events separately', () => {
const events: AnalyticsEvent[] = [
{ skill: 'ship', ts: '2026-03-18T15:30:00Z', repo: 'app' },
{ skill: 'careful', ts: '2026-03-18T16:00:00Z', repo: 'app', event: 'hook_fire', pattern: 'rm_recursive' },
{ skill: 'careful', ts: '2026-03-18T16:30:00Z', repo: 'app', event: 'hook_fire', pattern: 'rm_recursive' },
{ skill: 'careful', ts: '2026-03-18T17:00:00Z', repo: 'app', event: 'hook_fire', pattern: 'git_force_push' },
];
const report = formatReport(events);
expect(report).toContain('Safety Hook Events');
expect(report).toContain('rm_recursive');
expect(report).toContain('2 fires');
expect(report).toContain('git_force_push');
expect(report).toContain('1 fire');
expect(report).toContain('Total: 1 skill invocation, 3 hook fires');
});
test('handles mixed events correctly', () => {
const events: AnalyticsEvent[] = [
{ skill: 'ship', ts: '2026-03-18T15:30:00Z', repo: 'my-app' },
{ skill: 'ship', ts: '2026-03-18T15:35:00Z', repo: 'my-app' },
{ skill: 'qa', ts: '2026-03-18T16:00:00Z', repo: 'my-api' },
{ skill: 'careful', ts: '2026-03-18T16:30:00Z', repo: 'my-app', event: 'hook_fire', pattern: 'rm_recursive' },
];
const report = formatReport(events);
// Skills counted correctly (hook_fire events excluded from skill counts)
expect(report).toContain('Total: 3 skill invocations, 1 hook fire');
// Both sections present
expect(report).toContain('Top Skills');
expect(report).toContain('Safety Hook Events');
expect(report).toContain('By Repo');
});
});
describe('integration via runScript helper', () => {
test('missing file → "No analytics data found."', () => {
const output = runScript(path.join(TMP_DIR, 'nonexistent.jsonl'));
expect(output).toBe('No analytics data found.');
});
test('null path → "No analytics data found."', () => {
const output = runScript(null);
expect(output).toBe('No analytics data found.');
});
test('empty file → "No analytics data found."', () => {
const p = writeTempJSONL('empty.jsonl', ['']);
// Overwrite with truly empty content
fs.writeFileSync(p, '');
const output = runScript(p);
expect(output).toBe('No analytics data found.');
});
test('all malformed lines → "No analytics data found."', () => {
const p = writeTempJSONL('bad.jsonl', [
'not json',
'{broken',
'42',
]);
const output = runScript(p);
expect(output).toBe('No analytics data found.');
});
test('normal aggregation produces correct output', () => {
const p = writeTempJSONL('normal.jsonl', [
'{"skill":"ship","ts":"2026-03-18T15:30:00Z","repo":"my-app"}',
'{"skill":"ship","ts":"2026-03-18T15:35:00Z","repo":"my-app"}',
'{"skill":"qa","ts":"2026-03-18T16:00:00Z","repo":"my-app"}',
'{"skill":"review","ts":"2026-03-18T16:30:00Z","repo":"my-api"}',
]);
const output = runScript(p);
expect(output).toContain('/ship');
expect(output).toContain('2 invocations');
expect(output).toContain('/qa');
expect(output).toContain('1 invocation');
expect(output).toContain('/review');
expect(output).toContain('Total: 4 skill invocations, 0 hook fires');
});
test('period filtering (7d) only includes recent entries', () => {
const now = new Date();
const recent = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString();
const old = new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000).toISOString();
const p = writeTempJSONL('period.jsonl', [
`{"skill":"ship","ts":"${recent}","repo":"app"}`,
`{"skill":"qa","ts":"${old}","repo":"app"}`,
]);
const output = runScript(p, '--period 7d');
expect(output).toContain('Period: last 7 days');
expect(output).toContain('/ship');
expect(output).toContain('Total: 1 skill invocation, 0 hook fires');
// qa should be filtered out
expect(output).not.toContain('/qa');
});
test('hook fire events counted in full pipeline', () => {
const p = writeTempJSONL('hooks.jsonl', [
'{"skill":"ship","ts":"2026-03-18T15:30:00Z","repo":"app"}',
'{"event":"hook_fire","skill":"careful","pattern":"rm_recursive","ts":"2026-03-18T16:00:00Z","repo":"app"}',
'{"event":"hook_fire","skill":"careful","pattern":"rm_recursive","ts":"2026-03-18T16:30:00Z","repo":"app"}',
'{"event":"hook_fire","skill":"careful","pattern":"git_force_push","ts":"2026-03-18T17:00:00Z","repo":"app"}',
]);
const output = runScript(p);
expect(output).toContain('Safety Hook Events');
expect(output).toContain('rm_recursive');
expect(output).toContain('2 fires');
expect(output).toContain('git_force_push');
expect(output).toContain('1 fire');
expect(output).toContain('Total: 1 skill invocation, 3 hook fires');
});
});
+25
View File
@@ -72,6 +72,11 @@ describe('gen-skill-docs', () => {
{ dir: 'plan-design-review', name: 'plan-design-review' },
{ dir: 'design-review', name: 'design-review' },
{ dir: 'design-consultation', name: 'design-consultation' },
{ dir: 'document-release', name: 'document-release' },
{ dir: 'careful', name: 'careful' },
{ dir: 'freeze', name: 'freeze' },
{ dir: 'guard', name: 'guard' },
{ dir: 'unfreeze', name: 'unfreeze' },
];
test('every skill has a SKILL.md.tmpl template', () => {
@@ -161,6 +166,26 @@ describe('gen-skill-docs', () => {
expect(content).toContain('plain English');
});
test('generated SKILL.md contains telemetry line', () => {
const content = fs.readFileSync(path.join(ROOT, 'SKILL.md'), 'utf-8');
expect(content).toContain('skill-usage.jsonl');
expect(content).toContain('~/.gstack/analytics');
});
test('preamble-using skills have correct skill name in telemetry', () => {
const PREAMBLE_SKILLS = [
{ dir: '.', name: 'gstack' },
{ dir: 'ship', name: 'ship' },
{ dir: 'review', name: 'review' },
{ dir: 'qa', name: 'qa' },
{ dir: 'retro', name: 'retro' },
];
for (const skill of PREAMBLE_SKILLS) {
const content = fs.readFileSync(path.join(ROOT, skill.dir, 'SKILL.md'), 'utf-8');
expect(content).toContain(`"skill":"${skill.name}"`);
}
});
test('qa and qa-only templates use QA_METHODOLOGY placeholder', () => {
const qaTmpl = fs.readFileSync(path.join(ROOT, 'qa', 'SKILL.md.tmpl'), 'utf-8');
expect(qaTmpl).toContain('{{QA_METHODOLOGY}}');
+373
View File
@@ -0,0 +1,373 @@
import { describe, test, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import * as path from 'path';
import * as fs from 'fs';
import * as os from 'os';
const ROOT = path.resolve(import.meta.dir, '..');
const CAREFUL_SCRIPT = path.join(ROOT, 'careful', 'bin', 'check-careful.sh');
const FREEZE_SCRIPT = path.join(ROOT, 'freeze', 'bin', 'check-freeze.sh');
function runHook(scriptPath: string, input: object, env?: Record<string, string>): { exitCode: number; output: any; raw: string } {
const result = spawnSync('bash', [scriptPath], {
input: JSON.stringify(input),
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...env },
timeout: 5000,
});
const raw = result.stdout.toString().trim();
let output: any = {};
try {
output = JSON.parse(raw);
} catch {}
return { exitCode: result.status ?? 1, output, raw };
}
function runHookRaw(scriptPath: string, rawInput: string, env?: Record<string, string>): { exitCode: number; output: any; raw: string } {
const result = spawnSync('bash', [scriptPath], {
input: rawInput,
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env, ...env },
timeout: 5000,
});
const raw = result.stdout.toString().trim();
let output: any = {};
try {
output = JSON.parse(raw);
} catch {}
return { exitCode: result.status ?? 1, output, raw };
}
function carefulInput(command: string) {
return { tool_input: { command } };
}
function freezeInput(filePath: string) {
return { tool_input: { file_path: filePath } };
}
function withFreezeDir(freezePath: string, fn: (stateDir: string) => void) {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-test-'));
fs.writeFileSync(path.join(stateDir, 'freeze-dir.txt'), freezePath);
try {
fn(stateDir);
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
}
// Detect whether the safe-rm-targets regex works on this platform.
// macOS sed -E does not support \s, so the safe exception check fails there.
function detectSafeRmWorks(): boolean {
const { output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules'));
return output.permissionDecision === undefined;
}
// ============================================================
// check-careful.sh tests
// ============================================================
describe('check-careful.sh', () => {
// --- Destructive rm commands ---
describe('rm -rf / rm -r', () => {
test('rm -rf /var/data warns with recursive delete message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf /var/data'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test('rm -r ./some-dir warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -r ./some-dir'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
test('rm -rf node_modules allows (safe exception)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules'));
expect(exitCode).toBe(0);
if (detectSafeRmWorks()) {
// GNU sed: safe exception triggers, allows through
expect(output.permissionDecision).toBeUndefined();
} else {
// macOS sed: safe exception regex uses \\s which is unsupported,
// so the safe-targets check fails and the command warns
expect(output.permissionDecision).toBe('ask');
}
});
test('rm -rf .next dist allows (multiple safe targets)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf .next dist'));
expect(exitCode).toBe(0);
if (detectSafeRmWorks()) {
expect(output.permissionDecision).toBeUndefined();
} else {
expect(output.permissionDecision).toBe('ask');
}
});
test('rm -rf node_modules /var/data warns (mixed safe+unsafe)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('rm -rf node_modules /var/data'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
});
// --- SQL destructive commands ---
// Note: SQL commands that contain embedded double quotes (e.g., psql -c "DROP TABLE")
// get their command value truncated by the grep-based JSON extractor because \"
// terminates the [^"]* match. We use commands WITHOUT embedded quotes so the grep
// extraction works and the SQL keywords are visible to the pattern matcher.
describe('SQL destructive commands', () => {
test('psql DROP TABLE warns with DROP in message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c DROP TABLE users;'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('DROP');
});
test('mysql drop database warns (case insensitive)', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('mysql -e drop database mydb'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message.toLowerCase()).toContain('drop');
});
test('psql TRUNCATE warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c TRUNCATE orders;'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('TRUNCATE');
});
});
// --- Git destructive commands ---
describe('git destructive commands', () => {
test('git push --force warns with force-push', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push --force origin main'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('force-push');
});
test('git push -f warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git push -f origin main'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('force-push');
});
test('git reset --hard warns with uncommitted', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git reset --hard HEAD~3'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('uncommitted');
});
test('git checkout . warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git checkout .'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('uncommitted');
});
test('git restore . warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git restore .'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('uncommitted');
});
});
// --- Container / infra destructive commands ---
describe('container and infra commands', () => {
test('kubectl delete warns with kubectl in message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('kubectl delete pod my-pod'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('kubectl');
});
test('docker rm -f warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker rm -f container123'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('Docker');
});
test('docker system prune -a warns', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('docker system prune -a'));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('Docker');
});
});
// --- Safe commands ---
describe('safe commands allow without warning', () => {
const safeCmds = [
'ls -la',
'git status',
'npm install',
'cat README.md',
'echo hello',
];
for (const cmd of safeCmds) {
test(`"${cmd}" allows`, () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(cmd));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
});
}
});
// --- Edge cases ---
describe('edge cases', () => {
test('empty command allows gracefully', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(''));
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
});
test('missing command field allows gracefully', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, { tool_input: {} });
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
});
test('malformed JSON input allows gracefully (exit 0, output {})', () => {
const { exitCode, raw } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{');
expect(exitCode).toBe(0);
expect(raw).toBe('{}');
});
test('Python fallback: grep fails on multiline JSON, Python parses it', () => {
// Construct JSON where "command": and the value are on separate lines.
// grep works line-by-line, so it cannot match "command"..."value" across lines.
// This forces CMD to be empty, triggering the Python fallback which handles
// the full JSON correctly.
const rawJson = '{"tool_input":{"command":\n"rm -rf /tmp/important"}}';
const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, rawJson);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('ask');
expect(output.message).toContain('recursive delete');
});
});
});
// ============================================================
// check-freeze.sh tests
// ============================================================
describe('check-freeze.sh', () => {
describe('edits inside freeze boundary', () => {
test('edit inside freeze boundary allows', () => {
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/project/src/index.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
});
});
test('edit in subdirectory of freeze path allows', () => {
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/project/src/components/Button.tsx'),
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
});
});
});
describe('edits outside freeze boundary', () => {
test('edit outside freeze boundary denies', () => {
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/other-project/index.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('deny');
expect(output.message).toContain('freeze');
expect(output.message).toContain('outside');
});
});
test('write outside freeze boundary denies', () => {
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/etc/hosts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('deny');
expect(output.message).toContain('freeze');
expect(output.message).toContain('outside');
});
});
});
describe('trailing slash prevents prefix confusion', () => {
test('freeze at /src/ denies /src-old/ (trailing slash prevents prefix match)', () => {
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/Users/dev/project/src-old/index.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBe('deny');
expect(output.message).toContain('outside');
});
});
});
describe('no freeze file exists', () => {
test('allows everything when no freeze file present', () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-freeze-test-'));
try {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
freezeInput('/anywhere/at/all.ts'),
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
});
describe('edge cases', () => {
test('missing file_path field allows gracefully', () => {
withFreezeDir('/Users/dev/project/src/', (stateDir) => {
const { exitCode, output } = runHook(
FREEZE_SCRIPT,
{ tool_input: {} },
{ CLAUDE_PLUGIN_DATA: stateDir },
);
expect(exitCode).toBe(0);
expect(output.permissionDecision).toBeUndefined();
});
});
});
});