From c35fa15eb47abe0d1a731a0a4384eca5aca66e26 Mon Sep 17 00:00:00 2001 From: Daniel Tipping Date: Sun, 26 Apr 2026 16:46:55 +0100 Subject: [PATCH] Remove complex Bash hook --- codex/plugins/ox/.codex-plugin/plugin.json | 2 +- codex/plugins/ox/scripts/ban_complex_bash.py | 90 ------------- plugins/ox/.claude-plugin/plugin.json | 2 +- plugins/ox/hooks/hooks.json | 12 -- plugins/ox/scripts/ban_complex_bash.py | 90 ------------- tests/ox/test_ban_complex_bash.py | 135 ------------------- 6 files changed, 2 insertions(+), 329 deletions(-) delete mode 100644 codex/plugins/ox/scripts/ban_complex_bash.py delete mode 100644 plugins/ox/scripts/ban_complex_bash.py delete mode 100644 tests/ox/test_ban_complex_bash.py diff --git a/codex/plugins/ox/.codex-plugin/plugin.json b/codex/plugins/ox/.codex-plugin/plugin.json index 2f71c1d..664a645 100644 --- a/codex/plugins/ox/.codex-plugin/plugin.json +++ b/codex/plugins/ox/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ox", - "version": "0.0.15", + "version": "0.0.16", "description": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects", "author": { "name": "Oxidian" diff --git a/codex/plugins/ox/scripts/ban_complex_bash.py b/codex/plugins/ox/scripts/ban_complex_bash.py deleted file mode 100644 index bae74e9..0000000 --- a/codex/plugins/ox/scripts/ban_complex_bash.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""PermissionRequest hook: auto-deny complex Bash commands. - -In practice, when Claude attempts complex bash commands there's already a simpler -way to do what it wants. These commands block the agentic loop waiting for user -input, which we don't want. - -When Claude Code shows a permission dialog for a Bash command and the -``permission_suggestions`` field is missing or empty (meaning no "Always Allow" -option is available), the command is considered too complex and is denied -automatically. - -Plain ``cd`` commands are an exception — they never have permission_suggestions -but are simple navigation commands, so they are allowed through to the normal -permission dialog. Chained ``cd`` commands (e.g. ``cd /path && make build``) are -denied with guidance to run the ``cd`` separately. -""" - -import json -import re -import sys - -DENY_MESSAGE = ( - "BLOCKED: Bash command too complex. " - "Check CLAUDE.md for available dev commands or use a simpler command with fewer pipes." -) - -CD_DENY_MESSAGE = ( - "BLOCKED: You MUST run `{cd_part}` as a separate Bash tool call first," - " THEN run your actual command in a second Bash tool call." - " Do NOT drop the cd or combine them." -) - -_SHELL_CHAIN = re.compile(r"[;&|`]|\$\(") - - -def _is_cd_only(command: str) -> bool: - """Return True if the command is just ``cd`` with no chaining.""" - stripped = command.strip() - if stripped != "cd" and not stripped.startswith(("cd ", "cd\t")): - return False - return not _SHELL_CHAIN.search(stripped) - - -def _extract_cd(command: str) -> str: - """Extract the cd portion from a chained command like ``cd /path && ...``.""" - m = re.match(r"(cd\s+\S+)", command.strip()) - return m.group(1) if m else "cd" - - -def should_deny(input_data: dict) -> str | None: - """Return a deny message, or None to allow.""" - if input_data.get("tool_name") != "Bash": - return None - suggestions = input_data.get("permission_suggestions") - if suggestions: - return None - command = input_data.get("tool_input", {}).get("command", "") - if _is_cd_only(command): - return None - if command.strip().startswith(("cd ", "cd\t")): - return CD_DENY_MESSAGE.format(cd_part=_extract_cd(command)) - return DENY_MESSAGE - - -def main() -> None: - try: - input_data = json.load(sys.stdin) - except json.JSONDecodeError as e: - print(f"Error: Invalid JSON input: {e}", file=sys.stderr) - sys.exit(1) - - message = should_deny(input_data) - if message: - result = { - "hookSpecificOutput": { - "hookEventName": "PermissionRequest", - "decision": { - "behavior": "deny", - "message": message, - }, - } - } - json.dump(result, sys.stdout) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/plugins/ox/.claude-plugin/plugin.json b/plugins/ox/.claude-plugin/plugin.json index da18a30..80cb8c4 100644 --- a/plugins/ox/.claude-plugin/plugin.json +++ b/plugins/ox/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ox", "description": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects", - "version": "0.0.15", + "version": "0.0.16", "author": { "name": "Oxidian" } diff --git a/plugins/ox/hooks/hooks.json b/plugins/ox/hooks/hooks.json index 3e54df2..7d3929f 100644 --- a/plugins/ox/hooks/hooks.json +++ b/plugins/ox/hooks/hooks.json @@ -45,18 +45,6 @@ ] } ], - "PermissionRequest": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/ban_complex_bash.py", - "timeout": 10 - } - ] - } - ], "Stop": [ { "matcher": "", diff --git a/plugins/ox/scripts/ban_complex_bash.py b/plugins/ox/scripts/ban_complex_bash.py deleted file mode 100644 index bae74e9..0000000 --- a/plugins/ox/scripts/ban_complex_bash.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -"""PermissionRequest hook: auto-deny complex Bash commands. - -In practice, when Claude attempts complex bash commands there's already a simpler -way to do what it wants. These commands block the agentic loop waiting for user -input, which we don't want. - -When Claude Code shows a permission dialog for a Bash command and the -``permission_suggestions`` field is missing or empty (meaning no "Always Allow" -option is available), the command is considered too complex and is denied -automatically. - -Plain ``cd`` commands are an exception — they never have permission_suggestions -but are simple navigation commands, so they are allowed through to the normal -permission dialog. Chained ``cd`` commands (e.g. ``cd /path && make build``) are -denied with guidance to run the ``cd`` separately. -""" - -import json -import re -import sys - -DENY_MESSAGE = ( - "BLOCKED: Bash command too complex. " - "Check CLAUDE.md for available dev commands or use a simpler command with fewer pipes." -) - -CD_DENY_MESSAGE = ( - "BLOCKED: You MUST run `{cd_part}` as a separate Bash tool call first," - " THEN run your actual command in a second Bash tool call." - " Do NOT drop the cd or combine them." -) - -_SHELL_CHAIN = re.compile(r"[;&|`]|\$\(") - - -def _is_cd_only(command: str) -> bool: - """Return True if the command is just ``cd`` with no chaining.""" - stripped = command.strip() - if stripped != "cd" and not stripped.startswith(("cd ", "cd\t")): - return False - return not _SHELL_CHAIN.search(stripped) - - -def _extract_cd(command: str) -> str: - """Extract the cd portion from a chained command like ``cd /path && ...``.""" - m = re.match(r"(cd\s+\S+)", command.strip()) - return m.group(1) if m else "cd" - - -def should_deny(input_data: dict) -> str | None: - """Return a deny message, or None to allow.""" - if input_data.get("tool_name") != "Bash": - return None - suggestions = input_data.get("permission_suggestions") - if suggestions: - return None - command = input_data.get("tool_input", {}).get("command", "") - if _is_cd_only(command): - return None - if command.strip().startswith(("cd ", "cd\t")): - return CD_DENY_MESSAGE.format(cd_part=_extract_cd(command)) - return DENY_MESSAGE - - -def main() -> None: - try: - input_data = json.load(sys.stdin) - except json.JSONDecodeError as e: - print(f"Error: Invalid JSON input: {e}", file=sys.stderr) - sys.exit(1) - - message = should_deny(input_data) - if message: - result = { - "hookSpecificOutput": { - "hookEventName": "PermissionRequest", - "decision": { - "behavior": "deny", - "message": message, - }, - } - } - json.dump(result, sys.stdout) - - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/tests/ox/test_ban_complex_bash.py b/tests/ox/test_ban_complex_bash.py deleted file mode 100644 index a45f75c..0000000 --- a/tests/ox/test_ban_complex_bash.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Tests for ban_complex_bash.py PermissionRequest hook.""" - -import importlib.util -import sys -from pathlib import Path -from types import ModuleType - -# Load the module dynamically since it's not in a proper package -_script_path = Path(__file__).parent.parent.parent / "plugins" / "ox" / "scripts" / "ban_complex_bash.py" -_spec = importlib.util.spec_from_file_location("ban_complex_bash", _script_path) -assert _spec is not None -assert _spec.loader is not None -ban_complex_bash: ModuleType = importlib.util.module_from_spec(_spec) -sys.modules["ban_complex_bash"] = ban_complex_bash -_spec.loader.exec_module(ban_complex_bash) - -should_deny = ban_complex_bash.should_deny -_is_cd_only = ban_complex_bash._is_cd_only -DENY_MESSAGE = ban_complex_bash.DENY_MESSAGE - - -class TestShouldDeny: - """Tests for should_deny().""" - - def test_denies_bash_with_empty_suggestions(self) -> None: - """Bash command with no 'always allow' options is denied.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cat foo | grep bar | sed 's/x/y/' | awk '{print $1}'"}, - "permission_suggestions": [], - } - assert should_deny(input_data) is not None - - def test_allows_bash_with_suggestions(self) -> None: - """Bash command with 'always allow' options is allowed.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "git status"}, - "permission_suggestions": [{"allow_command": "git status"}], - } - assert should_deny(input_data) is None - - def test_allows_non_bash_with_empty_suggestions(self) -> None: - """Non-Bash tool with empty suggestions is allowed.""" - input_data = { - "tool_name": "Read", - "tool_input": {"file_path": "/etc/passwd"}, - "permission_suggestions": [], - } - assert should_deny(input_data) is None - - def test_denies_missing_suggestions_field(self) -> None: - """Missing permission_suggestions field means no 'always allow' — denied.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cat foo | grep bar | sed 's/x/y/'"}, - } - assert should_deny(input_data) is not None - - def test_allows_cd_only(self) -> None: - """A plain cd command with no suggestions is allowed through.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cd /some/path"}, - } - assert should_deny(input_data) is None - - def test_allows_bare_cd(self) -> None: - """Bare cd with no arguments is allowed.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cd"}, - } - assert should_deny(input_data) is None - - def test_allows_cd_with_tilde(self) -> None: - """cd with tilde path is allowed.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cd ~/project"}, - } - assert should_deny(input_data) is None - - def test_denies_cd_chained_with_and(self) -> None: - """cd chained with && is denied with a message containing the cd part.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cd /path && make build"}, - } - result = should_deny(input_data) - assert result is not None - assert "cd /path" in result - - def test_denies_cd_chained_with_semicolon(self) -> None: - """cd chained with ; is denied with a message containing the cd part.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cd /path; ls"}, - } - result = should_deny(input_data) - assert result is not None - assert "cd /path" in result - - def test_denies_non_cd_complex(self) -> None: - """Non-cd complex command returns the standard DENY_MESSAGE.""" - input_data = { - "tool_name": "Bash", - "tool_input": {"command": "cat foo | grep bar"}, - } - assert should_deny(input_data) == DENY_MESSAGE - - -class TestIsCdOnly: - """Tests for _is_cd_only().""" - - def test_simple_cd_path(self) -> None: - assert _is_cd_only("cd /some/path") is True - - def test_bare_cd(self) -> None: - assert _is_cd_only("cd") is True - - def test_cd_dotdot(self) -> None: - assert _is_cd_only("cd ..") is True - - def test_cd_with_chaining(self) -> None: - assert _is_cd_only("cd /path && ls") is False - - def test_cd_with_pipe(self) -> None: - assert _is_cd_only("cd /path | something") is False - - def test_cd_with_semicolon(self) -> None: - assert _is_cd_only("cd /path; ls") is False - - def test_not_cd(self) -> None: - assert _is_cd_only("cat file") is False