A curated Claude Code permission setup plus six safety hooks (and two workflow hooks). The guiding principle:
Read freely, gate on write. Investigation/read commands run without prompting; anything that changes state (writes, pushes, history rewrites, installs, deletes) still asks.
The real value is settings.example.json (the permission model) and the six safety
hooks in hooks/ that close gaps a static allowlist can't. Two extra workflow hooks
typecheck edited TypeScript at the end of each turn.
hooks/
deny-secret-access.sh # block Bash access to .env / keys / credential stores
ask-on-package-install.sh # always confirm npm/pnpm/yarn/bun installs
git-flag-guard.sh # close the `git -C` / global-flag bypass
allow-localhost-curl.sh # auto-allow localhost curl/wget, ask otherwise
find-guard.sh # auto-allow read-only find, ask on -delete/-exec/...
allow-readonly-pipeline.sh # auto-allow all-read-only pipelines (incl. read-only git + sed/awk) the built-in matcher balks at
record-ts-edit.sh # (workflow) note which .ts/.tsx files were edited this turn
typecheck-on-stop.sh # (workflow) typecheck those projects when the turn ends
settings.example.json # permissions (allow/deny/ask) + hook wiring + editorMode
keybindings.example.json # Shift+Enter = newline (so plain Enter submits)
- Claude Code
jq,bash,grep,sed(preinstalled on macOS; standard on Linux)
-
Copy the hooks into your Claude hooks directory and make them executable:
mkdir -p ~/.claude/hooks cp hooks/*.sh ~/.claude/hooks/ chmod +x ~/.claude/hooks/*.sh
-
Merge
settings.example.jsoninto~/.claude/settings.json. If you don't have one yet, copy it as a starting point. If you do, merge thepermissionsandhooksblocks (arrays are additive — don't drop your existing entries). The hook commands reference$HOME/.claude/hooks/..., so they work for any user. -
Edit the one machine-specific rule. In
permissions.allow, replace"Bash(cd /CHANGE-ME/to/your/projects/root*)"with the absolute path to where your code lives, e.g.
"Bash(cd /Users/you/code*)". (Permission rules match the literal command text, so use the real absolute path —~won't expand here.) -
(Optional) Editor mode & keybindings.
settings.example.jsonsets"editorMode": "vim"for vim-style editing in the prompt box (/config→ Editor mode to toggle, or"normal"to disable). To make plain Enter submit and Shift+Enter insert a newline, copy the keybindings template:cp keybindings.example.json ~/.claude/keybindings.jsonIn tmux/screen, Shift+Enter may be swallowed —
Ctrl+Jand\+Enter always work as newline fallbacks. Terminals like iTerm2/WezTerm/Ghostty/Kitty pass Shift+Enter through natively. -
Restart Claude Code. Hooks hot-reload, but permission rules are read at startup — changes to
allow/deny/askonly take effect after a restart (or reopening via/hooks).
The deny rules for secrets (Read(.env), Read(~/.ssh/**), Read(//**/*.pem), …) are scoped
to the Read/Edit/Write tools — Bash bypasses them, and cat/grep/rg/cp are allowlisted
with :*, so cat .env or cat ~/.ssh/id_rsa would read secrets straight to stdout. This hook
extends the same protection to Bash. When a command references a secret path (.env, id_rsa/
id_ed25519, *.pem/*.key/*.p12, .aws/.ssh/.gnupg/.kube/gcloud dirs, .netrc,
.git-credentials, .docker/config.json, service-account*.json, secrets/, *.secret) it is
denied if it would expose the contents — a read/copy/transmit/interpret verb (cat, grep,
rg, head, cp, tar, curl, base64, python, …) or a redirect that writes into a
secret path (echo pwn >> ~/.ssh/authorized_keys). A deny beats every allow, including the
other hooks.
Metadata-only ops are allowed, since they expose nothing: ls/stat/file/test/find and
chmod/chown on a key, plus git (so commit messages mentioning .env don't trip it). It scans
a quote-stripped copy so real paths are caught but prose isn't, and config templates
(.env.example/.sample/.template/.dist/.defaults/.schema) are exempt. Fail-safe: it only
ever denies or stays silent.
The package managers (npm, pnpm, yarn, bun) are allowlisted so build/run/test
commands don't prompt — but installs always should. This hook detects
install/add/ci/update/upgrade/i anywhere in the command (including npx pnpm@9 install,
pnpm -C pkg add, corepack pnpm add) and forces a confirmation.
Allow rules are prefix matches (Bash(git diff:*)). A leading global flag shifts the
prefix: git -C /path push starts with git -C, not git push, so it slips past both
your ask rule on git push and your deny rules. This hook resolves the real
subcommand after skipping global flags (-C, -c, --git-dir, --work-tree, …) and:
- allows read-only subcommands (
diff,log,show,status, …), - denies everything else (
push,reset,rebase,commit, …) —cdinto the repo and use plain git for writes, where your normal rules apply.
It scans the whole command, so compound forms like ls && git -C /x push are caught too.
Tokenizes without eval/command-substitution, so the guard itself can't be injected.
curl/wget are deliberately not in the static ask list (an explicit ask rule
overrides a hook's allow). This hook is their sole authority:
- allow when every
http(s)URL is localhost /127.0.0.1/::1, there are no file writes or non-curl command substitutions, and every companion command is read-only (same allowlist as find-guard — socurl localhost && bash -c "…"asks), - ask for anything else.
So localhost health-checks and port probes run silently, while outbound curl, file
writes (-o, -O, >), @host tricks, and curl localhost && rm -rf ~ all still
prompt (and deny rules still hard-block the dangerous ones).
find is dual-use — -delete and -exec rm {} \; are destructive — so it's deliberately
not allowlisted. But that means every read-only search prompts, which is noise. This
hook makes find usable without opening the destructive door:
- allow read-only
find(-type,-name,-ipath,-print,-prune, …), - ask when it sees
-delete,-exec,-execdir,-ok,-fprint*,-fls, a file redirect, a backtick, or any companion command that isn't provably read-only.
A $(…) is deferred to allow-readonly-pipeline (which vouches the read-only
VAR=$(find …) form), so f=$(find . -name X | head -1); grep -n foo "$f" no longer prompts.
A destructive find inside $() is still caught here by the -delete/-exec check above.
The companion check is an allowlist, not a denylist — every command-position program must be
find, a read-only tool (cat/grep/head/…), or a read-only git/xargs invocation.
So find … && git log and find … | xargs grep run without a prompt, while find … && git push,
find … | xargs rm, and find … && bash -c "…" / … && ./script.sh all ask — an
interpreter or arbitrary executable can hide a destructive payload a word-scan can't see, so it
never rides along inside an "allowed" find. Dangerous words inside quotes (-name '*git*',
echo "rm -rf") and inside paths don't trip it — only real command-position programs do.
Why a hook instead of "just use fd"? Because a preference to use fd can't be relied
on — it doesn't propagate across projects or sessions, and pasted find one-liners ignore
it. A hook in settings.json is global and deterministic: read-only find works
everywhere, destructive find is gated everywhere. (fd is still allowlisted and is a
fine, faster choice when you reach for it.)
Claude Code's built-in matcher auto-approves a compound command only when it can statically
decompose it and match every leaf — it gives up on gnarly shells (mixed &&/|/;,
2>/dev/null redirects, !-prefixed glob args), so a read-only search like
rg -n foo src --glob '!**/node_modules/**' 2>/dev/null | head prompts even though rg,
head, cd are each allowlisted. This hook closes that gap:
- allow when every segment is a known read-only program (
rg,grep,cat,ls,head,tail,wc,sort,uniq,cut,jq,diff,cd,echo, …) and there's no output redirect to a real file, - silent (no opinion) otherwise — the other hooks and normal rules still apply.
Command substitution is vouched in one narrow, safe shape: an assignment value,
VAR=$(…) (or VAR="$(…)"), where the inner command is itself fully read-only — so
f=$(find . -name X | head -1); grep -n foo "$f" auto-allows. The output lands in a
variable, never at command position, and a later $var used as a command isn't read-only
so it bails. Any other $(…) — at command position ($(printf rm) file), as a bare
argument (echo $(date)), nested, or arithmetic — keeps the hook silent. Backticks and
process substitution always bail. (find-guard defers its $(…) ask to this hook, so a
read-only VAR=$(find …) no longer prompts; a destructive find inside $() is still caught
by find-guard's -delete/-exec check.)
It also vouches read-only git (status, log, diff, show, branch, blame,
stash list, …), which is what defeats Claude Code's built-in "changes directory before
running git, can execute untrusted hooks" prompt on a cd <repo> && git log sweep — that
built-in heuristic ignores allowlist rules and can only be overridden by a hook allow.
Read-only git is gated tightly: it never includes a mutating subcommand
(commit/push/reset/rebase/clean/stash save), bails on a global flag before the
subcommand (that's git-flag-guard's job) or an exec/write flag after it
(--output, -O, --upload-pack, --ext-diff, …) or a GIT_* env prefix, and — when a
cd is present — requires every cd target to stay inside a trusted root (no climbing out
via ..), so a malicious sibling repo's config can't ride along.
It also vouches read-only sed/awk as companions, so a sweep like
cd repo && grep -n serve f.ts && sed -n '/A/,/B/p' f.ts | head runs without a prompt —
honoring read freely. sed/awk can write (-i, the w command, print > file) or
execute (sed e, awk system()), so the hook gates exactly those: it rejects the
in-place / script-file flags (-i/--in-place/-f/--file, gawk -i inplace) per segment,
and scans the quoted script for write/exec constructs — awk system()/getline/print … >/
… | cmd/>>, and sed w/W/r/R/e commands and s///w/s///e flags. Any of those,
or a shell redirect, drops it back to a normal prompt. This is footgun-prevention, not a
sandbox (the rest of the allowlist already runs arbitrary code via node/npm/make), and
deny-secret-access — which runs first — still blocks sed/awk on secret paths.
Because a hook allow bypasses deny, the read-only set is otherwise strict: it excludes every
remaining command-runner and writer (fd, tee, node, sh, …), so a write or arbitrary-exec
can never ride along inside an "allowed" pipeline. Two exec-capable tools get dedicated
read-only checks instead of a blanket exclusion: find is vouched only when it carries no
-delete/-exec/-fprint*/redirect action, and xargs only when the command it runs is
itself in the read-only set — so ls | xargs -n1 basename auto-allows, while xargs rm,
xargs sh -c "…", a separated option arg (xargs -n 1 …), or a bare xargs all fall back to a
prompt. (To stay safe against the earlier {}-stripping, only no-arg flags and attached option
args — -n1, -P4, -I{} — are parsed; anything ambiguous bails. This mirrors find-guard's
own find … | xargs grep vs find … | xargs rm split.)
sed/awk are deliberately not in the static allow list (like find), so this hook is
their sole authority: read-only forms auto-allow here, while sed -i, redirects, and the other
write/exec forms fall through to a prompt. Note this is footgun-prevention, not a sandbox —
the script scan is heuristic and a crafted sed/awk write can evade it; that's acceptable
because node/npm/make already run arbitrary code, and deny-secret-access still blocks
secret paths. If you want a hard wall, deny sed/awk outright (you lose in-place edits).
These two aren't about safety — they keep a fast feedback loop on TypeScript edits without
adding per-edit noise. Wired as PostToolUse (Write|Edit) and Stop in settings.example.json.
A PostToolUse hook on Write|Edit. It does no typechecking — it just appends each edited
.ts/.tsx path to a per-session queue file ($TMPDIR/claude-tsq-<session>.txt). Instant and
silent, so editing produces zero prompts or delay. Set SKIP_GLOB at the top to exclude repos
you never want auto-typechecked (e.g. a vendored repo with its own checks); leave it pointing at a
non-existent path to check everything.
A Stop hook that fires once when the turn ends. It reads the queue, resolves the nearest
package.json above each edited file, and typechecks each unique project — preferring
pnpm typecheck, falling back to a local tsc --noEmit. On failure it blocks once (sending
the model back to fix the errors), then on the next stop warns only so it never loops forever.
All green clears the queue.
- allow — read-only git (incl. plumbing), coreutils, search tools, and JS runtimes
(
node/npm/pnpm/bunfor build/run — installs are gated by the hook above). - ask —
git push/--force,git reset --hard,rebase,filter-branch,clean -f/-fd. (curl/wgetare handled by the hook, not listed here.) - deny — secrets (
.env, SSH/GPG/cloud creds) via the Read/Edit/Write tools and via Bash (thedeny-secret-access.shhook),sudo/su,--no-verifycommits,push --mirror, and catastrophicrm -rf/mkfs/dd/shutdownforms.
- These are guardrails, not a sandbox. They reduce footguns and prompt fatigue; they don't contain a determined adversary. Review before adopting.
- Tested on macOS (BSD userland). The hooks use portable constructs but if you hit a
GNU/BSD
sed/grepquirk, open an issue. - Don't commit your real
~/.claude/settings.json— it may contain machine paths, tokens, or org IDs..gitignorehere already excludessettings.json.