-
-
Save jorgemanrubia/b5638d601a879958b13a9e7c741be881 to your computer and use it in GitHub Desktop.
Claude Code PreToolUse hook: block kamal exec arbitrary-code paths (rails runner/console/dbconsole/db: tasks/shell), allow bin/rails query. Includes --self-test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # PreToolUse (Bash) guard: block arbitrary-code-execution entry points into | |
| # containers via kamal, while leaving read-only `bin/rails query` (and | |
| # everything unrelated) alone. | |
| # | |
| # Matches ANY kamal invocation form: | |
| # kamal exec / kamal app exec / bin/kamal app exec / with --reuse, -i, -d, etc. | |
| # | |
| # Blocks (kamal + a dangerous payload): | |
| # - rails runner|r|console|c|dbconsole|db|dbc | |
| # - rails db:<task> (db:migrate, db:rollback, db:drop, …) | |
| # - rails|ruby -e '<code>' (inline eval) | |
| # - bash|sh|zsh|irb|ruby (raw shell / interpreter) | |
| # Also blocks kamal's own interactive subcommands: | |
| # - kamal [app] console|shell|dbc | |
| # | |
| # Allows: | |
| # - kamal ... bin/rails query ... (read-only inspection) | |
| # - kamal deploy / db:migrate / anything not going through kamal | |
| # | |
| # Design: PASSTHROUGH. Emits a deny decision ONLY on a match; otherwise exits 0 | |
| # with no output so the normal permission flow proceeds untouched. Never | |
| # fail-closed on unrelated commands. | |
| # | |
| # Usage: | |
| # As a Claude Code PreToolUse hook: reads the tool-call JSON on stdin. | |
| # Validate it yourself: ./block-rails-runner.sh --self-test | |
| # kamal invoked as a command word: `kamal `, `bin/kamal `, `/usr/bin/kamal `. | |
| # Requires a trailing space so `kamal-something` (a path fragment) doesn't match. | |
| is_kamal() { echo "$1" | grep -qE '(^|[[:space:]]|/)kamal[[:space:]]'; } | |
| # Dangerous payloads (arbitrary code / SQL / shell) run inside a container. | |
| is_dangerous_payload() { | |
| local c="$1" | |
| echo "$c" | grep -qE 'rails[[:space:]]+(runner|r|console|c|dbconsole|db|dbc)([[:space:]]|$)' && return 0 | |
| echo "$c" | grep -qE 'rails[[:space:]]+db:' && return 0 | |
| echo "$c" | grep -qE '(rails|ruby)\b[^|;&]*[[:space:]]-e([[:space:]]|=)' && return 0 | |
| echo "$c" | grep -qE '[[:space:]"'\''](bash|sh|zsh|irb|ruby)([[:space:]"'\'']|$)' && return 0 | |
| return 1 | |
| } | |
| # kamal's own interactive/arbitrary-execution subcommands (with or without `app`). | |
| is_kamal_shortcut() { | |
| echo "$1" | grep -qE '(^|[[:space:]]|/)kamal[[:space:]]+(app[[:space:]]+)?(console|shell|dbc)\b' | |
| } | |
| deny() { | |
| local reason="$1" | |
| cat <<JSON | |
| {"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"$reason"}} | |
| JSON | |
| } | |
| # Returns nothing to allow; prints a deny decision otherwise. | |
| evaluate() { | |
| local cmd="$1" | |
| if is_kamal "$cmd" && is_dangerous_payload "$cmd"; then | |
| deny "This kamal command runs arbitrary code/SQL/shell in the container, which is not allowed. Use 'bin/rails query' for read-only inspection instead." | |
| return | |
| fi | |
| if is_kamal_shortcut "$cmd"; then | |
| deny "kamal console/shell/dbc open an arbitrary-execution session, which is not allowed. Use 'kamal app exec bin/rails query' for read-only inspection instead." | |
| return | |
| fi | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Self-test: `./block-rails-runner.sh --self-test` | |
| # Exits 0 if every case behaves as expected, 1 otherwise. | |
| # --------------------------------------------------------------------------- | |
| self_test() { | |
| local fail=0 | |
| should_deny() { | |
| if [ -z "$(evaluate "$1")" ]; then printf ' FAIL (expected DENY): %s\n' "$1"; fail=1 | |
| else printf ' ok DENY %s\n' "$1"; fi | |
| } | |
| should_allow() { | |
| if [ -n "$(evaluate "$1")" ]; then printf ' FAIL (expected ALLOW): %s\n' "$1"; fail=1 | |
| else printf ' ok ALLOW %s\n' "$1"; fi | |
| } | |
| echo "── must DENY ──" | |
| should_deny 'cd ~/Work/basecamp/bc3 && bin/kamal app exec -d staging --reuse "bin/rails runner '\''puts 1'\''"' | |
| should_deny 'kamal app exec -d production "bin/rails runner \"puts 1\""' | |
| should_deny 'kamal exec bin/rails runner foo.rb' | |
| should_deny 'bin/kamal app exec -i --reuse bin/rails r foo.rb' | |
| should_deny 'kamal app exec bin/rails console' | |
| should_deny 'kamal exec -d production bin/rails c' | |
| should_deny 'kamal app exec bin/rails dbconsole' | |
| should_deny 'kamal app exec bin/rails db' | |
| should_deny 'kamal app exec "bin/rails db:migrate"' | |
| should_deny 'kamal exec -d production bin/rails db:rollback' | |
| should_deny 'bin/kamal app exec -d staging --reuse "bin/rails db:schema:load"' | |
| should_deny 'kamal app exec bin/rails db:drop' | |
| should_deny 'kamal app exec ruby -e "puts 1"' | |
| should_deny 'kamal app exec --interactive bash' | |
| should_deny 'kamal exec -i sh' | |
| should_deny 'kamal app exec irb' | |
| should_deny 'kamal console -d production' | |
| should_deny 'kamal app console' | |
| should_deny 'kamal shell' | |
| should_deny 'kamal dbc -d beta' | |
| echo "── must ALLOW ──" | |
| should_allow 'cd ~/Work/basecamp/bc3 && bin/kamal app exec -d staging --reuse "bin/rails query \"User.count\""' | |
| should_allow 'kamal app exec -d production "bin/rails query"' | |
| should_allow 'bin/rails runner x.rb' | |
| should_allow 'kamal deploy -d production' | |
| should_allow 'bin/rails db:migrate' | |
| should_allow 'ssh host' | |
| should_allow 'grep -e foo file.log' | |
| should_allow 'cd ~/kamal-config && ls' | |
| should_allow 'ls -la' | |
| echo | |
| if [ "$fail" -eq 0 ]; then echo "PASS — all cases behaved as expected"; else echo "FAILURES above"; fi | |
| return "$fail" | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Entry point | |
| # --------------------------------------------------------------------------- | |
| if [ "${1:-}" = "--self-test" ]; then | |
| self_test | |
| exit $? | |
| fi | |
| input=$(cat) | |
| # Extract the command; fall back to raw input if jq is unavailable. | |
| if command -v jq >/dev/null 2>&1; then | |
| cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // ""') | |
| else | |
| cmd="$input" | |
| fi | |
| evaluate "$cmd" | |
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # Diff to apply to ~/.claude/settings.json | |
| # | |
| # Only the hook-relevant addition is shown: hooks.PreToolUse, which wires up | |
| # the enforcer. The hook is the sole enforcement — no permissions allow/deny | |
| # rules are needed. | |
| # | |
| # NOTE: Claude Code rewrites settings.json with alphabetically-sorted keys, | |
| # so exact line numbers/order in your file may differ. Merge this key in | |
| # by hand rather than applying with `patch`. | |
| --- a/.claude/settings.json | |
| +++ b/.claude/settings.json | |
| @@ hooks @@ | |
| "hooks": { | |
| + "PreToolUse": [ | |
| + { | |
| + "matcher": "Bash", | |
| + "hooks": [ | |
| + { | |
| + "type": "command", | |
| + "command": "/home/jorge/.claude/hooks/block-rails-runner.sh", | |
| + "timeout": 5000 | |
| + } | |
| + ] | |
| + } | |
| + ], | |
| "Stop": [ | |
| ... your existing Stop hook, unchanged ... | |
| ] | |
| }, |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment