An environment-aware Claude Code PreToolUse hook that
guards the Railway CLI so an AI coding agent can't
accidentally redeploy production, wipe a volume, or open a shell on your live
database — while staying out of the way for read-only troubleshooting.
It is designed for running Claude Code in auto or acceptEdits mode,
where you want fewer permission prompts but still need hard stops on the
irreversible stuff.
Claude Code's static permissions.deny / ask rules match the command text.
But railway redeploy carries no environment in its text — it acts on whatever
environment the current directory is linked to. So a static rule cannot say
"deny on production, ask on staging" for the same command.
This hook resolves the target environment at call time — from an explicit
-e/--environment flag, otherwise from the Railway CLI's per-directory link in
~/.railway/config.json — and returns deny vs ask accordingly.
Note: A
PreToolUsehook anddeny/askpermission rules both apply in every permission mode, includingbypassPermissions. Hooks and rules are the durable guardrails; a mode is not.
| Command class | Examples | Decision |
|---|---|---|
| Read-only | status, logs, list, variables list, link, metrics, docs, whoami |
defer (no guard) |
| Shared-DB / shell | run, shell, connect, ssh |
deny (all envs) |
| Catastrophic / cross-env | delete (project), environment delete, service delete, volume delete, bucket delete |
deny (all envs) |
| Other mutating | up, deploy, redeploy, down, add, variable set/delete, domain mgmt, service create, environment create |
deny on a deny-env (default: production prod), ask elsewhere |
| Environment undetermined | mutating command, no -e flag, link unreadable |
deny (fail closed) |
run/shell/connect are blocked everywhere because they execute arbitrary
commands or open a shell with your live credentials injected — the highest-blast-
radius operations the CLI offers.
-
Copy the hook into your project (or point at it wherever you keep it):
mkdir -p .claude/hooks cp hooks/railway-guard.sh .claude/hooks/ chmod +x .claude/hooks/railway-guard.sh
-
Wire it into
.claude/settings.json(seeexamples/settings.json). Thepermissions.denyblock is an optional coarse second layer; the hook is the primary, environment-aware guard:{ "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/railway-guard.sh\"" } ] } ] } } -
Restart Claude Code. Hooks load at session start. Confirm with
/hooks— you should seerailway-guard.shunderPreToolUse(Bash).
Requires bash and jq.
Set these in the hook's command wrapper or your shell/settings.json env block,
e.g. to also hard-deny a prod-eu environment:
{ "type": "command",
"command": "RAILWAY_GUARD_DENY_ENVS='production prod prod-eu' \"$CLAUDE_PROJECT_DIR/.claude/hooks/railway-guard.sh\"" }- Explicit
-e <env>/--environment <env>on the command wins. - Otherwise the hook reads
RAILWAY_CONFIG_FILEand finds the linkedenvironmentNamefor the command's working directory (the link is stored per-directory). If the project is linked in exactly one place, that single environment is used. - If neither yields an environment, the hook fails closed and denies.
test/test-railway-guard.shThe harness is hermetic and inert: it feeds mock PreToolUse JSON to the
hook and checks the decision. It runs no railway commands and never reads
your real ~/.railway/config.json — it points RAILWAY_CONFIG_FILE at
test/fixtures/config.staging.json, a fake config with no credentials.
For a guarded command the hook prints a Claude Code PreToolUse decision and
exits 0:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"…"}}For deferred (read-only or non-railway) commands it prints nothing and exits 0, leaving Claude Code's normal permission flow untouched.
This hook is a policy layer — it decides deny/ask per command and environment. You can add a capability layer underneath it so production is not just denied, but unreachable.
Railway has no read-only tokens. Every token type (account / workspace / project / OAuth) grants full operational access within its scope, and tokens inherit the role of the user they're tied to. The roles are Admin (full), Member (read-write), and Deployer (deploy via GitHub only — no CLI). None is read-only, so a read-only CLI token cannot be constructed. (See Railway support.)
What you can do is scope a token to a single environment. A project
token (Project Settings → Tokens, bound to one environment) is still
read-write within that environment, but the API rejects any call against any
other environment — so a staging token literally cannot touch production.
# Project tokens use RAILWAY_TOKEN (account/workspace tokens use RAILWAY_API_TOKEN).
RAILWAY_TOKEN=<staging-project-token> railway status
RAILWAY_TOKEN=<staging-project-token> railway redeploy # -e production -> auth errorThis only adds a real boundary if the scoped token is the agent's only credential.
railway loginwrites a full-access account token to~/.railway/config.json; the CLI prefersRAILWAY_TOKENwhen set, but the login remains a fallback. For an airtight capability boundary, run the agent in a context that has onlyRAILWAY_TOKENexported and no logged-in config (e.g. a dedicated CI/automation shell). Alongside an interactive login, the scoped token adds little over the hook.
Pair the two for the strongest setup: the hook enforces the deny/ask policy, and an environment-scoped token enforces that production can't be reached at all.
- The Railway plugin/skill distributed by Railway ships an auto-approve hook
that suppresses prompts on Railway commands — the opposite of this guard. If
you install both, make sure this guard runs; a
deny/askdecision takes precedence over anallow. - The guard matches known mutating subcommands. An unknown future subcommand defaults to defer; open an issue/PR to add it.
- This is a guardrail, not a security boundary. It can reduce accidents; it can't stop a determined bypass. For a capability-level boundary add an environment-scoped token, and review actions on production regardless.
MIT — see LICENSE.
