Cloudflare Wrangler Config Guard Hook for Claude Code
Read-only Claude Code PostToolUse hook that checks edited Cloudflare Wrangler config files for required Worker fields, environment inheritance traps, secret-like vars, deprecated Workers Sites usage, and source-of-truth drift before Claude continues.
Open the source and read safety notes before installing.
Safety notes
- This hook is static and read-only. It reads the edited Wrangler config file and does not run `wrangler deploy`, `wrangler dev`, `wrangler whoami`, `wrangler secret list`, package scripts, build plugins, or network commands.
- The hook exits non-zero only for parse failures or missing required top-level Worker fields such as `name` and `compatibility_date`; warnings remain advisory so the user can review them.
- The TOML checks are heuristic and do not replace Wrangler's own parser or a trusted pre-deploy dry run. Use them as a fast edit-time guard, then run official Wrangler validation in a trusted repository.
- Do not expand this hook into a dry-run deploy hook unless the team has reviewed local build-script execution, inherited environment variables, and output-directory cleanup.
- Keep matchers scoped to config file edits. Running config checks after every tool call adds noise without improving Cloudflare safety.
Privacy notes
- The hook reads `wrangler.toml`, `wrangler.json`, or `wrangler.jsonc`; those files can contain worker names, route patterns, account identifiers, resource IDs, binding names, environment names, analytics settings, and non-secret vars.
- Hook output may include file paths, config key names, environment names, and secret-like variable names, so avoid pasting terminal logs into public issue comments without review.
- If a Wrangler config currently contains real secrets in `vars`, the hook can reveal the key names and the surrounding configuration context. Move sensitive values to Wrangler secrets before sharing output.
Prerequisites
- Claude Code project where hooks are allowed by user or project policy.
- Cloudflare Workers or Pages project with `wrangler.toml`, `wrangler.json`, or `wrangler.jsonc` checked into the repository.
- `jq` available locally to parse Claude Code hook input.
- Node.js available locally for static JSON/JSONC/TOML heuristics.
- A reviewed `.claude/settings.json` or user settings hook configuration scoped to `Write`, `Edit`, and `MultiEdit`.
Schema details
- Install type
- cli
- Reading time
- 9 min
- Difficulty score
- 68
- Troubleshooting
- Yes
- Breaking changes
- No
- Scope
- Source repo
- Trigger
- PostToolUse
- Script language
- bash
Script body
#!/usr/bin/env bash
set -uo pipefail
input="$(cat)"
if ! command -v jq >/dev/null 2>&1; then
echo "Wrangler config guard skipped: jq is required to parse Claude Code hook input." >&2
exit 0
fi
if ! command -v node >/dev/null 2>&1; then
echo "Wrangler config guard skipped: Node.js is required for static config checks." >&2
exit 0
fi
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // .toolName // empty')"
file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // .toolInput.file_path // .toolInput.path // empty')"
case "$tool_name" in
Write|Edit|MultiEdit|write|edit|multiedit) ;;
*) exit 0 ;;
esac
case "$(basename "$file_path")" in
wrangler.toml|wrangler.json|wrangler.jsonc) ;;
*) exit 0 ;;
esac
if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then
exit 0
fi
WRANGLER_CONFIG_PATH="$file_path" node <<'NODE'
const fs = require("node:fs");
const path = process.env.WRANGLER_CONFIG_PATH;
const raw = fs.readFileSync(path, "utf8");
const name = path.split(/[\\/]/).pop();
const errors = [];
const warnings = [];
function stripJsonc(value) {
let out = "";
let inString = false;
let quote = "";
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
const next = value[i + 1];
if (lineComment) {
if (char === "\n") {
lineComment = false;
out += char;
}
continue;
}
if (blockComment) {
if (char === "*" && next === "/") {
blockComment = false;
i += 1;
}
continue;
}
if (inString) {
out += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
inString = false;
}
continue;
}
if (char === "\"" || char === "'") {
inString = true;
quote = char;
out += char;
continue;
}
if (char === "/" && next === "/") {
lineComment = true;
i += 1;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
i += 1;
continue;
}
out += char;
}
return out.replace(/,\s*([}\]])/g, "$1");
}
function looksSecretLike(key) {
return /(secret|token|password|private|credential|api[_-]?key|client[_-]?secret)/i.test(key);
}
function checkConfigObject(config) {
if (!config || typeof config !== "object" || Array.isArray(config)) {
errors.push("Config did not parse to an object.");
return;
}
if (!config.name) errors.push("Missing top-level `name`.");
if (!config.compatibility_date) errors.push("Missing top-level `compatibility_date`.");
if (!config.main && !config.assets && !config.pages_build_output_dir) {
warnings.push("No `main`, `assets`, or `pages_build_output_dir` found. `main` is optional only for assets-only Workers.");
}
if (config.site) {
warnings.push("`site` is present. Workers Sites is deprecated in Wrangler v4; prefer Workers Static Assets for new projects.");
}
if (config.send_metrics !== false) {
warnings.push("`send_metrics` is not set to false. Review Cloudflare telemetry expectations for this repository.");
}
if (config.vars && typeof config.vars === "object") {
for (const key of Object.keys(config.vars)) {
if (looksSecretLike(key)) {
warnings.push(`Top-level vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
}
}
}
const bindingKeys = [
"vars",
"kv_namespaces",
"r2_buckets",
"d1_databases",
"queues",
"durable_objects",
"services",
"analytics_engine_datasets",
"vectorize",
"ai",
];
if (config.env && typeof config.env === "object") {
for (const [envName, envConfig] of Object.entries(config.env)) {
if (!envConfig || typeof envConfig !== "object") continue;
for (const key of bindingKeys) {
if (config[key] && !Object.prototype.hasOwnProperty.call(envConfig, key)) {
warnings.push(`Environment \`${envName}\` does not define \`${key}\`; Wrangler treats bindings and vars as non-inheritable.`);
}
}
if (envConfig.vars && typeof envConfig.vars === "object") {
for (const key of Object.keys(envConfig.vars)) {
if (looksSecretLike(key)) {
warnings.push(`Environment \`${envName}\` vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
}
}
}
}
}
}
function stripTomlComment(line) {
let out = "";
let inString = false;
let quote = "";
let escaped = false;
for (const char of line) {
if (inString) {
out += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
inString = false;
}
continue;
}
if (char === "\"" || char === "'") {
inString = true;
quote = char;
out += char;
continue;
}
if (char === "#") break;
out += char;
}
return out.trim();
}
function checkToml() {
const top = new Set();
const sections = new Set();
const envSections = new Set();
const envKeys = new Map();
const bindingKeys = [
"vars",
"kv_namespaces",
"r2_buckets",
"d1_databases",
"queues",
"durable_objects",
"services",
"analytics_engine_datasets",
"vectorize",
"ai",
];
let section = "";
for (const originalLine of raw.split(/\r?\n/)) {
const line = stripTomlComment(originalLine);
if (!line) continue;
const sectionMatch = line.match(/^\s*\[+\s*([A-Za-z0-9_.-]+)\s*\]+/);
if (sectionMatch) {
section = sectionMatch[1];
sections.add(section);
const envMatch = section.match(/^env\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_.-]+))?/);
if (envMatch) {
envSections.add(envMatch[1]);
if (!envKeys.has(envMatch[1])) envKeys.set(envMatch[1], new Set());
if (envMatch[2]) envKeys.get(envMatch[1]).add(envMatch[2].split(".")[0]);
}
continue;
}
const keyMatch = line.match(/^([A-Za-z0-9_$-]+)\s*=/);
if (!keyMatch) continue;
const key = keyMatch[1];
if (!section) {
top.add(key);
} else if (section === "vars" && looksSecretLike(key)) {
warnings.push(`Top-level vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
} else {
const envVarMatch = section.match(/^env\.([A-Za-z0-9_-]+)\.vars$/);
if (envVarMatch && looksSecretLike(key)) {
warnings.push(`Environment \`${envVarMatch[1]}\` vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
}
}
}
if (!top.has("name")) errors.push("Missing top-level `name`.");
if (!top.has("compatibility_date")) errors.push("Missing top-level `compatibility_date`.");
if (!top.has("main") && !sections.has("assets") && !top.has("pages_build_output_dir")) {
warnings.push("No `main`, `[assets]`, or `pages_build_output_dir` found. `main` is optional only for assets-only Workers.");
}
if (sections.has("site")) {
warnings.push("`site` is present. Workers Sites is deprecated in Wrangler v4; prefer Workers Static Assets for new projects.");
}
if (!top.has("send_metrics")) {
warnings.push("`send_metrics` is not set. Review Cloudflare telemetry expectations for this repository.");
}
for (const envName of envSections) {
const keys = envKeys.get(envName) || new Set();
for (const key of bindingKeys) {
if (sections.has(key) && !keys.has(key)) {
warnings.push(`Environment \`${envName}\` does not define \`${key}\`; Wrangler treats bindings and vars as non-inheritable.`);
}
}
}
}
console.error(`Wrangler config guard: checking ${name}`);
try {
if (name === "wrangler.json" || name === "wrangler.jsonc") {
checkConfigObject(JSON.parse(stripJsonc(raw)));
} else {
checkToml();
}
} catch (error) {
errors.push(`Parse failed: ${error.message}`);
}
for (const warning of warnings) console.error(`[warn] ${warning}`);
for (const error of errors) console.error(`[error] ${error}`);
if (!warnings.length && !errors.length) {
console.error("Wrangler config guard: no issues found.");
}
process.exit(errors.length ? 1 : 0);
NODEFull copyable content
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/wrangler-config-guard.sh"
}
]
}
]
}
}About this resource
Overview
This hook checks Cloudflare Wrangler config files immediately after Claude Code edits them. It focuses on static issues that are easy to miss during AI-assisted config changes:
- Required top-level Worker fields such as
nameandcompatibility_date. - Missing entrypoint/static-asset hints when neither
main,assets, norpages_build_output_diris present. - Environment binding drift for
vars, KV, R2, D1, queues, Durable Objects, services, analytics datasets, Vectorize, and Workers AI. - Secret-like keys placed in
varsinstead of Wrangler secrets. - Deprecated Workers Sites
siteconfiguration. - Project telemetry review via
send_metrics.
It deliberately does not run Wrangler. That keeps the hook useful for reviewing
untrusted or partially reviewed config edits, because wrangler deploy --dry-run
can still execute local build commands and bundler plugins.
Requirements
- Claude Code hooks enabled in user or project settings.
- A Cloudflare Workers or Pages project with a Wrangler config file.
jqinstalled locally.- Node.js installed locally.
- A reviewed hook configuration scoped to
Write,Edit, andMultiEdit.
Hook Configuration
Add the hook command to .claude/settings.json or the appropriate user-level
Claude Code settings file:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "./.claude/hooks/wrangler-config-guard.sh"
}
]
}
]
}
}
Hook Script
Save this as .claude/hooks/wrangler-config-guard.sh and make it executable:
#!/usr/bin/env bash
set -uo pipefail
input="$(cat)"
if ! command -v jq >/dev/null 2>&1; then
echo "Wrangler config guard skipped: jq is required to parse Claude Code hook input." >&2
exit 0
fi
if ! command -v node >/dev/null 2>&1; then
echo "Wrangler config guard skipped: Node.js is required for static config checks." >&2
exit 0
fi
tool_name="$(printf '%s' "$input" | jq -r '.tool_name // .toolName // empty')"
file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // .toolInput.file_path // .toolInput.path // empty')"
case "$tool_name" in
Write|Edit|MultiEdit|write|edit|multiedit) ;;
*) exit 0 ;;
esac
case "$(basename "$file_path")" in
wrangler.toml|wrangler.json|wrangler.jsonc) ;;
*) exit 0 ;;
esac
if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then
exit 0
fi
WRANGLER_CONFIG_PATH="$file_path" node <<'NODE'
const fs = require("node:fs");
const path = process.env.WRANGLER_CONFIG_PATH;
const raw = fs.readFileSync(path, "utf8");
const name = path.split(/[\\/]/).pop();
const errors = [];
const warnings = [];
function stripJsonc(value) {
let out = "";
let inString = false;
let quote = "";
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let i = 0; i < value.length; i += 1) {
const char = value[i];
const next = value[i + 1];
if (lineComment) {
if (char === "\n") {
lineComment = false;
out += char;
}
continue;
}
if (blockComment) {
if (char === "*" && next === "/") {
blockComment = false;
i += 1;
}
continue;
}
if (inString) {
out += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
inString = false;
}
continue;
}
if (char === "\"" || char === "'") {
inString = true;
quote = char;
out += char;
continue;
}
if (char === "/" && next === "/") {
lineComment = true;
i += 1;
continue;
}
if (char === "/" && next === "*") {
blockComment = true;
i += 1;
continue;
}
out += char;
}
return out.replace(/,\s*([}\]])/g, "$1");
}
function looksSecretLike(key) {
return /(secret|token|password|private|credential|api[_-]?key|client[_-]?secret)/i.test(key);
}
function checkConfigObject(config) {
if (!config || typeof config !== "object" || Array.isArray(config)) {
errors.push("Config did not parse to an object.");
return;
}
if (!config.name) errors.push("Missing top-level `name`.");
if (!config.compatibility_date) errors.push("Missing top-level `compatibility_date`.");
if (!config.main && !config.assets && !config.pages_build_output_dir) {
warnings.push("No `main`, `assets`, or `pages_build_output_dir` found. `main` is optional only for assets-only Workers.");
}
if (config.site) {
warnings.push("`site` is present. Workers Sites is deprecated in Wrangler v4; prefer Workers Static Assets for new projects.");
}
if (config.send_metrics !== false) {
warnings.push("`send_metrics` is not set to false. Review Cloudflare telemetry expectations for this repository.");
}
if (config.vars && typeof config.vars === "object") {
for (const key of Object.keys(config.vars)) {
if (looksSecretLike(key)) {
warnings.push(`Top-level vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
}
}
}
const bindingKeys = [
"vars",
"kv_namespaces",
"r2_buckets",
"d1_databases",
"queues",
"durable_objects",
"services",
"analytics_engine_datasets",
"vectorize",
"ai",
];
if (config.env && typeof config.env === "object") {
for (const [envName, envConfig] of Object.entries(config.env)) {
if (!envConfig || typeof envConfig !== "object") continue;
for (const key of bindingKeys) {
if (config[key] && !Object.prototype.hasOwnProperty.call(envConfig, key)) {
warnings.push(`Environment \`${envName}\` does not define \`${key}\`; Wrangler treats bindings and vars as non-inheritable.`);
}
}
if (envConfig.vars && typeof envConfig.vars === "object") {
for (const key of Object.keys(envConfig.vars)) {
if (looksSecretLike(key)) {
warnings.push(`Environment \`${envName}\` vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
}
}
}
}
}
}
function stripTomlComment(line) {
let out = "";
let inString = false;
let quote = "";
let escaped = false;
for (const char of line) {
if (inString) {
out += char;
if (escaped) {
escaped = false;
} else if (char === "\\") {
escaped = true;
} else if (char === quote) {
inString = false;
}
continue;
}
if (char === "\"" || char === "'") {
inString = true;
quote = char;
out += char;
continue;
}
if (char === "#") break;
out += char;
}
return out.trim();
}
function checkToml() {
const top = new Set();
const sections = new Set();
const envSections = new Set();
const envKeys = new Map();
const bindingKeys = [
"vars",
"kv_namespaces",
"r2_buckets",
"d1_databases",
"queues",
"durable_objects",
"services",
"analytics_engine_datasets",
"vectorize",
"ai",
];
let section = "";
for (const originalLine of raw.split(/\r?\n/)) {
const line = stripTomlComment(originalLine);
if (!line) continue;
const sectionMatch = line.match(/^\s*\[+\s*([A-Za-z0-9_.-]+)\s*\]+/);
if (sectionMatch) {
section = sectionMatch[1];
sections.add(section);
const envMatch = section.match(/^env\.([A-Za-z0-9_-]+)(?:\.([A-Za-z0-9_.-]+))?/);
if (envMatch) {
envSections.add(envMatch[1]);
if (!envKeys.has(envMatch[1])) envKeys.set(envMatch[1], new Set());
if (envMatch[2]) envKeys.get(envMatch[1]).add(envMatch[2].split(".")[0]);
}
continue;
}
const keyMatch = line.match(/^([A-Za-z0-9_$-]+)\s*=/);
if (!keyMatch) continue;
const key = keyMatch[1];
if (!section) {
top.add(key);
} else if (section === "vars" && looksSecretLike(key)) {
warnings.push(`Top-level vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
} else {
const envVarMatch = section.match(/^env\.([A-Za-z0-9_-]+)\.vars$/);
if (envVarMatch && looksSecretLike(key)) {
warnings.push(`Environment \`${envVarMatch[1]}\` vars contains secret-like key \`${key}\`; use Wrangler secrets for sensitive values.`);
}
}
}
if (!top.has("name")) errors.push("Missing top-level `name`.");
if (!top.has("compatibility_date")) errors.push("Missing top-level `compatibility_date`.");
if (!top.has("main") && !sections.has("assets") && !top.has("pages_build_output_dir")) {
warnings.push("No `main`, `[assets]`, or `pages_build_output_dir` found. `main` is optional only for assets-only Workers.");
}
if (sections.has("site")) {
warnings.push("`site` is present. Workers Sites is deprecated in Wrangler v4; prefer Workers Static Assets for new projects.");
}
if (!top.has("send_metrics")) {
warnings.push("`send_metrics` is not set. Review Cloudflare telemetry expectations for this repository.");
}
for (const envName of envSections) {
const keys = envKeys.get(envName) || new Set();
for (const key of bindingKeys) {
if (sections.has(key) && !keys.has(key)) {
warnings.push(`Environment \`${envName}\` does not define \`${key}\`; Wrangler treats bindings and vars as non-inheritable.`);
}
}
}
}
console.error(`Wrangler config guard: checking ${name}`);
try {
if (name === "wrangler.json" || name === "wrangler.jsonc") {
checkConfigObject(JSON.parse(stripJsonc(raw)));
} else {
checkToml();
}
} catch (error) {
errors.push(`Parse failed: ${error.message}`);
}
for (const warning of warnings) console.error(`[warn] ${warning}`);
for (const error of errors) console.error(`[error] ${error}`);
if (!warnings.length && !errors.length) {
console.error("Wrangler config guard: no issues found.");
}
process.exit(errors.length ? 1 : 0);
NODE
How It Works
- Reads Claude Code hook input from
stdin. - Ignores non-edit tools.
- Runs only for files named
wrangler.toml,wrangler.json, orwrangler.jsonc. - Parses JSON and JSONC with a small comment/trailing-comma stripper.
- Parses TOML with conservative section/key heuristics.
- Reports required-field errors and advisory warnings.
- Exits non-zero only when a parse or required-field error is found.
Safety Notes
This hook is intentionally narrower than a Cloudflare deploy-readiness command. It does not authenticate to Cloudflare, list secrets, execute a dry run, run project package scripts, invoke bundlers, write output directories, or contact the network. That makes it suitable as a first-line edit guard before any trusted pre-deploy workflow.
The tradeoff is that it is not a full Wrangler validator. TOML parsing is heuristic, and Cloudflare can add new config keys over time. Treat warnings as review prompts, not a complete deployment decision.
Privacy Notes
Wrangler config files are not always harmless. They can expose worker names,
route patterns, account identifiers, resource IDs, environment names, analytics
preferences, and non-secret vars. If the hook reports secret-like vars, move
those values to Wrangler secrets before sharing logs or PR comments.
Source Notes
- Cloudflare documents
wrangler.toml,wrangler.json, andwrangler.jsoncas supported config formats and recommendswrangler.jsoncfor new projects. - The Wrangler configuration docs describe the config file as the Worker source
of truth and show required fields such as
name,main, andcompatibility_date. - Cloudflare documents bindings and environment variables as non-inheritable, meaning environments should define their own binding values where applicable.
- The workers-sdk repository is the official Wrangler source repository.
Duplicate Check
This entry is distinct from the existing /deploy-readiness command, which can
run Wrangler auth, secret-list, and deploy dry-run checks in a trusted project.
This hook is limited to static config-file review after Claude edits
wrangler.toml, wrangler.json, or wrangler.jsonc.
Existing Cloudflare skills and guides cover broader Workers development and
deployment workflows. This entry is specifically a Claude Code PostToolUse
hook for edit-time Wrangler config safety.
Troubleshooting
- If the hook never runs, confirm the settings file uses
PostToolUsewith aWrite|Edit|MultiEditmatcher. - If it prints a
jqmessage, installjqor replace the shell parser with a project-approved hook wrapper. - If it prints a Node.js message, install Node.js or move the static checker into a language already required by the project.
- If TOML warnings are too noisy, keep the static hook as advisory and run the official Wrangler checks in a trusted pre-deploy workflow.
Source citations
Signals
Loading live community signals…
A short, calm digest of reviewed Claude resources. Unsubscribe any time.