From bf9fcfd25b4441d01a17e806981a7a7f5bf8d330 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:52:13 -0400 Subject: [PATCH 1/5] feat: add script-guards plugin for runtime script prevention (claude) --- script-guards/.claude-plugin/plugin.json | 9 ++ script-guards/hooks/hooks.json | 48 +++++++++ script-guards/scripts/bash-script-guard.sh | 63 ++++++++++++ script-guards/scripts/inline-script-guard.sh | 70 +++++++++++++ script-guards/scripts/research-reminder.sh | 40 ++++++++ script-guards/scripts/write-script-guard.sh | 100 +++++++++++++++++++ 6 files changed, 330 insertions(+) create mode 100644 script-guards/.claude-plugin/plugin.json create mode 100644 script-guards/hooks/hooks.json create mode 100755 script-guards/scripts/bash-script-guard.sh create mode 100755 script-guards/scripts/inline-script-guard.sh create mode 100755 script-guards/scripts/research-reminder.sh create mode 100755 script-guards/scripts/write-script-guard.sh 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..2a114d1 --- /dev/null +++ b/script-guards/scripts/bash-script-guard.sh @@ -0,0 +1,63 @@ +#!/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 +input=$(cat) + +# Extract command using jq (fail-open if jq fails) +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 + +# Check for file-writing patterns to script files +if echo "$command" | grep -qE '(cat|tee|echo|printf)\s+(>>?|>)\s+\S+\.(sh|py|rb|pl|js|bash)'; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "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." + } + }' >&2 + exit 2 +fi + +# Check for heredoc patterns (cat <<) +if echo "$command" | grep -qE 'cat\s+<<'; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "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." + } + }' >&2 + exit 2 +fi + +# Check for chmod +x on non-existent files +if echo "$command" | grep -qE 'chmod\s+\+x\s+'; then + # Extract the file path after chmod +x + target_file=$(echo "$command" | grep -oE 'chmod\s+\+x\s+\S+' | head -1 | sed 's/chmod\s\+\+x\s\+//') + if [[ -n "$target_file" ]] && [[ ! -f "$target_file" ]]; then + jq -n '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: "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/)." + } + }' >&2 + exit 2 + 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..b129a05 --- /dev/null +++ b/script-guards/scripts/inline-script-guard.sh @@ -0,0 +1,70 @@ +#!/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 +input=$(cat) + +# Extract file_path and new_string using jq (fail-open if jq fails) +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) +extension="${file_path##*.}" +extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]') + +# --- Check .nix files for inline shell scripts --- +if [[ "$extension" == "nix" ]]; then + # Count lines containing shell keywords + shell_keyword_count=0 + while IFS= read -r line; do + if echo "$line" | grep -qE '\b(if|then|else|fi|for|while|do|done|case|esac)\b|&&|\|\||[^|]\|[^|]'; then + shell_keyword_count=$((shell_keyword_count + 1)) + fi + done <<< "$new_string" + + 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: >) + if echo "$new_string" | grep -qE 'run:\s*[|>]'; then + # Count lines after the run: | or run: > marker + run_block_lines=$(echo "$new_string" | sed -n '/run:\s*[|>]/,/^[^ ]/p' | wc -l) + + 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..5948508 --- /dev/null +++ b/script-guards/scripts/research-reminder.sh @@ -0,0 +1,40 @@ +#!/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 +input=$(cat) + +# Extract the user prompt (fail-open if jq fails) +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) +prompt_lower=$(echo "$prompt" | tr '[:upper:]' '[:lower:]') + +keywords="create write build implement add make generate automate script function helper utility tool wrapper" +found=false +for keyword in $keywords; do + if echo "$prompt_lower" | grep -qw "$keyword"; then + found=true + break + fi +done + +if [[ "$found" == true ]]; 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..4e1e68f --- /dev/null +++ b/script-guards/scripts/write-script-guard.sh @@ -0,0 +1,100 @@ +#!/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 +input=$(cat) + +# Extract file_path and content using jq (fail-open if jq fails) +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) +extension="${file_path##*.}" +extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]') + +# Known script extensions +script_extensions="sh py rb pl js bash zsh fish" +is_script_ext=false +for ext in $script_extensions; do + if [[ "$extension" == "$ext" ]]; then + is_script_ext=true + break + fi +done + +# Check for shebang in content +has_shebang=false +if [[ "$content" == "#!"* ]]; then + has_shebang=true +fi + +# If NOT a script extension AND no shebang, allow immediately (most files exit here) +if [[ "$is_script_ext" == false ]] && [[ "$has_shebang" == false ]]; then + exit 0 +fi + +# --- Stage 2: Evaluate suspected scripts --- + +# Check if path contains allowed directories +allowed_dirs="/scripts/ /hooks/ /.github/ /tests/ /test/ /plugin /.claude/plugins/" +for dir in $allowed_dirs; do + if [[ "$file_path" == *"$dir"* ]]; then + exit 0 + fi +done + +# 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 From b76fa6e13837cfa0e1132f3d5a84ab02063a9b77 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:14:15 -0400 Subject: [PATCH 2/5] fix: add trailing slash to /plugin pattern in write-script-guard The /plugin entry lacked a trailing slash, meaning it would match unintended paths like /pluginable/ or /plugin-foo/. Changed to /plugins/ which is more precise and covers the intended use case. (claude) --- script-guards/scripts/write-script-guard.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script-guards/scripts/write-script-guard.sh b/script-guards/scripts/write-script-guard.sh index 4e1e68f..03514da 100755 --- a/script-guards/scripts/write-script-guard.sh +++ b/script-guards/scripts/write-script-guard.sh @@ -52,7 +52,7 @@ fi # --- Stage 2: Evaluate suspected scripts --- # Check if path contains allowed directories -allowed_dirs="/scripts/ /hooks/ /.github/ /tests/ /test/ /plugin /.claude/plugins/" +allowed_dirs="/scripts/ /hooks/ /.github/ /tests/ /test/ /plugins/ /.claude/plugins/" for dir in $allowed_dirs; do if [[ "$file_path" == *"$dir"* ]]; then exit 0 From a8e46949c2571291971eb5b38ebe86fb4f4547fc Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:16:06 -0400 Subject: [PATCH 3/5] fix: simplify regex and keyword matching in hook scripts - bash-script-guard: remove redundant alternation in redirect regex (>>?|> simplified to >>? since >>? already matches both > and >>) - research-reminder: replace per-keyword loop with single grep -wE alternation (14 grep processes reduced to 1) (claude) --- script-guards/scripts/bash-script-guard.sh | 2 +- script-guards/scripts/research-reminder.sh | 11 +---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/script-guards/scripts/bash-script-guard.sh b/script-guards/scripts/bash-script-guard.sh index 2a114d1..9254e1b 100755 --- a/script-guards/scripts/bash-script-guard.sh +++ b/script-guards/scripts/bash-script-guard.sh @@ -20,7 +20,7 @@ if [[ -z "$command" ]]; then fi # Check for file-writing patterns to script files -if echo "$command" | grep -qE '(cat|tee|echo|printf)\s+(>>?|>)\s+\S+\.(sh|py|rb|pl|js|bash)'; then +if echo "$command" | grep -qE '(cat|tee|echo|printf)\s+>>?\s+\S+\.(sh|py|rb|pl|js|bash)'; then jq -n '{ hookSpecificOutput: { hookEventName: "PreToolUse", diff --git a/script-guards/scripts/research-reminder.sh b/script-guards/scripts/research-reminder.sh index 5948508..48defa3 100755 --- a/script-guards/scripts/research-reminder.sh +++ b/script-guards/scripts/research-reminder.sh @@ -22,16 +22,7 @@ fi # Check for implementation keywords (case-insensitive) prompt_lower=$(echo "$prompt" | tr '[:upper:]' '[:lower:]') -keywords="create write build implement add make generate automate script function helper utility tool wrapper" -found=false -for keyword in $keywords; do - if echo "$prompt_lower" | grep -qw "$keyword"; then - found=true - break - fi -done - -if [[ "$found" == true ]]; then +if echo "$prompt_lower" | grep -qwE '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." }' From df7506acef98ce05d80fd77a9a0a4a712432777b Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:23:53 -0400 Subject: [PATCH 4/5] fix(script-guards): BSD compatibility and code simplification Replace GNU-only \s/\S with POSIX [[:space:]]/[^[:space:]] in all grep and sed patterns for macOS BSD tool compatibility. Simplify write-script-guard extension/directory matching with case statements, replace per-line grep loop with grep -c in inline-script-guard, extract deny helper in bash-script-guard, and use grep -i instead of tr-lowercasing in research-reminder. Scope heredoc check to script file extensions only (was blocking all cat << patterns). (claude) --- script-guards/scripts/bash-script-guard.sh | 54 +++++++++----------- script-guards/scripts/inline-script-guard.sh | 32 +++++------- script-guards/scripts/research-reminder.sh | 12 ++--- script-guards/scripts/write-script-guard.sh | 47 +++++------------ 4 files changed, 55 insertions(+), 90 deletions(-) diff --git a/script-guards/scripts/bash-script-guard.sh b/script-guards/scripts/bash-script-guard.sh index 9254e1b..770a3ac 100755 --- a/script-guards/scripts/bash-script-guard.sh +++ b/script-guards/scripts/bash-script-guard.sh @@ -8,54 +8,48 @@ set -euo pipefail -# Read JSON input from stdin +# Read JSON input from stdin (fail-open if jq fails) input=$(cat) - -# Extract command using jq (fail-open if jq fails) -command=$(echo "$input" | jq -r '.tool_input.command // empty' 2>/dev/null) || { exit 0; } +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 -# Check for file-writing patterns to script files -if echo "$command" | grep -qE '(cat|tee|echo|printf)\s+>>?\s+\S+\.(sh|py|rb|pl|js|bash)'; then - jq -n '{ +# Shared deny helper to reduce duplication +deny() { + jq -n --arg reason "$1" '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", - permissionDecisionReason: "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." + 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 (cat <<) -if echo "$command" | grep -qE 'cat\s+<<'; then - jq -n '{ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: "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." - } - }' >&2 - exit 2 +# 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 -if echo "$command" | grep -qE 'chmod\s+\+x\s+'; then - # Extract the file path after chmod +x - target_file=$(echo "$command" | grep -oE 'chmod\s+\+x\s+\S+' | head -1 | sed 's/chmod\s\+\+x\s\+//') +# 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 - jq -n '{ - hookSpecificOutput: { - hookEventName: "PreToolUse", - permissionDecision: "deny", - permissionDecisionReason: "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/)." - } - }' >&2 - exit 2 + 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 diff --git a/script-guards/scripts/inline-script-guard.sh b/script-guards/scripts/inline-script-guard.sh index b129a05..ad94613 100755 --- a/script-guards/scripts/inline-script-guard.sh +++ b/script-guards/scripts/inline-script-guard.sh @@ -8,31 +8,25 @@ set -euo pipefail -# Read JSON input from stdin +# Read JSON input from stdin (fail-open if jq fails) input=$(cat) - -# Extract file_path and new_string using jq (fail-open if jq fails) -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; } +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) -extension="${file_path##*.}" -extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]') +# 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 keywords - shell_keyword_count=0 - while IFS= read -r line; do - if echo "$line" | grep -qE '\b(if|then|else|fi|for|while|do|done|case|esac)\b|&&|\|\||[^|]\|[^|]'; then - shell_keyword_count=$((shell_keyword_count + 1)) - fi - done <<< "$new_string" + # 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" '{ @@ -49,9 +43,11 @@ fi # --- Check .yml/.yaml files for complex inline bash --- if [[ "$extension" == "yml" ]] || [[ "$extension" == "yaml" ]]; then # Check for multiline run blocks (run: | or run: >) - if echo "$new_string" | grep -qE 'run:\s*[|>]'; then - # Count lines after the run: | or run: > marker - run_block_lines=$(echo "$new_string" | sed -n '/run:\s*[|>]/,/^[^ ]/p' | wc -l) + # Uses [[:space:]] for BSD sed/grep compatibility on macOS + if echo "$new_string" | grep -qE 'run:[[:space:]]*[|>]'; then + # Count lines in the run block (from run: marker to next unindented line) + run_block_lines=$(echo "$new_string" \ + | sed -n '/run:[[:space:]]*[|>]/,/^[^ ]/p' | wc -l) if [[ "$run_block_lines" -gt 5 ]]; then jq -n --arg fp "$file_path" '{ diff --git a/script-guards/scripts/research-reminder.sh b/script-guards/scripts/research-reminder.sh index 48defa3..74c12e6 100755 --- a/script-guards/scripts/research-reminder.sh +++ b/script-guards/scripts/research-reminder.sh @@ -8,21 +8,17 @@ set -euo pipefail -# Read JSON input from stdin +# Read JSON input from stdin (fail-open if jq fails) input=$(cat) - -# Extract the user prompt (fail-open if jq fails) -prompt=$(echo "$input" | jq -r '.tool_input.prompt // .tool_input.content // .prompt // .content // empty' 2>/dev/null) || { exit 0; } +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) -prompt_lower=$(echo "$prompt" | tr '[:upper:]' '[:lower:]') - -if echo "$prompt_lower" | grep -qwE 'create|write|build|implement|add|make|generate|automate|script|function|helper|utility|tool|wrapper'; then +# 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." }' diff --git a/script-guards/scripts/write-script-guard.sh b/script-guards/scripts/write-script-guard.sh index 03514da..00283a3 100755 --- a/script-guards/scripts/write-script-guard.sh +++ b/script-guards/scripts/write-script-guard.sh @@ -10,12 +10,10 @@ set -euo pipefail -# Read JSON input from stdin +# Read JSON input from stdin (fail-open if jq fails) input=$(cat) - -# Extract file_path and content using jq (fail-open if jq fails) -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; } +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 @@ -24,40 +22,21 @@ fi # --- Stage 1: Fast pattern check --- -# Extract file extension (lowercase) -extension="${file_path##*.}" -extension=$(echo "$extension" | tr '[:upper:]' '[:lower:]') - -# Known script extensions -script_extensions="sh py rb pl js bash zsh fish" -is_script_ext=false -for ext in $script_extensions; do - if [[ "$extension" == "$ext" ]]; then - is_script_ext=true - break - fi -done - -# Check for shebang in content -has_shebang=false -if [[ "$content" == "#!"* ]]; then - has_shebang=true -fi +# 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) -if [[ "$is_script_ext" == false ]] && [[ "$has_shebang" == false ]]; then - exit 0 -fi +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 --- -# Check if path contains allowed directories -allowed_dirs="/scripts/ /hooks/ /.github/ /tests/ /test/ /plugins/ /.claude/plugins/" -for dir in $allowed_dirs; do - if [[ "$file_path" == *"$dir"* ]]; then - exit 0 - fi -done +# Allow files in known script directories +case "$file_path" in + */scripts/*|*/hooks/*|*/.github/*|*/tests/*|*/test/*|*/plugins/*|*/.claude/plugins/*) exit 0 ;; +esac # Check if file already exists (editing existing scripts is fine) if [[ -f "$file_path" ]]; then From 7c523bc359f5b4502a6fd15a2487812f036b1091 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:27:46 -0400 Subject: [PATCH 5/5] fix(script-guards): address review feedback - tilde expansion and YAML indentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ~ → $HOME path expansion in write-script-guard before existence check - Replace sed-based YAML run-block counting with indentation-aware awk parser that correctly handles indented steps in GitHub Actions workflows - Add RLENGTH to cspell dictionary (awk built-in variable) (claude) --- cspell.json | 1 + script-guards/scripts/inline-script-guard.sh | 17 +++++++++++++---- script-guards/scripts/write-script-guard.sh | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) 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/scripts/inline-script-guard.sh b/script-guards/scripts/inline-script-guard.sh index ad94613..c7e14a3 100755 --- a/script-guards/scripts/inline-script-guard.sh +++ b/script-guards/scripts/inline-script-guard.sh @@ -43,11 +43,20 @@ 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 sed/grep compatibility on macOS + # Uses [[:space:]] for BSD compatibility on macOS if echo "$new_string" | grep -qE 'run:[[:space:]]*[|>]'; then - # Count lines in the run block (from run: marker to next unindented line) - run_block_lines=$(echo "$new_string" \ - | sed -n '/run:[[:space:]]*[|>]/,/^[^ ]/p' | wc -l) + # 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" '{ diff --git a/script-guards/scripts/write-script-guard.sh b/script-guards/scripts/write-script-guard.sh index 00283a3..402ed31 100755 --- a/script-guards/scripts/write-script-guard.sh +++ b/script-guards/scripts/write-script-guard.sh @@ -38,6 +38,9 @@ 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