Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
CODEX_INSTALL_PLUGINS := ox,oxgh
CODEX_DEV_PLUGINS := ox,oxgh,oxgl

.PHONY: setup dev dev-codex format check codex install-codex link-codex bump bump-check

setup:
@bash scripts/banner.sh
uv sync --frozen
Expand Down Expand Up @@ -41,6 +43,8 @@ link-codex:

bump:
uv run python scripts/bump.py $(filter-out $@,$(MAKECMDGOALS))
# Version bumps update plugin manifests that are copied into Codex plugin packages.
uv run python scripts/generate_codex.py

bump-check:
uv run python scripts/bump.py --check
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ Those user-level installed skills are available from any repo as `$oxgh:open-pr`

The generated Codex plugin packages under `codex/plugins/` are for Codex plugin marketplace workflows. The repo-local marketplace at `.agents/plugins/marketplace.json` points at those packages when working in this repository.

The generated `ox` Codex plugin also includes PostToolUse and Stop hooks. Configure checks with the same `.claude/ox-hooks.json` file shown below: Codex runs `fast` checks after edits and `slow` checks before it finishes a turn.

### Claude Code

#### 1. Add the marketplace to your project
Expand Down
5 changes: 3 additions & 2 deletions codex/plugins/ox/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ox",
"version": "0.0.13",
"version": "0.0.15",
"description": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects",
"author": {
"name": "Oxidian"
Expand All @@ -11,5 +11,6 @@
"shortDescription": "Base plugin — commit command, code quality hooks, auto-format and check hooks for all projects",
"developerName": "Oxidian",
"category": "Productivity"
}
},
"hooks": "./hooks.json"
}
29 changes: 29 additions & 0 deletions codex/plugins/ox/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"hooks": {
"PostToolUse": [
{
"matcher": "apply_patch|Edit|Write",
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner and $repo_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action fast'",
"timeout": 30,
"statusMessage": "Running fast checks"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; bootstrap_dir=\"${CODEX_PLUGINS_BOOTSTRAP_DIR:-.codex/cc-plugins}\"; case \"$bootstrap_dir\" in /*) bootstrap_root=\"$bootstrap_dir\" ;; *) bootstrap_root=\"$root/$bootstrap_dir\" ;; esac; bootstrap_runner=\"$bootstrap_root/codex/plugins/ox/scripts/run_if_changed.py\"; repo_runner=\"$root/codex/plugins/ox/scripts/run_if_changed.py\"; if [ -f \"$bootstrap_runner\" ]; then runner=\"$bootstrap_runner\"; elif [ -f \"$repo_runner\" ]; then runner=\"$repo_runner\"; else echo \"ox hook runner not found; checked $bootstrap_runner and $repo_runner\" >&2; exit 2; fi; exec python3 \"$runner\" --runtime codex --action slow'",
"timeout": 120,
"statusMessage": "Running final checks"
}
]
}
]
}
}
90 changes: 90 additions & 0 deletions codex/plugins/ox/scripts/ban_complex_bash.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/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()
72 changes: 72 additions & 0 deletions codex/plugins/ox/scripts/ban_custom_debug.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
import json
import re
import sys

# Define validation rules as a list of (regex pattern, message) tuples
VALIDATION_RULES_BASH = [
(
r"python3?\s+-c\b",
"Avoid 'python3 -c' commands for debugging. Run the existing tests with 'uv run pytest $TEST_FILE --vv --log-cli-level=INFO' and add `logger.info` logs to the code if you need to debug.",
),
(
r"debug.*<< 'EOF'",
"Avoid creating custom debug files. Run the existing tests with 'uv run pytest $TEST_FILE --vv --log-cli-level=INFO' and add `logger.info` logs to the code if you need to debug.",
),
]

VALIDATION_RULES_WRITE = [
(
r".*debug.*",
"Avoid creating custom debug files. Run the existing tests with 'uv run pytest $TEST_FILE --vv --log-cli-level=INFO' and add `logger.info` logs to the code if you need to debug.",
),
]


def validate_command(command: str) -> list[str]:
if not command:
print("ERROR: no command provided to validate_command", file=sys.stderr)
sys.exit(1)

issues = []
for pattern, message in VALIDATION_RULES_BASH:
if re.search(pattern, command):
issues.append(message)
return issues


def validate_write(tool_input: dict) -> list[str]:
if not tool_input:
print("ERROR: no tool_input provided to validate_write", file=sys.stderr)
sys.exit(1)

issues = []
for pattern, message in VALIDATION_RULES_WRITE:
if re.search(pattern, tool_input["file_path"]):
issues.append(message)
return issues


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)

tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
command = tool_input.get("command", "")

if tool_name == "Bash":
issues = validate_command(command)
elif tool_name == "Write":
issues = validate_write(tool_input)
else:
print(f"ERROR: unknown tool_name '{tool_name}', exiting", file=sys.stderr)
sys.exit(1)

if issues:
for message in issues:
print(f"\u2022 {message}", file=sys.stderr)
# Exit code 2 blocks tool call and shows stderr to Claude
sys.exit(2)
56 changes: 56 additions & 0 deletions codex/plugins/ox/scripts/ban_lint_suppressions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env python3
import json
import sys


def check_for_suppressions(content: str) -> list[str]:
"""Check if content contains lint/type checker suppressions."""
issues = []

banned_comments = ["# type: ignore", "# noqa", "# pyright: ignore"]

for banned in banned_comments:
if banned in content:
issues.append(f"BLOCKED: Code contains '{banned}' comment")

return issues


def validate_edit(tool_input: dict) -> list[str]:
"""Validate Edit tool for suppressions in new_string."""
new_string = tool_input.get("new_string", "")
return check_for_suppressions(new_string)


def validate_write(tool_input: dict) -> list[str]:
"""Validate Write tool for suppressions in content."""
content = tool_input.get("content", "")
return check_for_suppressions(content)


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)

tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})

issues = []
if tool_name == "Edit":
issues = validate_edit(tool_input)
elif tool_name == "Write":
issues = validate_write(tool_input)

if issues:
print("\n".join(issues), file=sys.stderr)
print(
"\nStop and explain to the user why you think this lint/type checker suppression is necessary.",
file=sys.stderr,
)
# Exit code 2 blocks tool call and shows stderr to Claude
sys.exit(2)

# If we reach here, no suppressions found
sys.exit(0)
62 changes: 62 additions & 0 deletions codex/plugins/ox/scripts/ban_redundant_cd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
import json
import re
import sys


def validate_bash_command(command: str, cwd: str) -> str:
"""Check if bash command has redundant cd to backend or frontend."""
if not command:
return ""

# Check for 'cd backend' pattern
if re.match(r"^\s*cd\s+backend\b", command) and cwd.endswith("/backend"):
# Strip the redundant cd part
clean_command = re.sub(r"^\s*cd\s+backend\s*&&\s*", "", command)
clean_command = re.sub(r"^\s*cd\s+backend\s*$", "", clean_command)
return f"""
BLOCKED: You are already in the backend directory ({cwd}).
Remove the 'cd backend' prefix.

Command should be: {clean_command}
"""

# Check for 'cd frontend' pattern
if re.match(r"^\s*cd\s+frontend\b", command) and cwd.endswith("/frontend"):
# Strip the redundant cd part
clean_command = re.sub(r"^\s*cd\s+frontend\s*&&\s*", "", command)
clean_command = re.sub(r"^\s*cd\s+frontend\s*$", "", clean_command)
return f"""
BLOCKED: You are already in the frontend directory ({cwd}).
Remove the 'cd frontend' prefix.

Command should be: {clean_command}
"""

return ""


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)

tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
cwd = input_data.get("cwd", "")
command = tool_input.get("command", "")

if tool_name != "Bash":
# Only applies to Bash commands
sys.exit(0)

error_message = validate_bash_command(command, cwd)

if error_message:
print(error_message, file=sys.stderr)
# Exit code 2 blocks tool call and shows stderr to Claude
sys.exit(2)

# If we reach here, the command is allowed
sys.exit(0)
Loading