/spend-guard
/spend-guard hooks
Block billable commands without an approval. PreToolUse hook for Bash, PowerShell and MCP tool calls. It enforces the spend-gate skill's gate at the tool-call level. A command or MCP call that matches a known billable pattern is blocked unless an approval already exists, so a billable action can't slip through even if the gate steps get skipped in the prompt.
- invocation PreToolUse hook
- source hooks/spend-guard.py
- tested lightly used
- suggested model · effort haiku · low
Install
Install just this, from a clone of the repo. It is copied, not wired: the installer prints the settings.json snippet to add.
./scripts/install-skills.sh --skill spend-guard # bash, Git Bash, macOS, Linux
.\scripts\install-skills.ps1 -Skill spend-guard # Windows PowerShell
What it does
PreToolUse hook for Bash, PowerShell and MCP tool calls. It enforces the
spend-gate skill's gate at the tool-call level. A command
or MCP call that matches a known billable pattern is blocked unless an
approval already exists, so a billable action can't slip through even if
the gate steps get skipped in the prompt.
How to invoke
mkdir -p .claude/hooks
cp <skills repo>/hooks/spend-guard.py .claude/hooks/
.claude/settings.json (merge into an existing hooks block if there is
one):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|PowerShell|mcp__.*",
"hooks": [
{
"type": "command",
"command": "python \"$CLAUDE_PROJECT_DIR/.claude/hooks/spend-guard.py\""
}
]
}
]
}
}
Self-test (no billable API/CLI is ever called — every case is in-process
with piped JSON): python hooks/spend-guard.py --self-test.
How it works
Reads the hook JSON on stdin (tool_name, tool_input.command for Bash and PowerShell, cwd).
- A
BashorPowerShellcommand is checked against a list of regexes. - Any other tool name is checked against a list of
fnmatchglobs (for MCP tool names likemcp__runpod__create-pod). - Default billable patterns:
runpodctlcreate/start(legacycreate pod/start podand v2pod|serverless|network-volume|nv create|start;template/registry createare free and stay ungated)runpodctl network-volume|nv update ... --size(resize; rename-only is allowed)runpodctl serverless|sls updaterunpodctl serverless|sls run(job submit)runpodctl pod|pods update ... --volume-in-gb|--container-disk-in-gb(storage resize; other updates allowed)runpodctl model add(uploads to model storage)- the
slsalias forcreate/update/run - global flags before the noun (
--profile x pod create,-o json serverless run) vultr-cli instance createaws ec2 run-instances|request-spot-instances|request-spot-fleet|start-instancesgcloud compute instances create|start|bulk createaz vm create|start(each with global flags before the service,aws --region x ec2 …,gcloud --project=p alpha|beta|preview compute …,az --subscription s vm …)modal deploy(also as.exe/.cmd, quoted, or full-path forms)- MCP tools matching
mcp__*runpod*__create-*(except the freecreate-template,create-secret,create-registryandcreate-delegation, which only write config or credential records; amcp:glob of your own still gates them) mcp__*runpod*__deploy-*__run-endpoint/__runsync-endpoint/__retry-job/__update-endpoint/__set-endpoint-gpus__pod-actiononly when its action isstartorresume(read fromtool_input.body.action, elsetool_input.action;stop,restart,terminateand an unrecognised shape are allowed).__update-network-volumeonly whentool_input.bodyis an object with asizekey (resize bills; name-only allowed; blocks even if the size equals the current one, since the hook can't know it).__update-podonly whentool_input.bodyis an object with adisk,templateId(applies a template's disk),mounts.persistent(always carriessize, so a path-only edit blocks too), or v1volumeInGb/containerDiskInGbkey (name/image/env/ports and network/global mounts allowed).
- There is no
runpodctl hub deploy: Hub deploys go throughserverless create --hub-id, already gated. update-global-volumeis not gated: it only takes a name, nothing billable.- Trade-off: more approval prompts, since every job submit and endpoint update now needs approval.
- Deliberately not defaulted:
fly deployand similar commands with a real free tier — add them yourself if your usage is always billable. - No match → exits 0 (allow) immediately.
- Any error while reading config or matching (bad regex, unreadable file, malformed stdin) fails open: it allows the call rather than blocking unrelated work on a hook bug. This hook is a safety net under
spend-gate, not the only gate.
Indirect forms. The patterns are also run on rewrites of the command, and a few rules cover billers that are not a CLI. All of these block:
- Aliases, functions, variables defined in the same command, substituted
into their call sites:
alias r=runpodctl; r create pod,X=runpodctl; $X create pod(also${X},"$X",A=runpod; B=ctl; $A$B),f(){ runpodctl "$@"; }; f create pod,function f { ... }, PowerShellSet-Alias r runpodctl,$X='runpodctl'; & $X create pod,function f { runpodctl @args },$a='create','pod'; runpodctl @a. The near miss (X=runpodctl; $X get pod) is allowed. - Start-Process / saps / start:
-FilePathand-ArgumentList(any order, positional or comma lists) are reassembled into<file> <args>.cmd /c,bash -c,pwsh -cwere already covered. - Quoting and encoding noise:
'run''podctl',r\unpodctl,{runpodctl,create,pod},('run'+'podctl'), PowerShell backticks,$'\x72...',(Get-Command runpodctl).Source, base64 from-EncodedCommandorbase64 -d. - REST/GraphQL writes (
curl,wget,Invoke-RestMethod/irm,Invoke-WebRequest/iwr, pythonrequests, ...).- A write is an explicit
POST/PUT/PATCHor a body flag (-d,--data*,--json,-F,-T,--post-data,-Body,-InFile). - Blocked:
- writes to
rest.runpod.ioexcepttemplates,containerregistryauth,secrets,user,billingand/stop api.runpod.ai/v2/<id>/run|runsync|...exceptcancel/purge-queue/status/health/stream- Runpod GraphQL (
api.runpod.io/graphql) mutations whose field reads deploy/rent/resume/create/save/update/edit/bid/start/launch (podFindAndDeployOnDemand,podRentInterruptable,podResume,saveEndpoint, ...) or a write whose body can't be read (-d @file) api.vultr.comwrites (except ssh-keys, firewalls, startup-scripts, domains, users, account, iso,/halt)- EC2
Action=RunInstances(and spot/fleet/start) - GCE
.../instanceswrites - Azure
Microsoft.Compute/virtualMachineswrites.
- writes to
- GETs, DELETEs,
/stopand a query-only GraphQL body (query { myself { pods { id } } }) are allowed.
- A write is an explicit
- SDK one-liners (needs
python/pyor animportin the command, sopython -candpython - <<EOFheredocs): Runpodcreate_pod,create_endpoint,resume_pod,create_network_volume,run_sync,Endpoint(..).run(..), importing one of those by name; boto3run_instances/request_spot_instances/create_instances/...; GCEinsert; Azurebegin_create_or_update; Modaldeploy/remote/spawn. - Fallback: when the command mentions a billable program (or
runpod,boto3, a provider API host), a call target or verb argument whose value can't be read blocks:$X create pod/& $c create podwith no definition in the command,eval "$C",iex $c,runpodctl $VERB,aws ec2 $ACTION,xargs runpodctl(whatever feeds it: a pipe,<<< 'pod create',< file,-a file). With no mention at all ($X create podalone) the hook can't tell and allows.
Spot and interruptible flags (--communityCloud, --bid, podRentInterruptable, "interruptible": true) don't change any of this: the create still blocks.
- A call blocked only through one of these forms is not covered by an inline
SPEND_GATE_APPROVED(that reads the raw command); use the approval file. - Every rewrite is a regex scan or a single linear pass, capped, so a 2000-flag line stays well under a second.
Approval scheme (either allows the one matched action):
- File-based, one-shot. Drop a file into
skilleddocs/spend-approvals/(path relative to the tool call'scwd; override the directory withSPEND_GATE_APPROVALS_DIR).- The filename is just a label — contents are ignored, and names starting with
.(.gitkeep,.DS_Store) never count. - The hook deletes the first file it finds (sorted by name) and allows that single call; the next matching call is blocked again until another approval file appears.
- The file is gone after use, so it is no record: the log is
skilleddocs/spend.md. - A call holding two or more billable commands (
<create> && <create>) is refused and the file kept: run them as separate calls.
- The filename is just a label — contents are ignored, and names starting with
SPEND_GATE_APPROVED=<label>. ForBash, prefix the one command:SPEND_GATE_APPROVED=yes runpodctl create pod .... The hook reads this prefix from the command text (it never runs the command), so it covers only that one invocation.- For
PowerShell, start the command with$env:SPEND_GATE_APPROVED='<label>'; <cmd>(leading only,;required, non-empty literal value, no$variables; a Bash-style prefix is not honoured). - Both inline forms count only when the rest of the call is one simple command whose program is the call's only billable match.
These are not approved:
true && <create>,<create>; <create>, a pipe,&, a newline, a subshell,$(...), backticks or a wrapper (bash -c '<create>',xargs,sudo).export SPEND_GATE_APPROVED=...never is. env SPEND_GATE_APPROVED=yes <create>is the same single command and counts.- Redirections (
> out 2>&1) are fine. - For an MCP tool call use the approval file:
exportinside a Bash tool call never reaches the hook's environment. - The hook does also honour
SPEND_GATE_APPROVEDin the environment Claude Code was launched with, but that approves every matching call for the whole session, so avoid it.
- For
This is a tripwire, not a security boundary:
- An agent that can write files can write its own approval.
- It catches a skipped gate.
- It can't stop an agent that ignores the skill.
Patterns are searched anywhere in the command string, so a command that only mentions a billable command also trips:
grep -rn 'runpodctl pod create'echo 'modal deploy app.py' >> README.md- a
git commit -mmessage naming one.
This is accepted tripwire behaviour: rephrase (e.g. put the text in a file and use git commit -F) or approve it.
Patterns are extendable, without editing the script:
- Env var
SPEND_GUARD_PATTERNS— entries separated by;(regexes can contain commas). Each entry isbash:<regex>ormcp:<glob>; no prefix defaults tobash:. - File
.claude/spend-guard-patterns.txt(relative to the tool call'scwd) — samebash:/mcp:format, one entry per line; blank lines and#comments are ignored.
# .claude/spend-guard-patterns.txt
bash:\bdoctl\s+droplets\s+create\b
mcp:mcp__*digitalocean*__create-*
Example
Example output when Claude runs a billable command with no approval in place: the hook exits 2 and prints this one line on stderr.
Bash(runpodctl create pod --gpuType RTX4090) (+1 line)
After an explicit yes, SPEND_GATE_APPROVED=yes runpodctl create pod --gpuType RTX4090
passes (exit 0, nothing printed); the next unprefixed call is blocked again.
Related
spend-gate (the skill this hook backs up).
Sample workflows
- Run the expensive tests
gap-scan → you → spend-gate → you → spend-guard → you
Install just this one:./scripts/install-skills.sh --workflow paid-testsor.\scripts\install-skills.ps1 -Workflow paid-tests
Use cases & tips from the community