GitHub - JackMBurch/claude-code-railway-guard: An environment-aware Claude Code PreToolUse hook for the Railway CLI. Resolves the target environment at call time - from -e or the per-directory link - so production deploys, volume deletes, and live DB shells hard-deny while read-only troubleshooting stays prompt-free. Fails closed. · GitHub
Skip to content

About

An environment-aware Claude Code PreToolUse hook for the Railway CLI. Resolves the target environment at call time - from -e or the per-directory link - so production deploys, volume deletes, and live DB shells hard-deny while read-only troubleshooting stays prompt-free. Fails closed.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

5 Commits

Folders and files

Repository files navigation

claude-code-railway-guard

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.

Why a hook (and not just permissions.deny)

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 PreToolUse hook and deny/ask permission rules both apply in every permission mode, including bypassPermissions. Hooks and rules are the durable guardrails; a mode is not.

Default policy

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.

Install

  1. 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
  2. Wire it into .claude/settings.json (see examples/settings.json). The permissions.deny block 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\"" } ] }
        ]
      }
    }
  3. Restart Claude Code. Hooks load at session start. Confirm with /hooks — you should see railway-guard.sh under PreToolUse(Bash).

Requires bash and jq.

Configuration

Env var Default Purpose
RAILWAY_GUARD_DENY_ENVS production prod Space-separated env names that are hard-denied for mutating commands. Everything else gets ask.
RAILWAY_CONFIG_FILE ~/.railway/config.json Where the Railway CLI stores the per-directory link the hook reads to resolve the active environment.

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\"" }

How environment resolution works

  1. Explicit -e <env> / --environment <env> on the command wins.
  2. Otherwise the hook reads RAILWAY_CONFIG_FILE and finds the linked environmentName for 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.
  3. If neither yields an environment, the hook fails closed and denies.

Tests

test/test-railway-guard.sh

The 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.

Output contract

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.

Defense in depth: environment-scoped tokens

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 error

This only adds a real boundary if the scoped token is the agent's only credential. railway login writes a full-access account token to ~/.railway/config.json; the CLI prefers RAILWAY_TOKEN when set, but the login remains a fallback. For an airtight capability boundary, run the agent in a context that has only RAILWAY_TOKEN exported 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.

Caveats

  • 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/ask decision takes precedence over an allow.
  • 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.

License

MIT — see LICENSE.

About

An environment-aware Claude Code PreToolUse hook for the Railway CLI. Resolves the target environment at call time - from -e or the per-directory link - so production deploys, volume deletes, and live DB shells hard-deny while read-only troubleshooting stays prompt-free. Fails closed.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages