From dc182dc27558700b138a77d64c58c9a4161fa50f Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Fri, 3 Jul 2026 11:54:04 +0200 Subject: [PATCH] feat(guard): opt-in session-time hook pack for Claude Code (#605) codeburn guard install|uninstall|status|refresh|allow plus the internal hook/statusline handlers. Off by default, fully local, cleanly removable. - Settings edits go through the action journal (guard-install / guard-uninstall) with expectedHash, appending our entries and removing exactly ours by command prefix; a byte-identical uninstall is asserted by test. - PreToolUse budget cap (soft warn once, hard block with a per-session allow override), Stop yield checkpoint (expensive with no edits and no commit, once), and a flagged-project SessionStart opener built from the optimize detectors. - Incremental per-session cache keyed by session id: resumes the transcript parse from the last complete-line byte offset via readSessionLines, folding only the tail into running totals. Warm invocation ~0.28s against a 90MB transcript, dominated by CLI startup; the tail parse itself is negligible. - All handlers fail open: any error, malformed stdin, or missing transcript exits 0 with no output so a broken guard can never block a session. - Hook protocol verified against the live docs (dated block at the top of hooks.ts); zero new dependencies. --- src/guard/cli.ts | 221 +++++++++++++++++++++++++++++ src/guard/flags.ts | 73 ++++++++++ src/guard/hooks.ts | 166 ++++++++++++++++++++++ src/guard/settings.ts | 199 ++++++++++++++++++++++++++ src/guard/store.ts | 86 +++++++++++ src/guard/usage.ts | 127 +++++++++++++++++ src/main.ts | 2 + tests/guard-hooks.test.ts | 276 ++++++++++++++++++++++++++++++++++++ tests/guard-install.test.ts | 144 +++++++++++++++++++ 9 files changed, 1294 insertions(+) create mode 100644 src/guard/cli.ts create mode 100644 src/guard/flags.ts create mode 100644 src/guard/hooks.ts create mode 100644 src/guard/settings.ts create mode 100644 src/guard/store.ts create mode 100644 src/guard/usage.ts create mode 100644 tests/guard-hooks.test.ts create mode 100644 tests/guard-install.test.ts diff --git a/src/guard/cli.ts b/src/guard/cli.ts new file mode 100644 index 0000000..f62941d --- /dev/null +++ b/src/guard/cli.ts @@ -0,0 +1,221 @@ +import type { Command } from 'commander' +import { readdir, stat, readFile } from 'fs/promises' +import { existsSync } from 'fs' +import { join } from 'path' +import chalk from 'chalk' + +type Scope = { global?: boolean; project?: string } + +function readStdin(): Promise { + return new Promise(resolve => { + const stdin = process.stdin + if (stdin.isTTY) { resolve(''); return } + let data = '' + stdin.setEncoding('utf-8') + stdin.on('data', c => { data += c }) + stdin.on('end', () => resolve(data)) + stdin.on('error', () => resolve('')) + }) +} + +function usd(n: number | null): string { + return n === null ? 'off' : `$${n}` +} + +async function refreshFlags(): Promise { + const { parseAllSessions } = await import('../parser.js') + const { buildFlags, writeFlags } = await import('./flags.js') + const projects = await parseAllSessions() + const flags = await buildFlags(projects) + await writeFlags(flags) + return flags.projects.length +} + +async function doInstall(scope: Scope, statusline: boolean): Promise { + const { settingsPathFor, buildInstall } = await import('./settings.js') + const { runAction } = await import('../act/apply.js') + const { shortId } = await import('../act/journal.js') + const { readGuardConfig, writeGuardConfig, guardConfigPath, DEFAULT_GUARD_CONFIG } = await import('./store.js') + + const path = settingsPathFor({ ...scope, cwd: process.cwd() }) + const built = buildInstall(path, { statusline }) + for (const note of built.notes) console.log(chalk.yellow(` ! ${note}`)) + if (built.plan) { + const record = await runAction(built.plan) + console.log(` Installed ${chalk.bold(shortId(record.id))} ${built.plan.description}`) + console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } else { + console.log(chalk.dim(` ${path}: nothing to change.`)) + } + + if (!existsSync(guardConfigPath())) { + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, updatedAt: new Date().toISOString() }) + const c = await readGuardConfig() + console.log(chalk.dim(` Wrote guard.json (soft ${usd(c.softUSD)}, hard ${usd(c.hardUSD)}, checkpoint ${usd(c.checkpointUSD)}).`)) + } + + try { + const flagged = await refreshFlags() + console.log(chalk.dim(` Flagged ${flagged} project${flagged === 1 ? '' : 's'} for session openers.`)) + } catch (e) { + console.log(chalk.yellow(` ! could not compute session-opener flags: ${e instanceof Error ? e.message : String(e)}`)) + } +} + +async function doUninstall(scope: Scope): Promise { + const { settingsPathFor, buildUninstall } = await import('./settings.js') + const { runAction } = await import('../act/apply.js') + const { shortId } = await import('../act/journal.js') + + const path = settingsPathFor({ ...scope, cwd: process.cwd() }) + const built = buildUninstall(path) + for (const note of built.notes) console.log(chalk.dim(` ${note}`)) + if (built.plan) { + const record = await runAction(built.plan) + console.log(` Uninstalled ${chalk.bold(shortId(record.id))} ${built.plan.description}`) + console.log(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`)) + } +} + +async function doStatus(): Promise { + const { readGuardConfig } = await import('./store.js') + const { inspectInstall, settingsPathFor } = await import('./settings.js') + const { readFlags, flagsAgeMs } = await import('./flags.js') + + const config = await readGuardConfig() + console.log(chalk.bold('\n codeburn guard')) + console.log(` soft cap: ${usd(config.softUSD)}`) + console.log(` hard cap: ${usd(config.hardUSD)}`) + console.log(` checkpoint: ${usd(config.checkpointUSD)}`) + console.log(` openers: ${config.openerEnabled ? 'on' : 'off'}`) + + const locations = [ + { label: 'global', path: settingsPathFor({ global: true }) }, + { label: 'project', path: settingsPathFor({ cwd: process.cwd() }) }, + ] + const found = locations + .map(l => ({ ...l, info: inspectInstall(l.path) })) + .filter(l => l.info.hooks.length > 0 || l.info.statusline) + if (found.length === 0) { + console.log(' installed: nowhere (run: codeburn guard install)') + } else { + for (const l of found) { + const bits = [...new Set(l.info.hooks)].join(', ') + console.log(` installed: ${l.label} ${l.path} [${bits}${l.info.statusline ? ', statusline' : ''}]`) + } + } + + const flags = await readFlags() + if (!flags) { + console.log(' flags: none (run: codeburn guard refresh)') + } else { + const ageDays = flagsAgeMs(flags) / 86_400_000 + console.log(` flags: ${flags.projects.length} project${flags.projects.length === 1 ? '' : 's'}, ${ageDays.toFixed(1)}d old`) + } + console.log() +} + +async function doAllow(sessionId: string | undefined): Promise { + const { guardDir } = await import('./store.js') + const { writeAllow } = await import('./usage.js') + let id = sessionId + if (!id) { + const dir = guardDir() + const names = (await readdir(dir).catch(() => [])).filter(f => f.endsWith('.json') && f !== 'flags.json') + let newest = { at: -1, id: '' } + for (const name of names) { + const st = await stat(join(dir, name)).catch(() => null) + if (!st) continue + if (st.mtimeMs > newest.at) { + try { + const cache = JSON.parse(await readFile(join(dir, name), 'utf-8')) as { sessionId?: string } + if (cache.sessionId) newest = { at: st.mtimeMs, id: cache.sessionId } + } catch { /* skip unreadable cache */ } + } + } + id = newest.id + } + if (!id) { + console.error(' No active guard session found. Pass the session id: codeburn guard allow .') + process.exitCode = 1 + return + } + await writeAllow(id) + console.log(` Lifted the guard hard cap for session ${id} (this session only).`) +} + +export function registerGuardCommands(program: Command): void { + const guard = program + .command('guard') + .description('Opt-in, removable session-time hooks for Claude Code (budget caps, openers, yield checkpoint)') + + guard + .command('install') + .description('Install the guard hooks into Claude Code settings (default: this project)') + .option('--global', 'Install into ~/.claude/settings.json instead of the project') + .option('--project ', 'Install into /.claude/settings.json') + .option('--statusline', 'Also configure the guard statusline (skipped if one already exists)') + .action(async (opts: { global?: boolean; project?: string; statusline?: boolean }) => { + try { + await doInstall({ global: opts.global, project: opts.project }, !!opts.statusline) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('uninstall') + .description('Remove the guard hooks, leaving any user hooks untouched') + .option('--global', 'Uninstall from ~/.claude/settings.json') + .option('--project ', 'Uninstall from /.claude/settings.json') + .action(async (opts: { global?: boolean; project?: string }) => { + try { + await doUninstall({ global: opts.global, project: opts.project }) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('status') + .description('Show resolved guard config, install locations, and the flag list') + .action(async () => { await doStatus() }) + + guard + .command('refresh') + .description('Recompute the per-project session-opener flag list from optimize signals') + .action(async () => { + try { + const n = await refreshFlags() + console.log(` Flagged ${n} project${n === 1 ? '' : 's'} for session openers.`) + } catch (e) { + console.error(` ${e instanceof Error ? e.message : String(e)}`) + process.exitCode = 1 + } + }) + + guard + .command('allow [sessionId]') + .description('Lift the hard budget cap for the current (or given) session') + .action(async (sessionId: string | undefined) => { await doAllow(sessionId) }) + + guard + .command('hook ') + .description('Internal: Claude Code invokes this with the hook payload on stdin') + .action(async (event: string) => { + const { runGuardHook } = await import('./hooks.js') + const out = await runGuardHook(event, await readStdin()) + if (out) process.stdout.write(out) + }) + + guard + .command('statusline') + .description('Internal: Claude Code statusline command; prints one line') + .action(async () => { + const { runGuardStatusline } = await import('./hooks.js') + const out = await runGuardStatusline(await readStdin()) + if (out) process.stdout.write(out + '\n') + }) +} diff --git a/src/guard/flags.ts b/src/guard/flags.ts new file mode 100644 index 0000000..0037045 --- /dev/null +++ b/src/guard/flags.ts @@ -0,0 +1,73 @@ +import { mkdir, readFile, writeFile } from 'fs/promises' +import { sep } from 'path' +import type { ProjectSummary } from '../types.js' +import { flagsPath, guardDir } from './store.js' + +// Per-project flag list computed at install / `guard refresh` time and read on +// SessionStart. The resolved opener text is stored here (not a code path into +// optimize) so the hot SessionStart handler never imports the analyzer. +export type ProjectFlag = { path: string; openers: string[] } +export type GuardFlags = { generatedAt: string; projects: ProjectFlag[] } + +export const FLAG_STALE_MS = 7 * 24 * 60 * 60 * 1000 + +// optimize is loaded lazily so importing this module (for readFlags/matchFlag, +// which the SessionStart hook needs) does not pull the analyzer. +export async function buildFlags(projects: ProjectSummary[]): Promise { + const { findLowWorthCandidates, findContextBloatCandidates, LOW_WORTH_OPENER, CONTEXT_HEAVY_OPENER } = + await import('../optimize.js') + const lowWorth = new Set(findLowWorthCandidates(projects).map(c => c.project)) + const contextHeavy = new Set(findContextBloatCandidates(projects).map(c => c.project)) + const flags: ProjectFlag[] = [] + for (const project of projects) { + const openers: string[] = [] + if (lowWorth.has(project.project)) openers.push(LOW_WORTH_OPENER) + if (contextHeavy.has(project.project)) openers.push(CONTEXT_HEAVY_OPENER) + if (openers.length > 0) flags.push({ path: project.projectPath, openers }) + } + return { generatedAt: new Date().toISOString(), projects: flags } +} + +export async function readFlags(base?: string): Promise { + let raw: string + try { + raw = await readFile(flagsPath(base), 'utf-8') + } catch { + return null + } + try { + const parsed = JSON.parse(raw) as GuardFlags + if (typeof parsed?.generatedAt !== 'string' || !Array.isArray(parsed.projects)) return null + return parsed + } catch { + return null + } +} + +export async function writeFlags(flags: GuardFlags, base?: string): Promise { + await mkdir(guardDir(base), { recursive: true }) + await writeFile(flagsPath(base), JSON.stringify(flags, null, 2) + '\n', 'utf-8') +} + +export function flagsAgeMs(flags: GuardFlags): number { + const t = Date.parse(flags.generatedAt) + return Number.isNaN(t) ? Number.POSITIVE_INFINITY : Date.now() - t +} + +function norm(p: string): string { + return p.length > 1 && p.endsWith(sep) ? p.slice(0, -1) : p +} + +// Openers for the flagged project the cwd sits in (exact match or a subdir of +// it); most-specific project wins. Empty when the cwd is not flagged. +export function matchFlag(flags: GuardFlags, cwd: string): string[] { + const target = norm(cwd) + let best: ProjectFlag | null = null + for (const flag of flags.projects) { + const base = norm(flag.path) + if (target === base || target.startsWith(base + sep)) { + if (!best || base.length > norm(best.path).length) best = flag + } + } + return best ? best.openers : [] +} diff --git a/src/guard/hooks.ts b/src/guard/hooks.ts new file mode 100644 index 0000000..236b3e9 --- /dev/null +++ b/src/guard/hooks.ts @@ -0,0 +1,166 @@ +// =========================================================================== +// Claude Code hook protocol, verified against the live docs on 2026-07-03 +// (https://docs.anthropic.com/en/docs/claude-code/hooks -> 301 -> +// https://code.claude.com/docs/en/hooks, and .../statusline). The spec's field +// names were NOT trusted; these are the doc's: +// +// Event names (exact casing): PreToolUse, SessionStart, Stop. +// stdin JSON (snake_case) common to every event: session_id, transcript_path, +// cwd, hook_event_name, permission_mode. PreToolUse adds tool_name + +// tool_input; SessionStart adds source (startup|resume|clear|compact). +// statusLine stdin differs: session_id, transcript_path, workspace.current_dir, +// cost.total_cost_usd (plain text out, first stdout line rendered). +// Exit/stdout contract: exit 0 -> stdout parsed as JSON output; exit 2 -> +// blocking, stderr fed to the model. We ALWAYS exit 0 and encode any decision +// as JSON, so an internal error is indistinguishable from "no opinion" +// (fail-open). Empty stdout = no effect. +// PreToolUse block: { hookSpecificOutput: { hookEventName: "PreToolUse", +// permissionDecision: "deny", permissionDecisionReason } } (permissionDecision +// is allow|deny|ask|defer). Non-blocking user note anywhere: top-level +// systemMessage. +// SessionStart context: { hookSpecificOutput: { hookEventName: "SessionStart", +// additionalContext } }; SessionStart cannot block. +// Stop: may block via top-level { decision: "block", reason } or exit 2; we +// never do; a non-blocking nudge uses systemMessage (additionalContext on Stop +// would force the conversation to continue, which we do not want). +// settings.json shape: { hooks: { : [ { matcher?, hooks: [ { type: +// "command", command } ] } ] } }. Stop takes no matcher; SessionStart matcher +// is startup|resume|clear|compact; an omitted matcher matches all. Statusline +// is a top-level statusLine: { type: "command", command }. +// =========================================================================== +import { readGuardConfig } from './store.js' +import { computeSessionUsage, isAllowed, readCache, writeCache } from './usage.js' +import { FLAG_STALE_MS, flagsAgeMs, matchFlag, readFlags } from './flags.js' + +export type HookOpts = { base?: string } + +function str(obj: unknown, key: string): string | undefined { + if (obj && typeof obj === 'object') { + const v = (obj as Record)[key] + if (typeof v === 'string') return v + } + return undefined +} + +function usd(n: number): string { + return `$${n.toFixed(2)}` +} + +async function handlePreToolUse(input: unknown, opts: HookOpts): Promise { + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + + const config = await readGuardConfig(opts.base) + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + + let output = '' + if (config.hardUSD !== null && cache.costUSD >= config.hardUSD && !(await isAllowed(sessionId, opts.base))) { + // A block is a stronger signal than the soft nag; once blocked, don't also + // fire a soft warning on the next (e.g. post-`allow`) tool call. + cache.softWarned = true + output = JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'deny', + permissionDecisionReason: + `Session cost passed ${usd(config.hardUSD)} (codeburn guard). Run 'codeburn guard allow' to lift the cap for this session, or raise hardUSD in guard.json.`, + }, + }) + } else if (config.softUSD !== null && cache.costUSD >= config.softUSD && !cache.softWarned) { + cache.softWarned = true + output = JSON.stringify({ + systemMessage: `codeburn guard: this session is ${usd(cache.costUSD)} (soft cap ${usd(config.softUSD)}).`, + }) + } + + await writeCache(cache, opts.base) + return output +} + +async function handleSessionStart(input: unknown, opts: HookOpts): Promise { + const cwd = str(input, 'cwd') + if (!cwd) return '' + const config = await readGuardConfig(opts.base) + if (!config.openerEnabled) return '' + const flags = await readFlags(opts.base) + if (!flags || flagsAgeMs(flags) > FLAG_STALE_MS) return '' + const openers = matchFlag(flags, cwd) + if (openers.length === 0) return '' + return JSON.stringify({ + hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: openers.join('\n\n') }, + }) +} + +async function handleStop(input: unknown, opts: HookOpts): Promise { + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + + const config = await readGuardConfig(opts.base) + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + + let output = '' + if ( + config.checkpointUSD !== null + && cache.costUSD > config.checkpointUSD + && cache.editCount === 0 + && !cache.sawGitCommit + && !cache.stopNotified + ) { + cache.stopNotified = true + output = JSON.stringify({ + systemMessage: + `This session is ${usd(cache.costUSD)} with no edits or commits yet. If exploring is the goal, fine; otherwise consider a fresh session with a named deliverable.`, + }) + } + + await writeCache(cache, opts.base) + return output +} + +// The fail-open boundary: parse stdin, dispatch, and turn ANY error, malformed +// payload, or unknown event into exit-0-with-empty-output. A broken guard must +// never block a session. +export async function runGuardHook(event: string, raw: string, opts: HookOpts = {}): Promise { + try { + const input = JSON.parse(raw) as unknown + switch (event.toLowerCase()) { + case 'pretooluse': return await handlePreToolUse(input, opts) + case 'sessionstart': return await handleSessionStart(input, opts) + case 'stop': return await handleStop(input, opts) + default: return '' + } + } catch { + return '' + } +} + +// statusLine is a separate command, not a hook event. One line: guard's session +// cost and how stale the incremental cache is versus a 5-minute turn TTL. +export const STATUSLINE_TTL_MS = 5 * 60 * 1000 + +export async function runGuardStatusline(raw: string, opts: HookOpts = {}): Promise { + try { + const input = JSON.parse(raw) as unknown + const sessionId = str(input, 'session_id') + const transcript = str(input, 'transcript_path') + if (!sessionId || !transcript) return '' + const prev = await readCache(sessionId, opts.base) + const { cache } = await computeSessionUsage(prev, transcript) + await writeCache(cache, opts.base) + return `codeburn guard ${usd(cache.costUSD)} ยท ${freshness(cache.lastTurnAt)}` + } catch { + return '' + } +} + +function freshness(lastTurnAt: string | null): string { + if (!lastTurnAt) return 'no turns yet' + const age = Date.now() - Date.parse(lastTurnAt) + if (Number.isNaN(age) || age < 0) return 'fresh' + const label = age < 60_000 ? `${Math.round(age / 1000)}s` : `${Math.round(age / 60_000)}m` + return age > STATUSLINE_TTL_MS ? `idle ${label}` : label +} diff --git a/src/guard/settings.ts b/src/guard/settings.ts new file mode 100644 index 0000000..843c2df --- /dev/null +++ b/src/guard/settings.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { sha256 } from '../act/backup.js' +import type { ActionPlan, PlannedChange } from '../act/types.js' + +// The hook entries `guard install` writes and `guard uninstall` removes. Every +// command carries the same recognizable prefix so uninstall can find exactly +// ours by substring even if the user later moved or reindented the file. +export const GUARD_HOOK_PREFIX = 'codeburn guard hook' +export const GUARD_STATUSLINE_COMMAND = 'codeburn guard statusline' + +const INSTALL_HOOKS: { event: string; matcher?: string; arg: string }[] = [ + { event: 'PreToolUse', arg: 'pretooluse' }, + { event: 'SessionStart', matcher: 'startup', arg: 'sessionstart' }, + { event: 'Stop', arg: 'stop' }, +] + +function hookCommand(arg: string): string { + return `${GUARD_HOOK_PREFIX} ${arg}` +} + +export function settingsPathFor(scope: { global?: boolean; project?: string; cwd?: string }): string { + const dir = scope.global ? homedir() : (scope.project ?? scope.cwd ?? process.cwd()) + return join(dir, '.claude', 'settings.json') +} + +type Loaded = { doc: Record; existed: boolean; rawHash: string | null } + +function load(path: string): Loaded { + if (!existsSync(path)) return { doc: {}, existed: false, rawHash: null } + const buf = readFileSync(path) + const rawHash = sha256(buf) + let raw = buf.toString('utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + let doc: unknown + try { + doc = JSON.parse(raw) + } catch (e) { + throw new Error(`could not parse ${path}: ${e instanceof Error ? e.message : String(e)}`) + } + if (!doc || typeof doc !== 'object' || Array.isArray(doc)) { + throw new Error(`${path} is not a JSON object`) + } + return { doc: doc as Record, existed: true, rawHash } +} + +type HookEntry = { type?: string; command?: string; [k: string]: unknown } +type MatcherGroup = { matcher?: string; hooks?: HookEntry[]; [k: string]: unknown } + +function asGroups(value: unknown): MatcherGroup[] { + return Array.isArray(value) ? (value as MatcherGroup[]) : [] +} + +function groupHasOurCommand(group: MatcherGroup, command: string): boolean { + return Array.isArray(group.hooks) && group.hooks.some(h => h?.command === command) +} + +export type SettingsBuild = { + plan: ActionPlan | null + path: string + existed: boolean + notes: string[] +} + +function change(path: string, existed: boolean, rawHash: string | null, doc: Record): PlannedChange { + return { + op: existed ? 'edit' : 'create', + path, + content: JSON.stringify(doc, null, 2) + '\n', + expectedHash: rawHash, + } +} + +export function buildInstall(path: string, opts: { statusline?: boolean } = {}): SettingsBuild { + const { doc, existed, rawHash } = load(path) + const notes: string[] = [] + const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks)) + ? doc.hooks as Record + : {} + let added = false + + for (const { event, matcher, arg } of INSTALL_HOOKS) { + const command = hookCommand(arg) + const groups = asGroups(hooks[event]) + if (groups.some(g => groupHasOurCommand(g, command))) continue + groups.push({ ...(matcher ? { matcher } : {}), hooks: [{ type: 'command', command }] }) + hooks[event] = groups + added = true + } + if (added || Object.keys(hooks).length > 0) doc.hooks = hooks + + if (opts.statusline) { + const existing = doc.statusLine + if (existing && typeof existing === 'object' && (existing as HookEntry).command !== GUARD_STATUSLINE_COMMAND) { + notes.push('a statusline is already configured; left it untouched (remove it first to use the guard statusline)') + } else if ((existing as HookEntry | undefined)?.command === GUARD_STATUSLINE_COMMAND) { + // already ours + } else { + doc.statusLine = { type: 'command', command: GUARD_STATUSLINE_COMMAND } + added = true + } + } + + if (!added) { + notes.push('guard hooks already present; nothing to install') + return { plan: null, path, existed, notes } + } + return { + plan: { + kind: 'guard-install', + findingId: null, + description: `Install codeburn guard hooks into ${path}`, + changes: [change(path, existed, rawHash, doc)], + }, + path, + existed, + notes, + } +} + +export function buildUninstall(path: string): SettingsBuild { + if (!existsSync(path)) { + return { plan: null, path, existed: false, notes: ['no settings file at that location; nothing to uninstall'] } + } + const { doc, existed, rawHash } = load(path) + let removed = false + + const hooks = (doc.hooks && typeof doc.hooks === 'object' && !Array.isArray(doc.hooks)) + ? doc.hooks as Record + : null + if (hooks) { + for (const event of Object.keys(hooks)) { + const groups = asGroups(hooks[event]) + const kept: MatcherGroup[] = [] + for (const group of groups) { + if (!Array.isArray(group.hooks)) { kept.push(group); continue } + const keptHooks = group.hooks.filter(h => !(typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX))) + if (keptHooks.length !== group.hooks.length) removed = true + if (keptHooks.length === 0) continue // drop a group that was only ours + kept.push(keptHooks.length === group.hooks.length ? group : { ...group, hooks: keptHooks }) + } + if (kept.length === 0) delete hooks[event] + else hooks[event] = kept + } + if (Object.keys(hooks).length === 0) delete doc.hooks + } + + const statusLine = doc.statusLine as HookEntry | undefined + if (statusLine && typeof statusLine.command === 'string' && statusLine.command.includes(GUARD_STATUSLINE_COMMAND)) { + delete doc.statusLine + removed = true + } + + if (!removed) { + return { plan: null, path, existed, notes: ['no codeburn guard hooks found in that settings file'] } + } + return { + plan: { + kind: 'guard-uninstall', + findingId: null, + description: `Remove codeburn guard hooks from ${path}`, + changes: [change(path, existed, rawHash, doc)], + }, + path, + existed, + notes: [], + } +} + +// Report whether a settings file currently carries our hooks / statusline, for +// `guard status`. Never throws: a missing or malformed file reads as absent. +export function inspectInstall(path: string): { path: string; hooks: string[]; statusline: boolean } { + const out = { path, hooks: [] as string[], statusline: false } + if (!existsSync(path)) return out + let doc: Record + try { + let raw = readFileSync(path, 'utf-8') + if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1) + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object') return out + doc = parsed as Record + } catch { + return out + } + const hooks = doc.hooks + if (hooks && typeof hooks === 'object') { + for (const [event, value] of Object.entries(hooks as Record)) { + for (const group of asGroups(value)) { + if (Array.isArray(group.hooks) && group.hooks.some(h => typeof h?.command === 'string' && h.command.includes(GUARD_HOOK_PREFIX))) { + out.hooks.push(event) + } + } + } + } + const sl = doc.statusLine as HookEntry | undefined + out.statusline = !!(sl && typeof sl.command === 'string' && sl.command.includes(GUARD_STATUSLINE_COMMAND)) + return out +} diff --git a/src/guard/store.ts b/src/guard/store.ts new file mode 100644 index 0000000..bfe1ffb --- /dev/null +++ b/src/guard/store.ts @@ -0,0 +1,86 @@ +import { mkdir, readFile, writeFile } from 'fs/promises' +import { dirname, join } from 'path' +import { getConfigFilePath } from '../config.js' + +// Guard state lives beside config.json under the CodeBurn home dir (in practice +// ~/.config/codeburn): guard.json for thresholds, a guard/ subdir for the +// per-session incremental caches, the flag list, and per-session allow markers. +// Every path derives from an injectable base so tests point the whole thing at +// a fixture dir and the real config dir is never touched. +export function guardBase(base?: string): string { + return base ?? dirname(getConfigFilePath()) +} + +export function guardConfigPath(base?: string): string { + return join(guardBase(base), 'guard.json') +} + +export function guardDir(base?: string): string { + return join(guardBase(base), 'guard') +} + +export function flagsPath(base?: string): string { + return join(guardDir(base), 'flags.json') +} + +export function sessionCachePath(sessionId: string, base?: string): string { + return join(guardDir(base), `${sanitizeId(sessionId)}.json`) +} + +export function allowPath(sessionId: string, base?: string): string { + return join(guardDir(base), `${sanitizeId(sessionId)}.allow`) +} + +// Session ids come from the hook payload; keep them to a filesystem-safe set so +// a malformed id can never escape the guard dir. +function sanitizeId(id: string): string { + return id.replace(/[^A-Za-z0-9._-]/g, '_') +} + +export type GuardConfig = { + softUSD: number | null + hardUSD: number | null + checkpointUSD: number | null + openerEnabled: boolean + updatedAt: string +} + +export const DEFAULT_GUARD_CONFIG: GuardConfig = { + softUSD: 5, + hardUSD: 15, + checkpointUSD: 3, + openerEnabled: true, + updatedAt: '', +} + +function coerceThreshold(v: unknown, fallback: number | null): number | null { + if (v === null) return null + return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback +} + +export async function readGuardConfig(base?: string): Promise { + let raw: string + try { + raw = await readFile(guardConfigPath(base), 'utf-8') + } catch { + return { ...DEFAULT_GUARD_CONFIG } + } + let parsed: Partial + try { + parsed = JSON.parse(raw) as Partial + } catch { + return { ...DEFAULT_GUARD_CONFIG } + } + return { + softUSD: coerceThreshold(parsed.softUSD, DEFAULT_GUARD_CONFIG.softUSD), + hardUSD: coerceThreshold(parsed.hardUSD, DEFAULT_GUARD_CONFIG.hardUSD), + checkpointUSD: coerceThreshold(parsed.checkpointUSD, DEFAULT_GUARD_CONFIG.checkpointUSD), + openerEnabled: parsed.openerEnabled !== false, + updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '', + } +} + +export async function writeGuardConfig(config: GuardConfig, base?: string): Promise { + await mkdir(guardBase(base), { recursive: true }) + await writeFile(guardConfigPath(base), JSON.stringify(config, null, 2) + '\n', 'utf-8') +} diff --git a/src/guard/usage.ts b/src/guard/usage.ts new file mode 100644 index 0000000..80edc72 --- /dev/null +++ b/src/guard/usage.ts @@ -0,0 +1,127 @@ +import { mkdir, readFile, stat, writeFile } from 'fs/promises' +import { readSessionLines } from '../fs-utils.js' +import { parseApiCall, parseJsonlLine } from '../parser.js' +import { EDIT_TOOLS } from '../classifier.js' +import { allowPath, guardDir, sessionCachePath } from './store.js' + +// Per-session running totals. The transcript is append-only, so each invocation +// streams only the bytes after `byteOffset` (the offset of the last complete +// line parsed) and folds them into the totals; a cold parse of a multi-hundred- +// MB transcript on every tool call is what this avoids. +export type GuardCache = { + version: number + sessionId: string + byteOffset: number + costUSD: number + editCount: number + sawGitCommit: boolean + lastTurnAt: string | null + updatedAt: string + softWarned: boolean + stopNotified: boolean +} + +export const GUARD_CACHE_VERSION = 1 + +// Raw command text is needed (not the base-binary names parseApiCall's +// bashCommands reduces to), so the Stop check reads call.toolSequence. +const GIT_COMMIT = /\bgit\b[\s\S]*?\bcommit\b/ + +export function emptyCache(sessionId: string): GuardCache { + return { + version: GUARD_CACHE_VERSION, + sessionId, + byteOffset: 0, + costUSD: 0, + editCount: 0, + sawGitCommit: false, + lastTurnAt: null, + updatedAt: '', + softWarned: false, + stopNotified: false, + } +} + +export async function readCache(sessionId: string, base?: string): Promise { + let raw: string + try { + raw = await readFile(sessionCachePath(sessionId, base), 'utf-8') + } catch { + return emptyCache(sessionId) + } + try { + const parsed = JSON.parse(raw) as Partial + if (parsed.version !== GUARD_CACHE_VERSION || typeof parsed.byteOffset !== 'number') { + return emptyCache(sessionId) + } + return { ...emptyCache(sessionId), ...parsed, sessionId } + } catch { + return emptyCache(sessionId) + } +} + +export async function writeCache(cache: GuardCache, base?: string): Promise { + await mkdir(guardDir(base), { recursive: true }) + await writeFile(sessionCachePath(cache.sessionId, base), JSON.stringify(cache), 'utf-8') +} + +// Fold the transcript tail into the totals. Reuses the streaming line reader +// (startByteOffset + a lastCompleteLineOffset tracker) and the shared per-call +// cost/pricing path (parseApiCall -> calculateCost), so the guard never +// reimplements cost math. `resumedFrom` is the offset the parse restarted at, +// which the test asserts to prove only the tail was read. +export async function computeSessionUsage( + prev: GuardCache, + transcriptPath: string, +): Promise<{ cache: GuardCache; resumedFrom: number }> { + let size: number + try { + size = (await stat(transcriptPath)).size + } catch { + return { cache: prev, resumedFrom: prev.byteOffset } + } + + // A shorter file than we last read means the transcript was rotated or + // truncated; start over from a clean total rather than trusting a stale + // offset into different bytes. + const cache = size < prev.byteOffset + ? { ...emptyCache(prev.sessionId), softWarned: prev.softWarned, stopNotified: prev.stopNotified } + : { ...prev } + const resumedFrom = cache.byteOffset + + const tracker = { lastCompleteLineOffset: resumedFrom } + for await (const line of readSessionLines(transcriptPath, undefined, { + startByteOffset: resumedFrom, + byteOffsetTracker: tracker, + largeLineAsBuffer: true, + })) { + const entry = parseJsonlLine(line) + if (!entry) continue + const call = parseApiCall(entry) + if (!call) continue + cache.costUSD += call.costUSD + for (const tc of call.toolSequence?.flat() ?? []) { + if (EDIT_TOOLS.has(tc.tool)) cache.editCount++ + if (!cache.sawGitCommit && tc.command && GIT_COMMIT.test(tc.command)) cache.sawGitCommit = true + } + if (call.timestamp) cache.lastTurnAt = call.timestamp + } + + cache.byteOffset = tracker.lastCompleteLineOffset + cache.updatedAt = new Date().toISOString() + return { cache, resumedFrom } +} + +export async function isAllowed(sessionId: string, base?: string): Promise { + try { + await stat(allowPath(sessionId, base)) + return true + } catch { + return false + } +} + +export async function writeAllow(sessionId: string, base?: string): Promise { + await mkdir(guardDir(base), { recursive: true }) + await writeFile(allowPath(sessionId, base), '', 'utf-8') +} diff --git a/src/main.ts b/src/main.ts index ffa0300..758d244 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,7 @@ import type { UsageQuery } from './sharing/share-server.js' import { formatDateRangeLabel, parseDateRangeFlags, parseDayFlag, parseDaysFlag, getDateRange, toPeriod, type Period } from './cli-date.js' import { runOptimize } from './optimize.js' import { registerActCommands } from './act/cli.js' +import { registerGuardCommands } from './guard/cli.js' import { runContextCommand } from './context-tree.js' import { renderCompare } from './compare.js' import { @@ -1555,5 +1556,6 @@ program }) registerActCommands(program) +registerGuardCommands(program) program.parse() diff --git a/tests/guard-hooks.test.ts b/tests/guard-hooks.test.ts new file mode 100644 index 0000000..04f8b16 --- /dev/null +++ b/tests/guard-hooks.test.ts @@ -0,0 +1,276 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { appendFile, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { computeSessionUsage, emptyCache, readCache, writeAllow, writeCache } from '../src/guard/usage.js' +import { runGuardHook, runGuardStatusline } from '../src/guard/hooks.js' +import { writeGuardConfig, DEFAULT_GUARD_CONFIG } from '../src/guard/store.js' +import { buildFlags, matchFlag, writeFlags, type GuardFlags } from '../src/guard/flags.js' +import type { ProjectSummary } from '../src/types.js' + +const roots: string[] = [] +async function tmp(): Promise { + const d = await mkdtemp(join(tmpdir(), 'codeburn-guard-hooks-')) + roots.push(d) + return d +} +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +type Tool = { name: string; input?: Record } +function assistantLine(id: string, o: { inTok?: number; outTok?: number; tools?: Tool[]; ts?: string } = {}): string { + const content: Record[] = [{ type: 'text', text: 'ok' }] + for (const t of o.tools ?? []) content.push({ type: 'tool_use', id: `${t.name}-${id}`, name: t.name, input: t.input ?? {} }) + return JSON.stringify({ + type: 'assistant', + timestamp: o.ts ?? '2026-07-01T00:00:00.000Z', + message: { + model: 'claude-sonnet-4-20250514', + id, + type: 'message', + role: 'assistant', + usage: { input_tokens: o.inTok ?? 1_000_000, output_tokens: o.outTok ?? 200_000, cache_read_input_tokens: 0 }, + content, + }, + }) + '\n' +} + +async function transcript(lines: string[]): Promise { + const dir = await tmp() + const path = join(dir, 'session.jsonl') + await writeFile(path, lines.join(''), 'utf-8') + return path +} + +const SID = 'sess-1' +function hookInput(path: string): string { + return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'PreToolUse', tool_name: 'Bash' }) +} + +describe('incremental session cache', () => { + it('parses only the appended tail and totals match a cold parse', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + expect(r1.resumedFrom).toBe(0) + expect(r1.cache.costUSD).toBeGreaterThan(0) + const offset1 = r1.cache.byteOffset + + await appendFile(path, assistantLine('c') + assistantLine('d'), 'utf-8') + const size = (await stat(path)).size + + const prev = await readCache(SID, base) + const r2 = await computeSessionUsage(prev, path) + + // Bytes-read assertion: the second pass resumed exactly where the first + // stopped and consumed only the appended region (not the whole file). + expect(r2.resumedFrom).toBe(offset1) + expect(r2.cache.byteOffset).toBe(size) + expect(r2.cache.byteOffset - r2.resumedFrom).toBeGreaterThan(0) + expect(r2.resumedFrom).toBeLessThan(size) + + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + expect(r2.cache.costUSD).toBeGreaterThan(r1.cache.costUSD) + }) + + it('resets to a cold parse when the transcript shrinks (rotation)', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const r1 = await computeSessionUsage(emptyCache(SID), path) + await writeCache(r1.cache, base) + + await writeFile(path, assistantLine('z'), 'utf-8') // smaller file, new content + const r2 = await computeSessionUsage(await readCache(SID, base), path) + expect(r2.resumedFrom).toBe(0) + const cold = await computeSessionUsage(emptyCache(SID), path) + expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10) + }) +}) + +describe('budget hook (PreToolUse)', () => { + async function costOf(path: string): Promise { + return (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + } + + it('stays silent below the soft cap', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 2, hardUSD: c * 4 }, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) + + it('warns once on the soft cap, then suppresses the repeat', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.5, hardUSD: c * 10 }, base) + + const first = await runGuardHook('pretooluse', hookInput(path), { base }) + expect(JSON.parse(first).systemMessage).toContain('soft cap') + const second = await runGuardHook('pretooluse', hookInput(path), { base }) + expect(second).toBe('') + }) + + it('blocks on the hard cap with a deny decision, and allow lifts it', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + const c = await costOf(path) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.2, hardUSD: c * 0.5 }, base) + + const blocked = JSON.parse(await runGuardHook('pretooluse', hookInput(path), { base })) + expect(blocked.hookSpecificOutput.hookEventName).toBe('PreToolUse') + expect(blocked.hookSpecificOutput.permissionDecision).toBe('deny') + expect(blocked.hookSpecificOutput.permissionDecisionReason).toContain('guard allow') + + await writeAllow(SID, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) + + it('disables the cap when the threshold is null', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: null, hardUSD: null }, base) + expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('') + }) +}) + +describe('yield checkpoint (Stop)', () => { + function stopInput(path: string): string { + return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'Stop' }) + } + async function withCheckpoint(base: string, path: string): Promise { + const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 0.5 }, base) + } + + it('fires once for an expensive no-edit no-commit session', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a'), assistantLine('b')]) + await withCheckpoint(base, path) + const first = JSON.parse(await runGuardHook('stop', stopInput(path), { base })) + expect(first.systemMessage).toContain('no edits or commits') + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') // once per session + }) + + it('stays silent when cost is below the checkpoint', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD + await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 5 }, base) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) + + it('stays silent when the session made an edit', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a', { tools: [{ name: 'Edit', input: { file_path: '/x' } }] }), assistantLine('b')]) + await withCheckpoint(base, path) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) + + it('stays silent when the session ran git commit', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a', { tools: [{ name: 'Bash', input: { command: 'git commit -m wip' } }] }), assistantLine('b')]) + await withCheckpoint(base, path) + expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') + }) +}) + +describe('session opener (SessionStart)', () => { + it('emits the matching opener text for a flagged project and nothing when stale', async () => { + const base = await tmp() + const projectPath = '/tmp/flagged-project' + const flags: GuardFlags = { generatedAt: new Date().toISOString(), projects: [{ path: projectPath, openers: ['DELIVERABLE OPENER'] }] } + await writeFlags(flags, base) + + const input = JSON.stringify({ session_id: SID, cwd: projectPath, hook_event_name: 'SessionStart', source: 'startup' }) + const out = JSON.parse(await runGuardHook('sessionstart', input, { base })) + expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart') + expect(out.hookSpecificOutput.additionalContext).toBe('DELIVERABLE OPENER') + + // Unflagged cwd -> nothing. + const other = JSON.stringify({ session_id: SID, cwd: '/tmp/other', hook_event_name: 'SessionStart' }) + expect(await runGuardHook('sessionstart', other, { base })).toBe('') + + // Stale flag list (> 7 days) -> nothing. + const stale: GuardFlags = { generatedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), projects: flags.projects } + await writeFlags(stale, base) + expect(await runGuardHook('sessionstart', input, { base })).toBe('') + }) + + it('builds flags from optimize candidate detectors and matches by project path', async () => { + // A project whose only session has real spend but zero edit turns is a + // low-worth candidate; buildFlags should flag its projectPath. + const projects = [lowWorthProject()] + const flags = await buildFlags(projects as unknown as ProjectSummary[]) + expect(flags.projects.length).toBeGreaterThan(0) + expect(matchFlag(flags, '/repo/alpha').length).toBeGreaterThan(0) + expect(matchFlag(flags, '/repo/alpha/src')).toEqual(matchFlag(flags, '/repo/alpha')) // subdir matches + expect(matchFlag(flags, '/repo/beta')).toEqual([]) + }) +}) + +describe('fail-open: malformed stdin', () => { + it('every handler exits with empty output on garbage input', async () => { + const base = await tmp() + for (const ev of ['pretooluse', 'sessionstart', 'stop']) { + expect(await runGuardHook(ev, 'not json {', { base })).toBe('') + expect(await runGuardHook(ev, '', { base })).toBe('') + } + expect(await runGuardHook('unknownevent', JSON.stringify({ session_id: SID }), { base })).toBe('') + expect(await runGuardStatusline('not json {', { base })).toBe('') + }) + + it('handlers stay silent when the transcript path is missing', async () => { + const base = await tmp() + const noPath = JSON.stringify({ session_id: SID, hook_event_name: 'PreToolUse' }) + expect(await runGuardHook('pretooluse', noPath, { base })).toBe('') + expect(await runGuardHook('stop', noPath, { base })).toBe('') + }) +}) + +describe('statusline', () => { + it('prints one line with the session cost', async () => { + const base = await tmp() + const path = await transcript([assistantLine('a')]) + const out = await runGuardStatusline(JSON.stringify({ session_id: SID, transcript_path: path }), { base }) + expect(out.startsWith('codeburn guard $')).toBe(true) + expect(out).not.toContain('\n') + }) +}) + +// A minimal ProjectSummary shaped just enough for findLowWorthCandidates: +// meaningful cost, no delivery command, and no edit turns. +function lowWorthProject(): Record { + const turn = { + userMessage: 'do a thing', + assistantCalls: [{ costUSD: 40, tools: [], bashCommands: [], timestamp: '2026-07-01T00:00:00Z' }], + timestamp: '2026-07-01T00:00:00Z', + sessionId: 's-alpha', + category: 'exploration', + retries: 0, + hasEdits: false, + } + const session = { + sessionId: 's-alpha', + project: 'alpha', + firstTimestamp: '2026-07-01T00:00:00Z', + lastTimestamp: '2026-07-01T01:00:00Z', + totalCostUSD: 40, + totalSavingsUSD: 0, + totalInputTokens: 100, totalOutputTokens: 100, totalReasoningTokens: 0, + totalCacheReadTokens: 0, totalCacheWriteTokens: 0, + apiCalls: 1, + turns: [turn], + modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {}, + categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {}, + } + return { + project: 'alpha', + projectPath: '/repo/alpha', + sessions: [session], + totalCostUSD: 40, totalSavingsUSD: 0, totalApiCalls: 1, totalProxiedCostUSD: 0, + } +} diff --git a/tests/guard-install.test.ts b/tests/guard-install.test.ts new file mode 100644 index 0000000..65b67a5 --- /dev/null +++ b/tests/guard-install.test.ts @@ -0,0 +1,144 @@ +import { afterAll, describe, expect, it } from 'vitest' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runAction } from '../src/act/apply.js' +import { readRecords } from '../src/act/journal.js' +import { + buildInstall, buildUninstall, inspectInstall, settingsPathFor, + GUARD_HOOK_PREFIX, GUARD_STATUSLINE_COMMAND, +} from '../src/guard/settings.js' + +const roots: string[] = [] +async function makeRoot(): Promise<{ settings: string; actionsDir: string }> { + const root = await mkdtemp(join(tmpdir(), 'codeburn-guard-install-')) + roots.push(root) + await mkdir(join(root, '.claude'), { recursive: true }) + return { settings: join(root, '.claude', 'settings.json'), actionsDir: join(root, 'actions') } +} +afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) }) + +function canonical(obj: unknown): string { + return JSON.stringify(obj, null, 2) + '\n' +} +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf-8')) +} + +describe('settingsPathFor', () => { + it('resolves project (default) and global scopes', () => { + expect(settingsPathFor({ cwd: '/repo' })).toBe(join('/repo', '.claude', 'settings.json')) + expect(settingsPathFor({ project: '/x' })).toBe(join('/x', '.claude', 'settings.json')) + expect(settingsPathFor({ global: true })).toContain(join('.claude', 'settings.json')) + }) +}) + +describe('guard install', () => { + it('creates settings with all three hook events when none exists', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + const built = buildInstall(settings) + expect(built.plan).not.toBeNull() + expect(built.plan!.kind).toBe('guard-install') + await runAction(built.plan!, actionsDir) + + const doc = await readJson(settings) + expect(Object.keys(doc.hooks).sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop']) + expect(doc.hooks.PreToolUse[0].hooks[0].command).toBe(`${GUARD_HOOK_PREFIX} pretooluse`) + expect(doc.hooks.SessionStart[0].matcher).toBe('startup') + expect(doc.hooks.Stop[0].matcher).toBeUndefined() // Stop takes no matcher + // journaled + undoable + const records = await readRecords(actionsDir) + expect(records[0]!.kind).toBe('guard-install') + }) + + it('appends to pre-existing user hooks without disturbing them', async () => { + const { settings, actionsDir } = await makeRoot() + const original = canonical({ + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + }) + await writeFile(settings, original) + + await runAction(buildInstall(settings).plan!, actionsDir) + const doc = await readJson(settings) + const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command)) + expect(commands).toContain('my-own-hook.sh') + expect(commands).toContain(`${GUARD_HOOK_PREFIX} pretooluse`) + }) + + it('is idempotent: re-install adds nothing', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + await runAction(buildInstall(settings).plan!, actionsDir) + const again = buildInstall(settings) + expect(again.plan).toBeNull() + expect(again.notes.join(' ')).toContain('already present') + }) + + it('configures the statusline only when none exists', async () => { + const { settings, actionsDir } = await makeRoot() + await rm(settings, { force: true }) + await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir) + expect((await readJson(settings)).statusLine.command).toBe(GUARD_STATUSLINE_COMMAND) + + // A pre-existing statusline is refused, not overwritten. + const { settings: s2, actionsDir: a2 } = await makeRoot() + await writeFile(s2, canonical({ statusLine: { type: 'command', command: 'my-statusline.sh' } })) + const built = buildInstall(s2, { statusline: true }) + expect(built.notes.join(' ')).toContain('statusline is already configured') + await runAction(built.plan!, a2) + expect((await readJson(s2)).statusLine.command).toBe('my-statusline.sh') + }) +}) + +describe('guard uninstall', () => { + it('removes exactly our entries and restores byte-identical settings', async () => { + const { settings, actionsDir } = await makeRoot() + const original = canonical({ + permissions: { allow: [] }, + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + }) + await writeFile(settings, original) + + await runAction(buildInstall(settings, { statusline: true }).plan!, actionsDir) + // ours are present now + const mid = inspectInstall(settings) + expect(mid.hooks.sort()).toEqual(['PreToolUse', 'SessionStart', 'Stop']) + expect(mid.statusline).toBe(true) + + await runAction(buildUninstall(settings).plan!, actionsDir) + expect(await readFile(settings, 'utf-8')).toBe(original) // byte-identical + const after = inspectInstall(settings) + expect(after.hooks).toEqual([]) + expect(after.statusline).toBe(false) + }) + + it('preserves a user hook that shares the PreToolUse array', async () => { + const { settings, actionsDir } = await makeRoot() + await writeFile(settings, canonical({ + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'my-own-hook.sh' }] }] }, + })) + await runAction(buildInstall(settings).plan!, actionsDir) + await runAction(buildUninstall(settings).plan!, actionsDir) + const doc = await readJson(settings) + const commands = doc.hooks.PreToolUse.flatMap((g: any) => g.hooks.map((h: any) => h.command)) + expect(commands).toEqual(['my-own-hook.sh']) + }) + + it('reports nothing to do on a settings file without our hooks', async () => { + const { settings } = await makeRoot() + await writeFile(settings, canonical({ hooks: {} })) + const built = buildUninstall(settings) + expect(built.plan).toBeNull() + expect(built.notes.join(' ')).toContain('no codeburn guard hooks') + }) + + it('reports nothing to do when the settings file is absent', async () => { + const { settings } = await makeRoot() + await rm(settings, { force: true }) + expect(existsSync(settings)).toBe(false) + const built = buildUninstall(settings) + expect(built.plan).toBeNull() + }) +})