mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-01 19:25:10 +02:00
c4f679d829
* 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>
374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|
|
});
|