From fafa8d881c76f80bc9cc2c067c0813f0348a474b Mon Sep 17 00:00:00 2001 From: c2keesey Date: Thu, 2 Apr 2026 15:55:41 -0700 Subject: [PATCH 1/2] feat: add SAFETY_NET_ASK mode to prompt user instead of blocking When SAFETY_NET_ASK=1 is set, dangerous commands return 'ask' instead of 'deny', prompting the user for confirmation rather than blocking outright. Supported in Claude Code and Copilot CLI hooks. Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 1 + CLAUDE.md | 1 + README.md | 19 +++++++++ dist/bin/cc-safety-net.js | 49 ++++++++++++++++------- dist/core/format.d.ts | 2 + dist/index.js | 13 ++++-- dist/types.d.ts | 2 +- src/bin/doctor/environment.ts | 5 +++ src/bin/help.ts | 1 + src/bin/hooks/claude-code.ts | 15 +++++-- src/bin/hooks/copilot-cli.ts | 17 +++++--- src/bin/statusline.ts | 6 +++ src/core/format.ts | 15 +++++-- src/types.ts | 2 +- tests/bin/cli-statusline.test.ts | 16 ++++++++ tests/bin/hooks/claude-code-hook.test.ts | 50 ++++++++++++++++++++++++ tests/bin/hooks/copilot-cli-hook.test.ts | 37 ++++++++++++++++++ tests/core/format.test.ts | 14 +++++++ 18 files changed, 233 insertions(+), 32 deletions(-) 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..d35c304 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 supported in Claude Code and GitHub Copilot CLI. Gemini CLI and +> OpenCode do not support interactive confirmation and will continue to block outright. +> Strict mode parse failures (`SAFETY_NET_STRICT=1`) always hard-block regardless of ask mode. + ### 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..a285ee6 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,20 +5506,21 @@ async function runClaudeCodeHook() { if (sessionId) { writeAuditLog(sessionId, command, result.segment, result.reason, cwd); } - outputDeny(result.reason, command, result.segment); + outputDecision(askMode ? "ask" : "deny", result.reason, command, result.segment); } } // src/bin/hooks/copilot-cli.ts -function outputCopilotDeny(reason, command, segment) { +function outputCopilotDecision(decision, reason, command, segment) { const message = formatBlockedMessage({ reason, command, segment, - redact: redactSecrets + redact: redactSecrets, + askMode: decision === "ask" }); const output = { - permissionDecision: "deny", + permissionDecision: decision, permissionDecisionReason: message }; console.log(JSON.stringify(output)); @@ -5523,7 +5539,7 @@ async function runCopilotCliHook() { input = JSON.parse(inputText); } catch { if (envTruthy("SAFETY_NET_STRICT")) { - outputCopilotDeny("Failed to parse hook input JSON (strict mode)"); + outputCopilotDecision("deny", "Failed to parse hook input JSON (strict mode)"); } return; } @@ -5535,7 +5551,7 @@ async function runCopilotCliHook() { toolArgs = JSON.parse(input.toolArgs); } catch { if (envTruthy("SAFETY_NET_STRICT")) { - outputCopilotDeny("Failed to parse toolArgs JSON (strict mode)"); + outputCopilotDecision("deny", "Failed to parse toolArgs JSON (strict mode)"); } return; } @@ -5545,6 +5561,7 @@ async function runCopilotCliHook() { } 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"); @@ -5559,7 +5576,7 @@ async function runCopilotCliHook() { if (result) { const sessionId = `copilot-${input.timestamp ?? Date.now()}`; writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - outputCopilotDeny(result.reason, command, result.segment); + outputCopilotDecision(askMode ? "ask" : "deny", result.reason, command, result.segment); } } @@ -5684,10 +5701,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..f420185 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 ? 'ask' : 'deny', result.reason, command, result.segment); } } diff --git a/src/bin/hooks/copilot-cli.ts b/src/bin/hooks/copilot-cli.ts index cdc1305..f078ac9 100644 --- a/src/bin/hooks/copilot-cli.ts +++ b/src/bin/hooks/copilot-cli.ts @@ -4,16 +4,22 @@ import { envTruthy } from '@/core/env'; import { formatBlockedMessage } from '@/core/format'; import type { CopilotCliHookInput, CopilotCliHookOutput } from '@/types'; -function outputCopilotDeny(reason: string, command?: string, segment?: string): void { +function outputCopilotDecision( + decision: 'deny' | 'ask', + reason: string, + command?: string, + segment?: string, +): void { const message = formatBlockedMessage({ reason, command, segment, redact: redactSecrets, + askMode: decision === 'ask', }); const output: CopilotCliHookOutput = { - permissionDecision: 'deny', + permissionDecision: decision, permissionDecisionReason: message, }; @@ -38,7 +44,7 @@ export async function runCopilotCliHook(): Promise { input = JSON.parse(inputText) as CopilotCliHookInput; } catch { if (envTruthy('SAFETY_NET_STRICT')) { - outputCopilotDeny('Failed to parse hook input JSON (strict mode)'); + outputCopilotDecision('deny', 'Failed to parse hook input JSON (strict mode)'); } return; } @@ -54,7 +60,7 @@ export async function runCopilotCliHook(): Promise { toolArgs = JSON.parse(input.toolArgs) as { command?: string }; } catch { if (envTruthy('SAFETY_NET_STRICT')) { - outputCopilotDeny('Failed to parse toolArgs JSON (strict mode)'); + outputCopilotDecision('deny', 'Failed to parse toolArgs JSON (strict mode)'); } return; } @@ -66,6 +72,7 @@ export async function runCopilotCliHook(): 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'); @@ -84,6 +91,6 @@ export async function runCopilotCliHook(): Promise { // Generate a session ID from timestamp for audit logging const sessionId = `copilot-${input.timestamp ?? Date.now()}`; writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - outputCopilotDeny(result.reason, command, result.segment); + outputCopilotDecision(askMode ? '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..bef45d7 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,21 @@ 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); + }); + // 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..14c3d32 100644 --- a/tests/bin/hooks/claude-code-hook.test.ts +++ b/tests/bin/hooks/claude-code-hook.test.ts @@ -24,6 +24,56 @@ 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 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'); + }); + }); + 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..e133d43 100644 --- a/tests/bin/hooks/copilot-cli-hook.test.ts +++ b/tests/bin/hooks/copilot-cli-hook.test.ts @@ -20,6 +20,43 @@ describe('Copilot CLI hook', () => { }); }); + describe('ask mode', () => { + test('ask mode returns ask decision instead of deny', 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('ask'); + expect(output.permissionDecisionReason).toContain('FLAGGED by Safety Net'); + expect(output.permissionDecisionReason).toContain('rm -rf'); + }); + + test('ask mode still allows safe commands', async () => { + const input = { + timestamp: Date.now(), + cwd: process.cwd(), + toolName: 'bash', + toolArgs: JSON.stringify({ command: 'ls -la' }), + }; + + const { stdout, exitCode } = await runCopilotHook(input, { + SAFETY_NET_ASK: '1', + }); + + expect(exitCode).toBe(0); + expect(stdout).toBe(''); + }); + }); + 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'); + }); }); From 825edc7a343754d843416e51dced6bbdfd059056 Mon Sep 17 00:00:00 2001 From: c2keesey Date: Thu, 2 Apr 2026 16:54:35 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20strict=20overrides=20ask,=20revert=20Copilot=20to?= =?UTF-8?q?=20deny-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strict mode now overrides ask mode (askMode && !strict) so unparseable commands in strict mode always hard-deny - Reverted Copilot CLI hook to deny-only since Copilot may not support 'ask' as a permissionDecision (fail-open risk) - Added tests: strict+ask command parse failures, combined statusline emoji, Copilot ignores ask mode - Updated README to document Claude Code-only support Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 6 +++--- dist/bin/cc-safety-net.js | 16 +++++++--------- src/bin/hooks/claude-code.ts | 2 +- src/bin/hooks/copilot-cli.ts | 17 +++++------------ tests/bin/cli-statusline.test.ts | 20 ++++++++++++++++++++ tests/bin/hooks/claude-code-hook.test.ts | 22 +++++++++++++++++++++- tests/bin/hooks/copilot-cli-hook.test.ts | 24 +++--------------------- 7 files changed, 60 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index d35c304..4ee84f3 100644 --- a/README.md +++ b/README.md @@ -616,9 +616,9 @@ 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 supported in Claude Code and GitHub Copilot CLI. Gemini CLI and -> OpenCode do not support interactive confirmation and will continue to block outright. -> Strict mode parse failures (`SAFETY_NET_STRICT=1`) always hard-block regardless of ask mode. +> **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 diff --git a/dist/bin/cc-safety-net.js b/dist/bin/cc-safety-net.js index a285ee6..5ad7ef3 100755 --- a/dist/bin/cc-safety-net.js +++ b/dist/bin/cc-safety-net.js @@ -5506,21 +5506,20 @@ async function runClaudeCodeHook() { if (sessionId) { writeAuditLog(sessionId, command, result.segment, result.reason, cwd); } - outputDecision(askMode ? "ask" : "deny", result.reason, command, result.segment); + outputDecision(askMode && !strict ? "ask" : "deny", result.reason, command, result.segment); } } // src/bin/hooks/copilot-cli.ts -function outputCopilotDecision(decision, reason, command, segment) { +function outputCopilotDeny(reason, command, segment) { const message = formatBlockedMessage({ reason, command, segment, - redact: redactSecrets, - askMode: decision === "ask" + redact: redactSecrets }); const output = { - permissionDecision: decision, + permissionDecision: "deny", permissionDecisionReason: message }; console.log(JSON.stringify(output)); @@ -5539,7 +5538,7 @@ async function runCopilotCliHook() { input = JSON.parse(inputText); } catch { if (envTruthy("SAFETY_NET_STRICT")) { - outputCopilotDecision("deny", "Failed to parse hook input JSON (strict mode)"); + outputCopilotDeny("Failed to parse hook input JSON (strict mode)"); } return; } @@ -5551,7 +5550,7 @@ async function runCopilotCliHook() { toolArgs = JSON.parse(input.toolArgs); } catch { if (envTruthy("SAFETY_NET_STRICT")) { - outputCopilotDecision("deny", "Failed to parse toolArgs JSON (strict mode)"); + outputCopilotDeny("Failed to parse toolArgs JSON (strict mode)"); } return; } @@ -5561,7 +5560,6 @@ async function runCopilotCliHook() { } 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"); @@ -5576,7 +5574,7 @@ async function runCopilotCliHook() { if (result) { const sessionId = `copilot-${input.timestamp ?? Date.now()}`; writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - outputCopilotDecision(askMode ? "ask" : "deny", result.reason, command, result.segment); + outputCopilotDeny(result.reason, command, result.segment); } } diff --git a/src/bin/hooks/claude-code.ts b/src/bin/hooks/claude-code.ts index f420185..fe28528 100644 --- a/src/bin/hooks/claude-code.ts +++ b/src/bin/hooks/claude-code.ts @@ -83,6 +83,6 @@ export async function runClaudeCodeHook(): Promise { if (sessionId) { writeAuditLog(sessionId, command, result.segment, result.reason, cwd); } - outputDecision(askMode ? 'ask' : 'deny', result.reason, command, result.segment); + outputDecision(askMode && !strict ? 'ask' : 'deny', result.reason, command, result.segment); } } diff --git a/src/bin/hooks/copilot-cli.ts b/src/bin/hooks/copilot-cli.ts index f078ac9..cdc1305 100644 --- a/src/bin/hooks/copilot-cli.ts +++ b/src/bin/hooks/copilot-cli.ts @@ -4,22 +4,16 @@ import { envTruthy } from '@/core/env'; import { formatBlockedMessage } from '@/core/format'; import type { CopilotCliHookInput, CopilotCliHookOutput } from '@/types'; -function outputCopilotDecision( - decision: 'deny' | 'ask', - reason: string, - command?: string, - segment?: string, -): void { +function outputCopilotDeny(reason: string, command?: string, segment?: string): void { const message = formatBlockedMessage({ reason, command, segment, redact: redactSecrets, - askMode: decision === 'ask', }); const output: CopilotCliHookOutput = { - permissionDecision: decision, + permissionDecision: 'deny', permissionDecisionReason: message, }; @@ -44,7 +38,7 @@ export async function runCopilotCliHook(): Promise { input = JSON.parse(inputText) as CopilotCliHookInput; } catch { if (envTruthy('SAFETY_NET_STRICT')) { - outputCopilotDecision('deny', 'Failed to parse hook input JSON (strict mode)'); + outputCopilotDeny('Failed to parse hook input JSON (strict mode)'); } return; } @@ -60,7 +54,7 @@ export async function runCopilotCliHook(): Promise { toolArgs = JSON.parse(input.toolArgs) as { command?: string }; } catch { if (envTruthy('SAFETY_NET_STRICT')) { - outputCopilotDecision('deny', 'Failed to parse toolArgs JSON (strict mode)'); + outputCopilotDeny('Failed to parse toolArgs JSON (strict mode)'); } return; } @@ -72,7 +66,6 @@ export async function runCopilotCliHook(): 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'); @@ -91,6 +84,6 @@ export async function runCopilotCliHook(): Promise { // Generate a session ID from timestamp for audit logging const sessionId = `copilot-${input.timestamp ?? Date.now()}`; writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - outputCopilotDecision(askMode ? 'ask' : 'deny', result.reason, command, result.segment); + outputCopilotDeny(result.reason, command, result.segment); } } diff --git a/tests/bin/cli-statusline.test.ts b/tests/bin/cli-statusline.test.ts index bef45d7..3763e46 100644 --- a/tests/bin/cli-statusline.test.ts +++ b/tests/bin/cli-statusline.test.ts @@ -66,6 +66,26 @@ describe('--statusline flag', () => { 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 14c3d32..b41c591 100644 --- a/tests/bin/hooks/claude-code-hook.test.ts +++ b/tests/bin/hooks/claude-code-hook.test.ts @@ -61,7 +61,7 @@ describe('Claude Code hook', () => { expect(exitCode).toBe(0); }); - test('strict parse failures still deny even in ask mode', async () => { + 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', @@ -72,6 +72,26 @@ describe('Claude Code hook', () => { 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', () => { diff --git a/tests/bin/hooks/copilot-cli-hook.test.ts b/tests/bin/hooks/copilot-cli-hook.test.ts index e133d43..2df2e98 100644 --- a/tests/bin/hooks/copilot-cli-hook.test.ts +++ b/tests/bin/hooks/copilot-cli-hook.test.ts @@ -20,8 +20,8 @@ describe('Copilot CLI hook', () => { }); }); - describe('ask mode', () => { - test('ask mode returns ask decision instead of deny', async () => { + describe('ask mode ignored', () => { + test('ask mode still denies on Copilot CLI (unsupported)', async () => { const input = { timestamp: Date.now(), cwd: process.cwd(), @@ -35,25 +35,7 @@ describe('Copilot CLI hook', () => { expect(exitCode).toBe(0); const output = JSON.parse(stdout); - expect(output.permissionDecision).toBe('ask'); - expect(output.permissionDecisionReason).toContain('FLAGGED by Safety Net'); - expect(output.permissionDecisionReason).toContain('rm -rf'); - }); - - test('ask mode still allows safe commands', async () => { - const input = { - timestamp: Date.now(), - cwd: process.cwd(), - toolName: 'bash', - toolArgs: JSON.stringify({ command: 'ls -la' }), - }; - - const { stdout, exitCode } = await runCopilotHook(input, { - SAFETY_NET_ASK: '1', - }); - - expect(exitCode).toBe(0); - expect(stdout).toBe(''); + expect(output.permissionDecision).toBe('deny'); }); });