Skip to content

Instantly share code, notes, and snippets.

@jorgemanrubia
Last active July 14, 2026 11:51
Show Gist options
  • Select an option

  • Save jorgemanrubia/b5638d601a879958b13a9e7c741be881 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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
# 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