import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { HookOutput } from '../src/types.ts'; function clearEnv(): void { delete process.env.SAFETY_NET_STRICT; delete process.env.SAFETY_NET_PARANOID; delete process.env.SAFETY_NET_PARANOID_RM; delete process.env.SAFETY_NET_PARANOID_INTERPRETERS; delete process.env.CLAUDE_SETTINGS_PATH; } describe('CLI wrapper output format', () => { test('blocked command produces correct JSON structure', async () => { const input = JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'git reset --hard', }, }); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--claude-code'], { stdin: new Blob([input]), stdout: 'pipe', stderr: 'pipe', }); const output = await new Response(proc.stdout).text(); await proc.exited; const parsed = JSON.parse(output) as HookOutput; expect(parsed.hookSpecificOutput).toBeDefined(); expect(parsed.hookSpecificOutput.hookEventName).toBe('PreToolUse'); expect(parsed.hookSpecificOutput.permissionDecision).toBe('deny'); expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain('BLOCKED by Safety Net'); expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain('git reset --hard'); }); test('allowed command produces no output', async () => { const input = JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'git status', }, }); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--claude-code'], { stdin: new Blob([input]), stdout: 'pipe', stderr: 'pipe', }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe(''); expect(exitCode).toBe(0); }); test('non-Bash tool produces no output', async () => { const input = JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Read', tool_input: { path: '/some/file.txt', }, }); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--claude-code'], { stdin: new Blob([input]), stdout: 'pipe', stderr: 'pipe', }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe(''); expect(exitCode).toBe(0); }); }); describe('--statusline flag', () => { // Create a temp settings file with plugin enabled to test statusline modes // When settings file doesn't exist, isPluginEnabled() defaults to false (disabled) let tempDir: string; let enabledSettingsPath: string; beforeEach(async () => { clearEnv(); tempDir = await mkdtemp(join(tmpdir(), 'safety-net-statusline-')); enabledSettingsPath = join(tempDir, 'settings.json'); await writeFile( enabledSettingsPath, JSON.stringify({ enabledPlugins: { 'safety-net@cc-marketplace': true }, }), ); process.env.CLAUDE_SETTINGS_PATH = enabledSettingsPath; }); afterEach(async () => { clearEnv(); await rm(tempDir, { recursive: true, force: true }); }); // 1. Enabled with no mode flags → ✅ test('outputs enabled status with no env flags', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net ✅'); expect(exitCode).toBe(0); }); // 3. Enabled + Strict → 🔒 (replaces ✅) test('shows strict mode emoji when SAFETY_NET_STRICT=1', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1' }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🔒'); expect(exitCode).toBe(0); }); // 4. Enabled + Paranoid → 👁️ test('shows paranoid emoji when SAFETY_NET_PARANOID=1', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_PARANOID: '1' }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 👁️'); expect(exitCode).toBe(0); }); // 7. Enabled + Strict + Paranoid → 🔒👁️ (concatenated) test('shows strict + paranoid emojis when both set', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🔒👁️'); expect(exitCode).toBe(0); }); // 5. Enabled + Paranoid RM only → 🗑️ test('shows rm emoji when SAFETY_NET_PARANOID_RM=1 only', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_PARANOID_RM: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🗑️'); expect(exitCode).toBe(0); }); // 8. Enabled + Strict + Paranoid RM only → 🔒🗑️ test('shows strict + rm emoji when STRICT and PARANOID_RM set', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID_RM: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🔒🗑️'); expect(exitCode).toBe(0); }); // 6. Enabled + Paranoid Interpreters only → 🐚 test('shows interpreters emoji when SAFETY_NET_PARANOID_INTERPRETERS=1', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_PARANOID_INTERPRETERS: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🐚'); expect(exitCode).toBe(0); }); // 9. Enabled + Strict + Paranoid Interpreters only → 🔒🐚 test('shows strict + interpreters emoji', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID_INTERPRETERS: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🔒🐚'); expect(exitCode).toBe(0); }); // 4/7. PARANOID_RM + PARANOID_INTERPRETERS together → 👁️ (same as PARANOID) test('shows paranoid emoji when both PARANOID_RM and PARANOID_INTERPRETERS set', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_PARANOID_RM: '1', SAFETY_NET_PARANOID_INTERPRETERS: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 👁️'); expect(exitCode).toBe(0); }); // 7. Strict + PARANOID_RM + PARANOID_INTERPRETERS → 🔒👁️ test('shows strict + paranoid when all three flags set', async () => { const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID_RM: '1', SAFETY_NET_PARANOID_INTERPRETERS: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🔒👁️'); expect(exitCode).toBe(0); }); }); describe('--statusline enabled/disabled detection', () => { let tempDir: string; beforeEach(async () => { clearEnv(); tempDir = await mkdtemp(join(tmpdir(), 'safety-net-test-')); }); afterEach(async () => { clearEnv(); await rm(tempDir, { recursive: true, force: true }); }); test('shows ❌ when plugin is disabled in settings', async () => { const settingsPath = join(tempDir, 'settings.json'); await writeFile( settingsPath, JSON.stringify({ enabledPlugins: { 'safety-net@cc-marketplace': false, }, }), ); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net ❌'); expect(exitCode).toBe(0); }); test('shows ✅ when plugin is enabled in settings', async () => { const settingsPath = join(tempDir, 'settings.json'); await writeFile( settingsPath, JSON.stringify({ enabledPlugins: { 'safety-net@cc-marketplace': true, }, }), ); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net ✅'); expect(exitCode).toBe(0); }); test('shows ❌ when settings file does not exist (default disabled)', async () => { const settingsPath = join(tempDir, 'nonexistent.json'); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net ❌'); expect(exitCode).toBe(0); }); test('shows ❌ when enabledPlugins key is missing (default disabled)', async () => { const settingsPath = join(tempDir, 'settings.json'); await writeFile(settingsPath, JSON.stringify({ model: 'opus' })); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net ❌'); expect(exitCode).toBe(0); }); test('disabled plugin ignores mode flags (shows ❌ only)', async () => { const settingsPath = join(tempDir, 'settings.json'); await writeFile( settingsPath, JSON.stringify({ enabledPlugins: { 'safety-net@cc-marketplace': false, }, }), ); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath, SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net ❌'); expect(exitCode).toBe(0); }); test('enabled plugin with modes shows mode emojis', async () => { const settingsPath = join(tempDir, 'settings.json'); await writeFile( settingsPath, JSON.stringify({ enabledPlugins: { 'safety-net@cc-marketplace': true, }, }), ); const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { stdout: 'pipe', stderr: 'pipe', env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath, SAFETY_NET_STRICT: '1', }, }); const output = await new Response(proc.stdout).text(); const exitCode = await proc.exited; expect(output.trim()).toBe('🛡️ Safety Net 🔒'); expect(exitCode).toBe(0); }); });