diff --git a/AGENTS.md b/AGENTS.md index fd22b3e..4efb9ed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ describe('git rules', () => { | Variable | Effect | |----------|--------| +| `SAFETY_NET_ASK=1` | Prompt user for confirmation instead of blocking | | `SAFETY_NET_STRICT=1` | Fail-closed on unparseable hook input/commands | | `SAFETY_NET_PARANOID=1` | Enable all paranoid checks (rm + interpreters) | | `SAFETY_NET_PARANOID_RM=1` | Block non-temp `rm -rf` even within cwd | diff --git a/CLAUDE.md b/CLAUDE.md index ffd2c7e..50da875 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,7 @@ When committing changes to files in `commands/`, `hooks/`, or `.opencode/`, use ## Environment Variables +- `SAFETY_NET_ASK=1`: Ask mode (prompt user for confirmation instead of blocking) - `SAFETY_NET_STRICT=1`: Strict mode (fail-closed on unparseable hook input/commands) - `SAFETY_NET_PARANOID=1`: Paranoid mode (enables all paranoid checks) - `SAFETY_NET_PARANOID_RM=1`: Paranoid rm (blocks non-temp `rm -rf` even within cwd) diff --git a/README.md b/README.md index cb59915..4ee84f3 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ A Claude Code plugin that acts as a safety net, catching destructive git and fil - [Examples](#examples) - [Error Handling](#error-handling) - [Advanced Features](#advanced-features) + - [Ask Mode](#ask-mode) - [Strict Mode](#strict-mode) - [Paranoid Mode](#paranoid-mode) - [Shell Wrapper Detection](#shell-wrapper-detection) @@ -296,6 +297,7 @@ The status line displays different emojis based on the current configuration: |--------|---------|---------| | Plugin disabled | `🛡️ Safety Net ❌` | Safety Net plugin is not enabled | | Default mode | `🛡️ Safety Net ✅` | Protection active with default settings | +| Ask mode | `🛡️ Safety Net ❓` | `SAFETY_NET_ASK=1` — prompts user instead of blocking | | Strict mode | `🛡️ Safety Net 🔒` | `SAFETY_NET_STRICT=1` — fail-closed on unparseable commands | | Paranoid mode | `🛡️ Safety Net 👁️` | `SAFETY_NET_PARANOID=1` — all paranoid checks enabled | | Paranoid RM only | `🛡️ Safety Net 🗑️` | `SAFETY_NET_PARANOID_RM=1` — blocks `rm -rf` even within cwd | @@ -601,6 +603,23 @@ Command: git add -A ## Advanced Features +### Ask Mode + +By default, dangerous commands are blocked outright. Enable ask mode to prompt the user +for confirmation instead, allowing them to approve or deny each flagged command +interactively: + +```bash +export SAFETY_NET_ASK=1 +``` + +When a dangerous command is detected, the user sees the Safety Net warning and can choose +to proceed or cancel. This is useful when you want awareness without hard blocks. + +> **Note:** Ask mode is currently supported in Claude Code only. Gemini CLI, OpenCode, +> and Copilot CLI do not support interactive confirmation and will continue to block outright. +> Strict mode (`SAFETY_NET_STRICT=1`) overrides ask mode — all blocks are hard-denied when strict is active. + ### Strict Mode By default, unparseable commands are allowed through. Enable strict mode to fail-closed diff --git a/dist/bin/cc-safety-net.js b/dist/bin/cc-safety-net.js index 733f3c5..5ad7ef3 100755 --- a/dist/bin/cc-safety-net.js +++ b/dist/bin/cc-safety-net.js @@ -852,6 +852,11 @@ function getConfigInfo(cwd, options) { // src/bin/doctor/environment.ts var ENV_VARS = [ + { + name: "SAFETY_NET_ASK", + description: "Prompt user instead of blocking", + defaultBehavior: "off" + }, { name: "SAFETY_NET_STRICT", description: "Fail-closed on unparseable commands", @@ -5330,6 +5335,7 @@ function printHelp() { lines.push(`${INDENT}${PROGRAM_NAME} --help Show help for a specific command`); lines.push(""); lines.push("ENVIRONMENT VARIABLES:"); + lines.push(`${INDENT}SAFETY_NET_ASK=1 Prompt user instead of blocking`); lines.push(`${INDENT}SAFETY_NET_STRICT=1 Fail-closed on unparseable commands`); lines.push(`${INDENT}SAFETY_NET_PARANOID=1 Enable all paranoid checks`); lines.push(`${INDENT}SAFETY_NET_PARANOID_RM=1 Block non-temp rm -rf within cwd`); @@ -5404,10 +5410,11 @@ function redactSecrets(text) { // src/core/format.ts function formatBlockedMessage(input) { - const { reason, command, segment } = input; + const { reason, command, segment, askMode } = input; const maxLen = input.maxLen ?? 200; const redact = input.redact ?? ((t) => t); - let message = `BLOCKED by Safety Net + const header = askMode ? "FLAGGED by Safety Net" : "BLOCKED by Safety Net"; + let message = `${header} Reason: ${reason}`; if (command) { @@ -5422,9 +5429,15 @@ Command: ${excerpt(safeCommand, maxLen)}`; Segment: ${excerpt(safeSegment, maxLen)}`; } - message += ` + if (askMode) { + message += ` + +This command may be destructive. Approve to proceed, or deny to cancel.`; + } else { + message += ` If this operation is truly needed, ask the user for explicit permission and have them run the command manually.`; + } return message; } function excerpt(text, maxLen) { @@ -5432,17 +5445,18 @@ function excerpt(text, maxLen) { } // src/bin/hooks/claude-code.ts -function outputDeny(reason, command, segment) { +function outputDecision(decision, reason, command, segment) { const message = formatBlockedMessage({ reason, command, segment, - redact: redactSecrets + redact: redactSecrets, + askMode: decision === "ask" }); const output = { hookSpecificOutput: { hookEventName: "PreToolUse", - permissionDecision: "deny", + permissionDecision: decision, permissionDecisionReason: message } }; @@ -5462,7 +5476,7 @@ async function runClaudeCodeHook() { input = JSON.parse(inputText); } catch { if (envTruthy("SAFETY_NET_STRICT")) { - outputDeny("Failed to parse hook input JSON (strict mode)"); + outputDecision("deny", "Failed to parse hook input JSON (strict mode)"); } return; } @@ -5475,6 +5489,7 @@ async function runClaudeCodeHook() { } const cwd = input.cwd ?? process.cwd(); const strict = envTruthy("SAFETY_NET_STRICT"); + const askMode = envTruthy("SAFETY_NET_ASK"); const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); @@ -5491,7 +5506,7 @@ async function runClaudeCodeHook() { if (sessionId) { writeAuditLog(sessionId, command, result.segment, result.reason, cwd); } - outputDeny(result.reason, command, result.segment); + outputDecision(askMode && !strict ? "ask" : "deny", result.reason, command, result.segment); } } @@ -5684,10 +5699,14 @@ async function printStatusline() { status = "\uD83D\uDEE1️ Safety Net ❌"; } else { const strict = envTruthy("SAFETY_NET_STRICT"); + const askMode = envTruthy("SAFETY_NET_ASK"); const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); let modeEmojis = ""; + if (askMode) { + modeEmojis += "❓"; + } if (strict) { modeEmojis += "\uD83D\uDD12"; } diff --git a/dist/core/format.d.ts b/dist/core/format.d.ts index 8e8ce7b..f75a6ed 100644 --- a/dist/core/format.d.ts +++ b/dist/core/format.d.ts @@ -5,6 +5,8 @@ export interface FormatBlockedMessageInput { segment?: string; maxLen?: number; redact?: RedactFn; + /** When true, formats the message as a confirmation prompt instead of a hard block. */ + askMode?: boolean; } export declare function formatBlockedMessage(input: FormatBlockedMessageInput): string; export {}; diff --git a/dist/index.js b/dist/index.js index e338a0b..3357665 100644 --- a/dist/index.js +++ b/dist/index.js @@ -2752,10 +2752,11 @@ function envTruthy(name) { // src/core/format.ts function formatBlockedMessage(input) { - const { reason, command, segment } = input; + const { reason, command, segment, askMode } = input; const maxLen = input.maxLen ?? 200; const redact = input.redact ?? ((t) => t); - let message = `BLOCKED by Safety Net + const header = askMode ? "FLAGGED by Safety Net" : "BLOCKED by Safety Net"; + let message = `${header} Reason: ${reason}`; if (command) { @@ -2770,9 +2771,15 @@ Command: ${excerpt(safeCommand, maxLen)}`; Segment: ${excerpt(safeSegment, maxLen)}`; } - message += ` + if (askMode) { + message += ` + +This command may be destructive. Approve to proceed, or deny to cancel.`; + } else { + message += ` If this operation is truly needed, ask the user for explicit permission and have them run the command manually.`; + } return message; } function excerpt(text, maxLen) { diff --git a/dist/types.d.ts b/dist/types.d.ts index b6b3a3d..cbf8c5a 100644 --- a/dist/types.d.ts +++ b/dist/types.d.ts @@ -53,7 +53,7 @@ export interface HookInput { export interface HookOutput { hookSpecificOutput: { hookEventName: string; - permissionDecision: 'allow' | 'deny'; + permissionDecision: 'allow' | 'deny' | 'ask'; permissionDecisionReason?: string; }; } diff --git a/src/bin/doctor/environment.ts b/src/bin/doctor/environment.ts index 3a637e8..0825cf5 100644 --- a/src/bin/doctor/environment.ts +++ b/src/bin/doctor/environment.ts @@ -9,6 +9,11 @@ const ENV_VARS: Array<{ description: string; defaultBehavior: string; }> = [ + { + name: 'SAFETY_NET_ASK', + description: 'Prompt user instead of blocking', + defaultBehavior: 'off', + }, { name: 'SAFETY_NET_STRICT', description: 'Fail-closed on unparseable commands', diff --git a/src/bin/help.ts b/src/bin/help.ts index dd87183..79b644f 100644 --- a/src/bin/help.ts +++ b/src/bin/help.ts @@ -109,6 +109,7 @@ export function printHelp(): void { // Environment variables lines.push('ENVIRONMENT VARIABLES:'); + lines.push(`${INDENT}SAFETY_NET_ASK=1 Prompt user instead of blocking`); lines.push(`${INDENT}SAFETY_NET_STRICT=1 Fail-closed on unparseable commands`); lines.push(`${INDENT}SAFETY_NET_PARANOID=1 Enable all paranoid checks`); lines.push(`${INDENT}SAFETY_NET_PARANOID_RM=1 Block non-temp rm -rf within cwd`); diff --git a/src/bin/hooks/claude-code.ts b/src/bin/hooks/claude-code.ts index 187e7cc..fe28528 100644 --- a/src/bin/hooks/claude-code.ts +++ b/src/bin/hooks/claude-code.ts @@ -4,18 +4,24 @@ import { envTruthy } from '@/core/env'; import { formatBlockedMessage } from '@/core/format'; import type { HookInput, HookOutput } from '@/types'; -function outputDeny(reason: string, command?: string, segment?: string): void { +function outputDecision( + decision: 'deny' | 'ask', + reason: string, + command?: string, + segment?: string, +): void { const message = formatBlockedMessage({ reason, command, segment, redact: redactSecrets, + askMode: decision === 'ask', }); const output: HookOutput = { hookSpecificOutput: { hookEventName: 'PreToolUse', - permissionDecision: 'deny', + permissionDecision: decision, permissionDecisionReason: message, }, }; @@ -41,7 +47,7 @@ export async function runClaudeCodeHook(): Promise { input = JSON.parse(inputText) as HookInput; } catch { if (envTruthy('SAFETY_NET_STRICT')) { - outputDeny('Failed to parse hook input JSON (strict mode)'); + outputDecision('deny', 'Failed to parse hook input JSON (strict mode)'); } return; } @@ -57,6 +63,7 @@ export async function runClaudeCodeHook(): Promise { const cwd = input.cwd ?? process.cwd(); const strict = envTruthy('SAFETY_NET_STRICT'); + const askMode = envTruthy('SAFETY_NET_ASK'); const paranoidAll = envTruthy('SAFETY_NET_PARANOID'); const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM'); const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS'); @@ -76,6 +83,6 @@ export async function runClaudeCodeHook(): Promise { if (sessionId) { writeAuditLog(sessionId, command, result.segment, result.reason, cwd); } - outputDeny(result.reason, command, result.segment); + outputDecision(askMode && !strict ? 'ask' : 'deny', result.reason, command, result.segment); } } diff --git a/src/bin/statusline.ts b/src/bin/statusline.ts index 6906184..b58526e 100644 --- a/src/bin/statusline.ts +++ b/src/bin/statusline.ts @@ -80,12 +80,18 @@ export async function printStatusline(): Promise { status = '🛡️ Safety Net ❌'; } else { const strict = envTruthy('SAFETY_NET_STRICT'); + const askMode = envTruthy('SAFETY_NET_ASK'); const paranoidAll = envTruthy('SAFETY_NET_PARANOID'); const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM'); const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS'); let modeEmojis = ''; + // Ask mode: ❓ + if (askMode) { + modeEmojis += '❓'; + } + // Strict mode: 🔒 if (strict) { modeEmojis += '🔒'; diff --git a/src/core/format.ts b/src/core/format.ts index e391bab..57694e3 100644 --- a/src/core/format.ts +++ b/src/core/format.ts @@ -6,14 +6,17 @@ export interface FormatBlockedMessageInput { segment?: string; maxLen?: number; redact?: RedactFn; + /** When true, formats the message as a confirmation prompt instead of a hard block. */ + askMode?: boolean; } export function formatBlockedMessage(input: FormatBlockedMessageInput): string { - const { reason, command, segment } = input; + const { reason, command, segment, askMode } = input; const maxLen = input.maxLen ?? 200; const redact = input.redact ?? ((t: string) => t); - let message = `BLOCKED by Safety Net\n\nReason: ${reason}`; + const header = askMode ? 'FLAGGED by Safety Net' : 'BLOCKED by Safety Net'; + let message = `${header}\n\nReason: ${reason}`; if (command) { const safeCommand = redact(command); @@ -25,8 +28,12 @@ export function formatBlockedMessage(input: FormatBlockedMessageInput): string { message += `\n\nSegment: ${excerpt(safeSegment, maxLen)}`; } - message += - '\n\nIf this operation is truly needed, ask the user for explicit permission and have them run the command manually.'; + if (askMode) { + message += '\n\nThis command may be destructive. Approve to proceed, or deny to cancel.'; + } else { + message += + '\n\nIf this operation is truly needed, ask the user for explicit permission and have them run the command manually.'; + } return message; } diff --git a/src/types.ts b/src/types.ts index 62878e4..0ace435 100644 --- a/src/types.ts +++ b/src/types.ts @@ -59,7 +59,7 @@ export interface HookInput { export interface HookOutput { hookSpecificOutput: { hookEventName: string; - permissionDecision: 'allow' | 'deny'; + permissionDecision: 'allow' | 'deny' | 'ask'; permissionDecisionReason?: string; }; } diff --git a/tests/bin/cli-statusline.test.ts b/tests/bin/cli-statusline.test.ts index 95d69ff..3763e46 100644 --- a/tests/bin/cli-statusline.test.ts +++ b/tests/bin/cli-statusline.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; function clearEnv(): void { + delete process.env.SAFETY_NET_ASK; delete process.env.SAFETY_NET_STRICT; delete process.env.SAFETY_NET_PARANOID; delete process.env.SAFETY_NET_PARANOID_RM; @@ -50,6 +51,41 @@ describe('--statusline flag', () => { expect(exitCode).toBe(0); }); + // Ask mode → ❓ + test('shows ask mode emoji when SAFETY_NET_ASK=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_ASK: '1' }, + }); + + const output = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + + expect(output.trim()).toBe('🛡️ Safety Net ❓'); + expect(exitCode).toBe(0); + }); + + // Ask + Strict → ❓🔒 + test('shows ask + strict 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_ASK: '1', + 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); + }); + // 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'], { diff --git a/tests/bin/hooks/claude-code-hook.test.ts b/tests/bin/hooks/claude-code-hook.test.ts index c1edab0..b41c591 100644 --- a/tests/bin/hooks/claude-code-hook.test.ts +++ b/tests/bin/hooks/claude-code-hook.test.ts @@ -24,6 +24,76 @@ describe('Claude Code hook', () => { }); }); + describe('ask mode', () => { + test('ask mode returns ask decision instead of deny', async () => { + const input = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { + command: 'git reset --hard', + }, + }; + + const { stdout, exitCode } = await runClaudeCodeHook(input, { + SAFETY_NET_ASK: '1', + }); + + const parsed = JSON.parse(stdout); + expect(exitCode).toBe(0); + expect(parsed.hookSpecificOutput.permissionDecision).toBe('ask'); + expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain('FLAGGED by Safety Net'); + }); + + test('ask mode still allows safe commands', async () => { + const input = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { + command: 'git status', + }, + }; + + const { stdout, exitCode } = await runClaudeCodeHook(input, { + SAFETY_NET_ASK: '1', + }); + + expect(stdout).toBe(''); + expect(exitCode).toBe(0); + }); + + test('strict JSON parse failures still deny even in ask mode', async () => { + const { stdout, exitCode } = await runClaudeCodeHook('{invalid json', { + SAFETY_NET_ASK: '1', + SAFETY_NET_STRICT: '1', + }); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain('BLOCKED by Safety Net'); + }); + + test('strict command parse failures still deny even in ask mode', async () => { + const input = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { + command: "git reset --hard 'unterminated", + }, + }; + + const { stdout, exitCode } = await runClaudeCodeHook(input, { + SAFETY_NET_ASK: '1', + SAFETY_NET_STRICT: '1', + }); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(stdout); + expect(parsed.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(parsed.hookSpecificOutput.permissionDecisionReason).toContain('BLOCKED by Safety Net'); + }); + }); + describe('allowed commands', () => { test('allowed command produces no output', async () => { const input = { diff --git a/tests/bin/hooks/copilot-cli-hook.test.ts b/tests/bin/hooks/copilot-cli-hook.test.ts index 214a55c..2df2e98 100644 --- a/tests/bin/hooks/copilot-cli-hook.test.ts +++ b/tests/bin/hooks/copilot-cli-hook.test.ts @@ -20,6 +20,25 @@ describe('Copilot CLI hook', () => { }); }); + describe('ask mode ignored', () => { + test('ask mode still denies on Copilot CLI (unsupported)', async () => { + const input = { + timestamp: Date.now(), + cwd: process.cwd(), + toolName: 'bash', + toolArgs: JSON.stringify({ command: 'rm -rf /' }), + }; + + const { stdout, exitCode } = await runCopilotHook(input, { + SAFETY_NET_ASK: '1', + }); + + expect(exitCode).toBe(0); + const output = JSON.parse(stdout); + expect(output.permissionDecision).toBe('deny'); + }); + }); + describe('allowed commands', () => { test('allows safe commands (no output)', async () => { const input = { diff --git a/tests/core/format.test.ts b/tests/core/format.test.ts index 95f0473..f990e9d 100644 --- a/tests/core/format.test.ts +++ b/tests/core/format.test.ts @@ -102,4 +102,18 @@ describe('formatBlockedMessage', () => { expect(result).toContain('Segment: echo ***'); expect(result).not.toContain('password'); }); + + test('ask mode uses FLAGGED header and confirmation footer', () => { + const result = formatBlockedMessage({ reason: 'test reason', askMode: true }); + expect(result).toContain('FLAGGED by Safety Net'); + expect(result).not.toContain('BLOCKED by Safety Net'); + expect(result).toContain('Approve to proceed'); + }); + + test('default mode uses BLOCKED header', () => { + const result = formatBlockedMessage({ reason: 'test reason' }); + expect(result).toContain('BLOCKED by Safety Net'); + expect(result).not.toContain('FLAGGED by Safety Net'); + expect(result).toContain('ask the user'); + }); });