Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
35 changes: 27 additions & 8 deletions dist/bin/cc-safety-net.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5330,6 +5335,7 @@ function printHelp() {
lines.push(`${INDENT}${PROGRAM_NAME} <command> --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`);
Expand Down Expand Up @@ -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) {
Expand All @@ -5422,27 +5429,34 @@ 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) {
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text;
}

// 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
}
};
Expand All @@ -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;
}
Expand All @@ -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");
Expand All @@ -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);
}
}

Expand Down Expand Up @@ -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";
}
Expand Down
2 changes: 2 additions & 0 deletions dist/core/format.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
13 changes: 10 additions & 3 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion dist/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export interface HookInput {
export interface HookOutput {
hookSpecificOutput: {
hookEventName: string;
permissionDecision: 'allow' | 'deny';
permissionDecision: 'allow' | 'deny' | 'ask';
permissionDecisionReason?: string;
};
}
Expand Down
5 changes: 5 additions & 0 deletions src/bin/doctor/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/bin/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
15 changes: 11 additions & 4 deletions src/bin/hooks/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +7 to +12
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify module-private naming and optional-parameter usage in this hook file.
rg -nP --type=ts '^\s*function\s+(?!_)[A-Za-z][A-Za-z0-9_]*\s*\(' src/bin/hooks/claude-code.ts -C1
rg -nP --type=ts '^\s*function\s+[A-Za-z_][A-Za-z0-9_]*\s*\([^)]*\?:' src/bin/hooks/claude-code.ts -C1

Repository: kenryu42/claude-code-safety-net

Length of output: 134


🏁 Script executed:

cat -n src/bin/hooks/claude-code.ts | head -100

Repository: kenryu42/claude-code-safety-net

Length of output: 2886


Rename module-private helper and use explicit undefined typing per coding guidelines.

The outputDecision function (lines 7-12) should be _outputDecision and parameters should use command: string | undefined and segment: string | undefined instead of optional syntax (command?: string, segment?: string).

Suggested refactor
-function outputDecision(
+function _outputDecision(
   decision: 'deny' | 'ask',
   reason: string,
-  command?: string,
-  segment?: string,
+  command: string | undefined = undefined,
+  segment: string | undefined = undefined,
 ): void {

Update call sites:

  • Line 50: _outputDecision('deny', 'Failed to parse hook input JSON (strict mode)');
  • Line 86: _outputDecision(askMode && !strict ? 'ask' : 'deny', result.reason, command, result.segment);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bin/hooks/claude-code.ts` around lines 7 - 12, Rename the module-private
helper outputDecision to _outputDecision and change its parameter types from
optional to explicit undefined types: command: string | undefined and segment:
string | undefined; then update every call site that uses outputDecision(...) to
call _outputDecision(...) with the same arguments (e.g., the call that logs
parse failure and the call that forwards result.reason, command, result.segment)
so the new name and signature are consistent across the module.

const message = formatBlockedMessage({
reason,
command,
segment,
redact: redactSecrets,
askMode: decision === 'ask',
});

const output: HookOutput = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecision: decision,
permissionDecisionReason: message,
},
};
Expand All @@ -41,7 +47,7 @@ export async function runClaudeCodeHook(): Promise<void> {
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;
}
Expand All @@ -57,6 +63,7 @@ export async function runClaudeCodeHook(): Promise<void> {

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');
Expand All @@ -76,6 +83,6 @@ export async function runClaudeCodeHook(): Promise<void> {
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);
}
}
6 changes: 6 additions & 0 deletions src/bin/statusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,18 @@ export async function printStatusline(): Promise<void> {
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 += '🔒';
Expand Down
15 changes: 11 additions & 4 deletions src/core/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export interface HookInput {
export interface HookOutput {
hookSpecificOutput: {
hookEventName: string;
permissionDecision: 'allow' | 'deny';
permissionDecision: 'allow' | 'deny' | 'ask';
permissionDecisionReason?: string;
};
}
Expand Down
Loading