Skip to content

feat: add SAFETY_NET_ASK mode to prompt user instead of blocking#45

Open
c2keesey wants to merge 2 commits intokenryu42:mainfrom
c2keesey:feat/ask-mode
Open

feat: add SAFETY_NET_ASK mode to prompt user instead of blocking#45
c2keesey wants to merge 2 commits intokenryu42:mainfrom
c2keesey:feat/ask-mode

Conversation

@c2keesey
Copy link
Copy Markdown

@c2keesey c2keesey commented Apr 2, 2026

What

Adds SAFETY_NET_ASK=1 environment variable. When set, dangerous commands return permissionDecision: 'ask' instead of 'deny', prompting the user for interactive confirmation rather than blocking outright.

Why

When Safety Net blocks a command, there's no hands-on-keyboard way to approve it. The user has to either:

  • Use /copy to grab the blocked command and paste it back
  • Manually highlight the command with the mouse and re-type it
  • Run the command themselves outside the agent session

This creates unnecessary friction when you do want the agent to run a flagged command. Ask mode keeps the safety analysis and warning visible while letting the user approve with a single keypress — similar to how --force-with-lease is safer than --force but still requires intent.

This is opt-in — default behavior is unchanged (hard deny). And it's not truly "soft" — the user must actively confirm each flagged command. The safety analysis still runs, the warning is still shown, and the audit log still records every flagged command.

Claude Code's hook protocol explicitly supports 'ask' as a permissionDecision alongside 'allow' and 'deny' (docs).

Changes

  • src/types.ts — Widen HookOutput.permissionDecision to include 'ask'
  • src/core/format.ts — Context-aware message: "FLAGGED by Safety Net" (ask) vs "BLOCKED by Safety Net" (deny), with appropriate footer text
  • src/bin/hooks/claude-code.ts — Read SAFETY_NET_ASK, return 'ask' instead of 'deny'
  • src/bin/hooks/copilot-cli.ts — Same
  • src/bin/statusline.ts — Show ❓ emoji for ask mode
  • src/bin/doctor/environment.ts — Added to env var diagnostics
  • src/bin/help.ts — Added to help output
  • CLAUDE.md, AGENTS.md, README.md — Documented the new env var

Gemini CLI and OpenCode are left as hard-deny only — their hook protocols don't support interactive confirmation.

Strict mode parse failures (SAFETY_NET_STRICT=1) always hard-deny regardless of ask mode.

Test coverage

  • Claude Code hook: ask mode returns 'ask', safe commands still pass through, strict parse failures still hard deny
  • Copilot CLI hook: ask mode returns 'ask', safe commands still pass through
  • Statusline: ❓ emoji displayed when SAFETY_NET_ASK=1
  • Format: FLAGGED header and confirmation footer in ask mode, BLOCKED header in default mode

Summary by CodeRabbit

Release Notes

  • New Features

    • Introduced "Ask Mode" (SAFETY_NET_ASK=1) that prompts users to approve or deny flagged commands instead of automatic blocking. Strict mode takes precedence.
  • Documentation

    • Updated documentation, help text, and status indicators to describe Ask Mode behavior. Ask Mode is supported in Claude Code only.

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) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fafa8d881c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/bin/hooks/copilot-cli.ts Outdated
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep Copilot hook in deny mode when ask is enabled

When SAFETY_NET_ASK=1 is set, this code emits permissionDecision: 'ask' for Copilot CLI. I checked GitHub’s Copilot hooks reference, and it states PreToolUse currently processes only deny, so returning ask will not trigger an interactive confirmation there. In that environment, dangerous commands that were previously blocked can execute instead, which is a fail-open regression for users who enable ask mode.

Useful? React with 👍 / 👎.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 2, 2026

Greptile Summary

This PR adds SAFETY_NET_ASK=1 opt-in mode that returns permissionDecision: 'ask' instead of 'deny' for dangerous commands in Claude Code, letting users approve flagged commands interactively rather than being hard-blocked. The implementation is clean: askMode && !strict ? 'ask' : 'deny' correctly ensures strict mode always hard-denies regardless of ask mode, and JSON/shell parse failures in strict mode are tested to confirm they continue to hard-deny.

Confidence Score: 5/5

Safe to merge — logic is correct, strict-mode invariant is properly enforced, and all key interactions are tested.

The only finding is a P2 cosmetic issue where the statusline shows ❓ even when strict mode renders ask mode inactive. All remaining changes are well-tested and the strict-mode/ask-mode guard (askMode && !strict) is implemented correctly at the decision point. Prior thread concerns about strict parse failures are fully addressed.

src/bin/statusline.ts — ❓ emoji could be suppressed when SAFETY_NET_STRICT=1 to match actual runtime behavior.

Important Files Changed

Filename Overview
src/bin/hooks/claude-code.ts Renames outputDeny to outputDecision, reads SAFETY_NET_ASK, and emits 'ask' when askMode && !strict — all combinations handled correctly.
src/bin/hooks/copilot-cli.ts Intentionally unchanged — always hard-denies; test and README correctly document that Copilot CLI does not support interactive confirmation.
src/core/format.ts Adds askMode flag to use FLAGGED header and confirmation footer instead of BLOCKED and manual-run footer; implementation is straightforward.
src/bin/statusline.ts Adds ❓ emoji when SAFETY_NET_ASK=1, but shows ❓🔒 even when strict mode overrides ask mode (rendering ❓ misleading).
src/types.ts Widens HookOutput.permissionDecision union to include 'ask'; minimal and correct change.
tests/bin/hooks/claude-code-hook.test.ts Adds ask mode tests covering: ask decision, safe command passthrough, strict JSON parse failures, and strict shell parse failures — all four key invariants tested.
tests/bin/hooks/copilot-cli-hook.test.ts Adds one test confirming Copilot CLI always hard-denies even with SAFETY_NET_ASK=1.
tests/core/format.test.ts Covers FLAGGED/BLOCKED header and confirm/manual-run footer for both ask and default modes.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Hook receives Bash command] --> B{Parse stdin JSON}
    B -- "parse error + STRICT" --> Z1[outputDecision 'deny']
    B -- "parse error, no STRICT" --> Z2[return, allow through]
    B -- success --> C{tool_name == 'Bash'?}
    C -- No --> Z3[return, allow through]
    C -- Yes --> D[analyzeCommand]
    D -- "no match" --> Z4[return, allow through]
    D -- "match (blocked)" --> E{SAFETY_NET_ASK && !SAFETY_NET_STRICT?}
    E -- Yes --> F[outputDecision 'ask' - FLAGGED header + confirm footer - Audit logged]
    E -- No --> G[outputDecision 'deny' - BLOCKED header + manual-run footer - Audit logged]
Loading

Reviews (2): Last reviewed commit: "fix: address review feedback — strict ov..." | Re-trigger Greptile

Comment thread src/bin/hooks/claude-code.ts Outdated
Comment thread src/bin/hooks/copilot-cli.ts Outdated
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

A new "ask mode" feature is introduced via the SAFETY_NET_ASK=1 environment variable, allowing dangerous commands to prompt users for confirmation instead of blocking them outright. Changes span documentation, environment variable registration, core formatting logic, hook implementations, status line display, and comprehensive test coverage. Ask mode is supported only in Claude Code and is overridden by strict mode when both are enabled.

Changes

Cohort / File(s) Summary
Documentation & Environment Registry
AGENTS.md, CLAUDE.md, README.md, src/bin/doctor/environment.ts, src/bin/help.ts
Added SAFETY_NET_ASK environment variable documentation and registry entries. README includes new "Ask Mode" subsection explaining behavior, platform support (Claude Code only), and precedence over strict mode.
Core Type System
src/types.ts
Extended HookOutput.hookSpecificOutput.permissionDecision union type to include 'ask' option alongside existing 'allow' and 'deny'.
Formatting Logic
src/core/format.ts
Added optional askMode parameter to FormatBlockedMessageInput interface. When enabled, changes header from "BLOCKED by Safety Net" to "FLAGGED by Safety Net" and updates confirmation footer text for user approval workflow.
Hook Implementation
src/bin/hooks/claude-code.ts
Refactored deny-only helper to generalized outputDecision() function. Added askMode derivation from SAFETY_NET_ASK environment flag. Routes unsafe commands to 'ask' decision when ask mode enabled and strict mode disabled; otherwise routes to 'deny'.
Status Line Display
src/bin/statusline.ts
Integrated SAFETY_NET_ASK flag reading into status line output. Appends emoji to mode indicators when ask mode is active.
Test Coverage
tests/bin/cli-statusline.test.ts, tests/bin/hooks/claude-code-hook.test.ts, tests/bin/hooks/copilot-cli-hook.test.ts, tests/core/format.test.ts
Added environment cleanup for SAFETY_NET_ASK, status line output assertions for ask mode variants, Claude Code hook ask mode behavior verification, Copilot CLI hook test confirming ask mode is ignored, and formatBlockedMessage unit tests validating "FLAGGED" vs. "BLOCKED" header conditional logic.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Hook as Claude Code Hook
    participant SafetyNet as Safety Net Logic
    participant Format as Message Formatter

    User->>Hook: Execute command
    Hook->>SafetyNet: Check if dangerous & read env flags
    
    alt Command is safe
        SafetyNet->>Hook: permit
        Hook->>User: Execute
    else Command is dangerous
        SafetyNet->>SafetyNet: Check SAFETY_NET_ASK & SAFETY_NET_STRICT
        
        alt Strict mode enabled
            SafetyNet->>Format: Prepare "BLOCKED" message
            Format->>User: Display blocked notification
            SafetyNet->>Hook: deny
        else Ask mode enabled
            SafetyNet->>Format: Prepare "FLAGGED" message with confirmation
            Format->>User: Prompt for approval/denial
            User->>Hook: Approve or Cancel
            SafetyNet->>Hook: ask (pending user decision)
        else Default (block)
            SafetyNet->>Format: Prepare "BLOCKED" message
            Format->>User: Display blocked notification
            SafetyNet->>Hook: deny
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

A curious bunny asks before it bounds,
Through safety nets with gentle sounds,
No "BLOCKED" now, but "FLAGGED" instead,
Prompting care where commands tread,
Ask mode hops where strict once said "no,"
A kinder path for commands to flow! 🐰❓

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main feature: adding a SAFETY_NET_ASK mode that prompts users instead of blocking commands.
Description check ✅ Passed The description covers the required template sections: Summary (what and why), Changes (specific modifications), Testing (bun run check reference), and PR Checklist items are addressed with documentation updates and test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
tests/bin/cli-statusline.test.ts (1)

54-67: LGTM!

Test case follows the established pattern and correctly verifies the ask mode emoji output.

Consider adding a test for combined modes (e.g., SAFETY_NET_ASK=1 + SAFETY_NET_STRICT=1❓🔒) to verify emoji concatenation works correctly with ask mode, similar to the existing "strict + paranoid" combination test at lines 99-117.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/bin/cli-statusline.test.ts` around lines 54 - 67, Add a new test in
tests/bin/cli-statusline.test.ts that spawns the CLI with --statusline and both
environment vars set (SAFETY_NET_ASK: '1' and SAFETY_NET_STRICT: '1') using the
same pattern as the existing test 'shows ask mode emoji when SAFETY_NET_ASK=1'
(reuse enabledSettingsPath and Bun.spawn call), then assert output.trim() equals
'🛡️ Safety Net ❓🔒' and exit code is 0 to verify emoji concatenation when ask +
strict are enabled.
tests/bin/hooks/copilot-cli-hook.test.ts (1)

23-58: Add strict+ask regression coverage for Copilot hook.

Nice ask-mode path coverage. Please also add tests for SAFETY_NET_ASK=1 + SAFETY_NET_STRICT=1 with invalid outer JSON and invalid toolArgs JSON to lock in the “strict parse failures always deny” invariant for Copilot too.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/bin/hooks/copilot-cli-hook.test.ts` around lines 23 - 58, Add two tests
under the same hook tests that run runCopilotHook with SAFETY_NET_ASK=1 and
SAFETY_NET_STRICT=1: one where the outer input JSON is malformed and one where
toolArgs contains invalid JSON; both tests should parse the hook stdout and
assert the permissionDecision is "deny" and the permissionDecisionReason
contains a parse/JSON error message (i.e., strict parse failures always deny),
and ensure the hook emits a JSON decision object so the test can inspect
permissionDecision and permissionDecisionReason; reference runCopilotHook,
SAFETY_NET_ASK, SAFETY_NET_STRICT and the existing "ask mode" tests as the place
to add these cases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/bin/cli-statusline.test.ts`:
- Around line 54-67: Add a new test in tests/bin/cli-statusline.test.ts that
spawns the CLI with --statusline and both environment vars set (SAFETY_NET_ASK:
'1' and SAFETY_NET_STRICT: '1') using the same pattern as the existing test
'shows ask mode emoji when SAFETY_NET_ASK=1' (reuse enabledSettingsPath and
Bun.spawn call), then assert output.trim() equals '🛡️ Safety Net ❓🔒' and exit
code is 0 to verify emoji concatenation when ask + strict are enabled.

In `@tests/bin/hooks/copilot-cli-hook.test.ts`:
- Around line 23-58: Add two tests under the same hook tests that run
runCopilotHook with SAFETY_NET_ASK=1 and SAFETY_NET_STRICT=1: one where the
outer input JSON is malformed and one where toolArgs contains invalid JSON; both
tests should parse the hook stdout and assert the permissionDecision is "deny"
and the permissionDecisionReason contains a parse/JSON error message (i.e.,
strict parse failures always deny), and ensure the hook emits a JSON decision
object so the test can inspect permissionDecision and permissionDecisionReason;
reference runCopilotHook, SAFETY_NET_ASK, SAFETY_NET_STRICT and the existing
"ask mode" tests as the place to add these cases.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f5fe88d7-c049-438c-8eb3-117d08b06786

📥 Commits

Reviewing files that changed from the base of the PR and between a8acf4d and fafa8d8.

⛔ Files ignored due to path filters (4)
  • dist/bin/cc-safety-net.js is excluded by !**/dist/**
  • dist/core/format.d.ts is excluded by !**/dist/**
  • dist/index.js is excluded by !**/dist/**
  • dist/types.d.ts is excluded by !**/dist/**
📒 Files selected for processing (14)
  • AGENTS.md
  • CLAUDE.md
  • README.md
  • src/bin/doctor/environment.ts
  • src/bin/help.ts
  • src/bin/hooks/claude-code.ts
  • src/bin/hooks/copilot-cli.ts
  • src/bin/statusline.ts
  • src/core/format.ts
  • src/types.ts
  • tests/bin/cli-statusline.test.ts
  • tests/bin/hooks/claude-code-hook.test.ts
  • tests/bin/hooks/copilot-cli-hook.test.ts
  • tests/core/format.test.ts

@c2keesey c2keesey marked this pull request as draft April 2, 2026 23:43
…o deny-only

- 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) <noreply@anthropic.com>
@c2keesey
Copy link
Copy Markdown
Author

c2keesey commented Apr 2, 2026

Manual Testing

Tested locally using a clean Claude Code session with the local plugin build:

Setup

# Build the plugin
cd claude-code-safety-net && bun run build

# Create a wrapper script to set env + load local plugin
cat > /tmp/claude-safety-test.sh << 'SCRIPT'
#!/bin/bash
export SAFETY_NET_ASK=1
exec claude --plugin-dir /path/to/claude-code-safety-net "$@"
SCRIPT
chmod +x /tmp/claude-safety-test.sh

Launched the session via agent-deck with the wrapper registered as a custom tool, providing an isolated Claude Code instance with only the local safety-net plugin.

Test: Dangerous command triggers ask prompt

Sent: Run this exact bash command: git reset --hard HEAD

Result — Claude Code showed an interactive confirmation dialog:

Hook PreToolUse:Bash requires confirmation for this command:
FLAGGED by Safety Net

Reason: git reset --hard destroys all uncommitted changes permanently.
Use 'git stash' first.

Command: git reset --hard HEAD

This command may be destructive. Approve to proceed, or deny to cancel.
[plugin:safety-net]

Do you want to proceed?
❯ 1. Yes
  2. No

The user can approve with Enter or deny with Esc — no need to /copy or manually re-type the command.

Review feedback addressed

  • Strict mode overrides ask mode: Changed to askMode && !strict ? 'ask' : 'deny' so all blocks (including unparseable command failures) hard-deny when SAFETY_NET_STRICT=1 is active
  • Reverted Copilot CLI to deny-only: Can't verify Copilot supports 'ask' as a permissionDecision, so keeping it as hard-deny to avoid a fail-open regression
  • Added tests: strict+ask command parse failures, combined statusline emoji (❓🔒), Copilot ignoring ask mode
  • Updated docs: Ask mode is Claude Code-only

@c2keesey c2keesey marked this pull request as ready for review April 2, 2026 23:55
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bin/hooks/claude-code.ts`:
- Around line 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.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e45693d1-bae8-4d37-82a4-af85b6878f9c

📥 Commits

Reviewing files that changed from the base of the PR and between fafa8d8 and 825edc7.

⛔ Files ignored due to path filters (1)
  • dist/bin/cc-safety-net.js is excluded by !**/dist/**
📒 Files selected for processing (5)
  • README.md
  • src/bin/hooks/claude-code.ts
  • tests/bin/cli-statusline.test.ts
  • tests/bin/hooks/claude-code-hook.test.ts
  • tests/bin/hooks/copilot-cli-hook.test.ts
✅ Files skipped from review due to trivial changes (1)
  • README.md
🚧 Files skipped from review as they are similar to previous changes (3)
  • tests/bin/cli-statusline.test.ts
  • tests/bin/hooks/claude-code-hook.test.ts
  • tests/bin/hooks/copilot-cli-hook.test.ts

Comment on lines +7 to +12
function outputDecision(
decision: 'deny' | 'ask',
reason: string,
command?: string,
segment?: string,
): void {
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant