diff --git a/cspell.json b/cspell.json index 52c4213..eefef18 100644 --- a/cspell.json +++ b/cspell.json @@ -35,6 +35,7 @@ "aouei", "subcmd", "ENDJSON", + "RLENGTH", "unrecognised", "tokenisation" ] diff --git a/script-guards/.claude-plugin/plugin.json b/script-guards/.claude-plugin/plugin.json new file mode 100644 index 0000000..55a616e --- /dev/null +++ b/script-guards/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "script-guards", + "version": "1.0.0", + "description": "Prevents unnecessary script generation - enforces research-first, native-tool-first patterns via PreToolUse and UserPromptSubmit hooks", + "author": {"name": "JacobPEvans"}, + "homepage": "https://github.com/JacobPEvans/claude-code-plugins", + "keywords": ["scripts", "guards", "direct-execution", "hooks", "nix", "inline-scripts"], + "license": "Apache-2.0" +} diff --git a/script-guards/hooks/hooks.json b/script-guards/hooks/hooks.json new file mode 100644 index 0000000..7434828 --- /dev/null +++ b/script-guards/hooks/hooks.json @@ -0,0 +1,48 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/write-script-guard.sh", + "timeout": 10 + } + ] + }, + { + "matcher": "Edit", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/inline-script-guard.sh", + "timeout": 5 + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/bash-script-guard.sh", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/research-reminder.sh", + "timeout": 3 + } + ] + } + ] + } +} diff --git a/script-guards/scripts/bash-script-guard.sh b/script-guards/scripts/bash-script-guard.sh new file mode 100755 index 0000000..770a3ac --- /dev/null +++ b/script-guards/scripts/bash-script-guard.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# bash-script-guard.sh - PreToolUse hook to prevent script creation via Bash tool +# +# Detects patterns where Bash is used to write script files (redirects, heredocs) +# and blocks them, directing to the Write tool instead. +# +# Exit codes: 0=allow, 2=deny + +set -euo pipefail + +# Read JSON input from stdin (fail-open if jq fails) +input=$(cat) +command=$(echo "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0 + +# If no command, allow +if [[ -z "$command" ]]; then + exit 0 +fi + +# Shared deny helper to reduce duplication +deny() { + jq -n --arg reason "$1" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $reason + } + }' >&2 + exit 2 +} + +# All regexes use [[:space:]] and [^[:space:]] for BSD grep/sed compatibility on macOS +# (\s and \S are GNU extensions not supported by BSD tools) + +# Check for file-writing patterns to script files (e.g., echo > file.sh, cat >> script.py) +if echo "$command" | grep -qE '\b(cat|tee|echo|printf)\b[^|;&]*>>?[[:space:]]+[^[:space:]]+\.(sh|py|rb|pl|js|bash)\b'; then + deny "BLOCKED: Use the Write tool for file creation, not Bash redirects.\n\nThe Write tool provides proper file creation with atomic writes. Bash redirects to script files are not allowed." +fi + +# Check for heredoc patterns writing to script files (e.g., cat > file.sh <>?[[:space:]]+[^[:space:]]+\.(sh|py|rb|pl|js|bash)[[:space:]]*<<|tee[[:space:]]+[^[:space:]]+\.(sh|py|rb|pl|js|bash)[[:space:]]*<<)'; then + deny "BLOCKED: Use the Write tool for file creation, not heredocs.\n\nThe Write tool provides proper file creation with atomic writes. Heredoc-based file creation via Bash is not allowed." +fi + +# Check for chmod +x on non-existent files (likely creating a new script outside allowed dirs) +if echo "$command" | grep -qE 'chmod[[:space:]]+\+x[[:space:]]+'; then + target_file=$(echo "$command" \ + | grep -oE 'chmod[[:space:]]+\+x[[:space:]]+[^[:space:]]+' \ + | head -1 \ + | sed 's/chmod[[:space:]]*+x[[:space:]]*//') + if [[ -n "$target_file" ]] && [[ ! -f "$target_file" ]]; then + deny "BLOCKED: Scripts must be placed in scripts/ directory.\n\nUse the Write tool to create scripts in the appropriate directory (scripts/, hooks/, .github/, or tests/)." + fi +fi + +# All checks passed, allow operation +exit 0 diff --git a/script-guards/scripts/inline-script-guard.sh b/script-guards/scripts/inline-script-guard.sh new file mode 100755 index 0000000..c7e14a3 --- /dev/null +++ b/script-guards/scripts/inline-script-guard.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# inline-script-guard.sh - PreToolUse hook to prevent inline scripts in .nix and .yml files +# +# Detects complex inline shell logic in Nix files and GitHub Actions workflows, +# enforcing extraction to separate script files. +# +# Exit codes: 0=allow, 2=deny + +set -euo pipefail + +# Read JSON input from stdin (fail-open if jq fails) +input=$(cat) +file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0 +new_string=$(echo "$input" | jq -r '.tool_input.new_string // empty' 2>/dev/null) || exit 0 + +# If no file path or no new content, allow +if [[ -z "$file_path" ]] || [[ -z "$new_string" ]]; then + exit 0 +fi + +# Extract file extension (lowercase, tr for macOS bash 3.x compatibility) +extension=$(echo "${file_path##*.}" | tr '[:upper:]' '[:lower:]') + +# --- Check .nix files for inline shell scripts --- +if [[ "$extension" == "nix" ]]; then + # Count lines containing shell control-flow keywords or pipeline operators + # Uses grep -c on the whole string instead of a per-line loop + shell_keyword_count=$(echo "$new_string" \ + | grep -cE '\b(if|then|else|fi|for|while|do|done|case|esac)\b|&&|\|\|' 2>/dev/null) || true + + if [[ "$shell_keyword_count" -gt 3 ]]; then + jq -n --arg fp "$file_path" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: ("BLOCKED: Inline script detected in Nix file \(.fp).\n\nExtract to scripts/ directory and reference via builtins.readFile or writeShellApplication.\n\nShell scripts must NEVER be inline in .nix files. Use separate files in scripts/ with proper extensions.") + } + }' >&2 + exit 2 + fi +fi + +# --- Check .yml/.yaml files for complex inline bash --- +if [[ "$extension" == "yml" ]] || [[ "$extension" == "yaml" ]]; then + # Check for multiline run blocks (run: | or run: >) + # Uses [[:space:]] for BSD compatibility on macOS + if echo "$new_string" | grep -qE 'run:[[:space:]]*[|>]'; then + # Count run-block lines using indentation-aware awk (handles indented YAML steps) + run_block_lines=$(printf '%s\n' "$new_string" | awk ' + /run:[[:space:]]*[|>]/ { + match($0, /^[[:space:]]*/); base = RLENGTH; counting = 1; n = 0; next + } + counting { + match($0, /^[[:space:]]*/); + if (RLENGTH <= base && $0 !~ /^[[:space:]]*$/) { counting = 0 } + else n++ + } + END { print n+0 } + ') + + if [[ "$run_block_lines" -gt 5 ]]; then + jq -n --arg fp "$file_path" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: ("BLOCKED: Complex inline bash in workflow YAML \(.fp).\n\nExtract to .github/scripts/ or scripts/ and call from the workflow step.\n\nNever embed complex bash logic inline in GitHub Actions workflow YAML.") + } + }' >&2 + exit 2 + fi + fi +fi + +# All checks passed, allow operation +exit 0 diff --git a/script-guards/scripts/research-reminder.sh b/script-guards/scripts/research-reminder.sh new file mode 100755 index 0000000..74c12e6 --- /dev/null +++ b/script-guards/scripts/research-reminder.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# research-reminder.sh - UserPromptSubmit hook to remind about research-first approach +# +# When the user's prompt contains implementation keywords, injects a system message +# reminding to check for existing tools before creating new ones. +# +# Exit codes: 0 always (never blocks user prompts) + +set -euo pipefail + +# Read JSON input from stdin (fail-open if jq fails) +input=$(cat) +prompt=$(echo "$input" | jq -r '.tool_input.prompt // .tool_input.content // .prompt // .content // empty' 2>/dev/null) || exit 0 + +# If no prompt found, exit silently +if [[ -z "$prompt" ]]; then + exit 0 +fi + +# Check for implementation keywords (case-insensitive via grep -i) +if echo "$prompt" | grep -qiwE 'create|write|build|implement|add|make|generate|automate|script|function|helper|utility|tool|wrapper'; then + jq -n '{ + systemMessage: "Before implementing: check if a native tool, CLI, module, or existing function handles this. Use Context7 MCP for library docs. Check the direct-execution alternatives table. Script files are blocked by hooks unless placed in scripts/, hooks/, .github/, or tests/ directories." + }' +fi + +exit 0 diff --git a/script-guards/scripts/write-script-guard.sh b/script-guards/scripts/write-script-guard.sh new file mode 100755 index 0000000..402ed31 --- /dev/null +++ b/script-guards/scripts/write-script-guard.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# write-script-guard.sh - PreToolUse hook to prevent unnecessary script file creation +# +# Two-stage guard: +# Stage 1 (instant): Skip non-script files by extension/shebang +# Stage 2 (for suspected scripts): Allow known directories, existing files, +# then consult local MLX model for nuanced evaluation +# +# Exit codes: 0=allow, 2=deny + +set -euo pipefail + +# Read JSON input from stdin (fail-open if jq fails) +input=$(cat) +file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || exit 0 +content=$(echo "$input" | jq -r '.tool_input.content // empty' 2>/dev/null) || exit 0 + +# If no file path, allow (fail-open) +if [[ -z "$file_path" ]]; then + exit 0 +fi + +# --- Stage 1: Fast pattern check --- + +# Extract file extension (lowercase, tr for macOS bash 3.x compatibility) +extension=$(echo "${file_path##*.}" | tr '[:upper:]' '[:lower:]') + +# If NOT a script extension AND no shebang, allow immediately (most files exit here) +case "$extension" in + sh|py|rb|pl|js|bash|zsh|fish) ;; # fall through to stage 2 + *) [[ "$content" == "#!"* ]] || exit 0 ;; +esac + +# --- Stage 2: Evaluate suspected scripts --- + +# Allow files in known script directories +case "$file_path" in + */scripts/*|*/hooks/*|*/.github/*|*/tests/*|*/test/*|*/plugins/*|*/.claude/plugins/*) exit 0 ;; +esac + +# Expand ~ to $HOME for path normalization (Claude Code may pass ~ paths) +file_path="${file_path/#\~/$HOME}" + +# Check if file already exists (editing existing scripts is fine) +if [[ -f "$file_path" ]]; then + exit 0 +fi + +# Consult local MLX model for nuanced evaluation +# Fail-open: if curl fails or model is unreachable, allow +response=$(curl -s --max-time 5 http://localhost:11434/v1/chat/completions \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg fp "$file_path" \ + '{ + model: "mlx-community/Qwen3.5-27B-4bit", + messages: [{ + role: "user", + content: ("You are a script-prevention guardrail. A file is being created at: " + $fp + "\n\nIs this a legitimate committed artifact (CI workflow, plugin hook, test fixture, build tool) or an unnecessary custom script?\n\nRespond with ONLY '\''allow'\'' or '\''deny'\'' followed by a brief reason.") + }], + max_tokens: 100, + temperature: 0 + }')" 2>/dev/null) || { exit 0; } + +# Parse response (fail-open on parse errors) +decision=$(echo "$response" | jq -r '.choices[0].message.content // empty' 2>/dev/null) || { exit 0; } + +# Check if response starts with "deny" (case-insensitive) +if echo "$decision" | head -1 | grep -qi '^deny'; then + reason=$(echo "$decision" | sed 's/^[Dd]eny[[:space:]]*//') + jq -n --arg fp "$file_path" --arg reason "$reason" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: ("BLOCKED: Script creation at \(.fp) was denied.\n\nReason: \(.reason)\n\nUse existing tools, CLIs, or native patterns instead of creating new scripts. If this script is a legitimate committed artifact, place it in scripts/, hooks/, .github/, or tests/ directories.") + } + }' >&2 + exit 2 +fi + +# Allow by default (model said allow, or response could not be parsed) +exit 0