Regex-based permission rules for Claude Code via hooks.
The native permission system in Claude Code only takes glob wildcards at the end of a pattern, which leaves big gaps. passthru adds a thin regex layer in front of it so you can auto-allow (or deny) tool calls by shape instead of listing each command. It sits on top of your existing settings.json and leaves everything that does not match to the native dialog.
Native Bash(bash /Users/you/project/:*) does not match bash /Users/you/project/script.sh because Claude Code enforces a word boundary after the prefix. You end up listing every script by name, or giving up and granting the full Bash(bash:*) namespace.
With passthru, one rule does what you meant in the first place:
{ "tool": "Bash", "match": { "command": "^bash /Users/you/project/" }, "reason": "run project scripts" }More examples: shape-matching a gh api endpoint across any owner/repo pair, allowing every tool on an MCP server, denying rm -rf / globally. See Rule format reference and docs/examples.md.
/plugin marketplace add nnemirovsky/claude-passthru
/plugin install passthru
- Regex-based Bash prefixes. Auto-allow a directory of scripts, a shell pipeline, or any command family the native glob syntax cannot express.
- Shape-aware path and URL rules. Match on the structure of a path or URL (e.g.
^gh api /repos/[^/]+/[^/]+/forks) so you pin the endpoint, not the owner. - MCP tool namespaces. Allow a whole MCP server family with a single tool-regex rule, no need to enumerate every tool.
- Deny lists that win. A matching deny rule unconditionally overrides any allow, so you can cement safety rules on top of a permissive allow set.
- Ask rules that route to the overlay (or native dialog as fallback). Mark a tool shape as "always prompt me" via
ask[]. Routes to the passthru overlay when enabled, falls back to Claude Code's native dialog otherwise. - Terminal overlay for permission prompts with Y/A/N/D keyboard flow. Inline TUI popup inside your tmux / kitty / wezterm session that intercepts permission prompts. Single-keystroke yes-once / yes-always / no-once / no-always. Escape drops through to the native dialog.
- Opt-in audit log. JSONL record of every decision (including what the native dialog did for passthroughs). Off by default, zero overhead when disabled.
- Standalone verifier. Validate every rule file from the command line or via
/passthru:verifyto catch bad JSON, invalid regex, and allow/deny conflicts before they silently disable rules. - First-run bootstrap. One-shot
/passthru:bootstrapcommand (orscripts/bootstrap.shfor scripting) that converts existing nativepermissions.allowentries into passthru rules. ASessionStarthint fires wheneversettings.jsonhas importable entries that are not yet inpassthru.imported.jsonand auto-silences after the next bootstrap run.
All commands are plugin-namespaced under /passthru:.
| Command | What it does |
|---|---|
/passthru:bootstrap |
One-shot importer: reviews your existing permissions.allow entries, shows the proposed rules, asks to confirm, then writes passthru.imported.json. Runs the verifier afterwards. |
/passthru:add |
Add a rule without hand-editing passthru.json. Supports --allow (default), --ask, --deny, and --field. |
/passthru:suggest |
Propose a generalized rule from a recent tool call in the conversation, then write it on confirmation. |
/passthru:list |
Show every rule across user and project scopes, grouped by (scope, list, source) with 1-based indexes. Filter by --scope, --list, --source, or --tool. |
/passthru:remove |
Remove an authored rule by <scope> <list> <index>. Indexes match the numbering from /passthru:list. Imported (bootstrap-generated) rules are not removable here; edit settings.json and re-run bootstrap instead. |
/passthru:verify |
Validate every rule file. Surfaces parse errors, schema violations, invalid regex, duplicates, and allow/deny conflicts. |
/passthru:log |
Read the audit log with filters. Also toggles the audit sentinel on/off. |
/passthru:overlay |
Toggle the permission-prompt overlay on or off. --status also reports which multiplexer the hook detects. |
Full reference in the Command reference section below.
Runtime dependencies the plugin needs on the user's machine.
- bash 3.2+ or bash 4.0+. The hook scripts are written to POSIX/bash 3.2 (no associative arrays, no
declare -n, nomapfile). macOS ships bash 3.2 by default and Linux distros ship bash 4+, both work. - jq 1.6+. Used to parse rule files and build JSON output.
- macOS:
brew install jq - Debian/Ubuntu:
apt install jq - RHEL/Fedora:
dnf install jq
- macOS:
- perl 5+. Used as the PCRE regex engine because BSD grep on macOS lacks
-P. Preinstalled on macOS and essentially every Linux distribution. - bats-core 1.9+ (tests only, not required to run the plugin).
- macOS:
brew install bats-core - Debian/Ubuntu:
apt install bats(usually older, prefer npm) - npm (any platform):
npm install -g bats
- macOS:
PowerShell support: the hook itself is Bash plus perl only. PowerShell rule matching works because Claude Code still invokes the PreToolUse hook for PowerShell tool calls. No PowerShell runtime is needed on the user's machine for the plugin itself.
Native rules solve the common case. They fall short when:
- The thing you want to match is not space-delimited after a prefix (directory paths, URL paths).
- You need to pin the shape of a sub-argument, not just the leading verb.
- You want to allow a whole MCP server family without listing every tool.
- You want a deny list that unconditionally overrides a more permissive allow.
passthru adds a thin regex layer in front of the native system. When a passthru rule matches, the hook emits a decision and Claude Code skips the permission dialog. When nothing matches, control passes through to the native rules unchanged. Nothing about your existing settings.json or .claude/settings.local.json changes.
Works across every tool Claude Code exposes (Bash, PowerShell, Read, Edit, Write, WebFetch, MCP tools, and so on).
The plugin ships a bootstrap importer that converts existing native permissions.allow entries into passthru rule files. It reads up to three settings files: the user-scope ~/.claude/settings.json, the project-scope shared ./.claude/settings.json, and the project-scope local ./.claude/settings.local.json. Run it once after install to avoid starting from zero.
Recommended: run /passthru:bootstrap inside a Claude Code session. It dry-runs first, shows the rules it would import, asks you to confirm, then writes and verifies. Use --user-only or --project-only to narrow the scope.
Non-interactive: the same logic is available as a plain shell script for CI or ad-hoc use. Dry run first (prints proposed rules to stdout, writes nothing):
bash ~/.claude/plugins/marketplaces/nnemirovsky/claude-passthru/scripts/bootstrap.sh
The exact path depends on where Claude Code installed the plugin. If you cloned the repo directly, the script lives at scripts/bootstrap.sh in your clone. Inspect the output, then re-run with --write to persist:
bash .../scripts/bootstrap.sh --write
--write mode also runs scripts/verify.sh --quiet after writing. If the verifier finds errors, the script restores the pre-write backup and exits non-zero.
What bootstrap converts. Six native rule shapes are recognized:
| Native rule | Converted to |
|---|---|
Bash(<prefix>:*) |
`{"tool": "Bash", "match": {"command": "^(\s |
Bash(<exact command>) |
{"tool": "Bash", "match": {"command": "^<exact>$"}} |
mcp__server__tool |
{"tool": "^mcp__server__tool$"} |
WebFetch(domain:x.com) |
{"tool": "WebFetch", "match": {"url": "^https?://([^/.]+\\.)*x\\.com([/:?#]|$)"}} |
WebSearch |
{"tool": "^WebSearch$"} |
Read(<path>), Edit(<path>), Write(<path>) |
{"tool": "^Read$", "match": {"file_path": "^<path>$"}} (exact) or "^<path>(/|$)" when the native rule ends in /** or /* |
Skill(<name>) |
{"tool": "^Skill$", "match": {"skill": "^<name>$"}} |
Regex metacharacters in the original path/prefix/name are escaped so the converted pattern matches literally. Anything that does not match one of the shapes above is skipped with a [WARN] line on stderr (for example, custom MCP tool patterns that do not start with mcp__, or a WebFetch(...) with a non-domain: argument).
For Read, Edit, and Write, path acceptance is permissive: redundant slash runs (//foo, ///foo/bar) are collapsed to a single slash, ~/... expands to $HOME/..., and paths with spaces or deep nesting are accepted. Only clearly invalid shapes are skipped with a [WARN]:
- shell / env expansion:
$VAR,${VAR},$(cmd),%VAR% - zsh equals expansion: leading
=(e.g.=cmd) - tilde variants other than
~/:~user,~+,~-,~N - UNC paths: leading
\\server\share
Bootstrap writes to dedicated imported files so hand-curated rules in passthru.json stay separate:
~/.claude/passthru.imported.json(user scope).claude/passthru.imported.json(project scope)
Re-running bootstrap overwrites the imported files. Edit passthru.json (the authored file) for hand-managed rules. Both files are merged at hook time.
SessionStart hint. The plugin ships a SessionStart hook that detects importable permissions.allow entries in settings.json that are not yet covered by a rule in passthru.imported.json. Each imported rule carries a _source_hash field recording which settings entry it came from, so the hint compares the two hash sets on every session start. The hint fires until the last un-imported entry is covered, then auto-silences. Run /passthru:bootstrap once and it will not fire again unless you add new native entries later.
Rule files are JSON with the shape:
{
"version": 2,
"allow": [ { "tool": "...", "match": { "...": "..." }, "reason": "..." } ],
"deny": [ { "tool": "...", "match": { "...": "..." }, "reason": "..." } ],
"ask": [ { "tool": "...", "match": { "...": "..." }, "reason": "..." } ]
}version: 1 files (no ask[] key) continue to load unchanged. ask[] is v2-only. See Ask rules below for when to use it.
Four examples covering common use cases.
Directory prefix (Bash). Auto-allow any bash invocation against a scripts dir:
{ "tool": "Bash", "match": { "command": "^bash /Users/you/scripts/" }, "reason": "local scripts" }Regex on gh api endpoints (Bash). Auto-allow repo forks queries across any owner/repo:
{ "tool": "Bash", "match": { "command": "^gh api /repos/[^/]+/[^/]+/forks" }, "reason": "github forks api reads" }MCP namespace (no match block). Auto-allow every tool on the gemini-cli MCP server:
{ "tool": "^mcp__gemini-cli__", "reason": "gemini mcp server" }Deny rule (priority over allow). Block destructive rm -rf / patterns across any shell tool, even if a broader allow would match:
{ "tool": "Bash|PowerShell", "match": { "command": "rm\\s+-rf\\s+/" }, "reason": "safety" }See docs/rule-format.md for the full schema reference and docs/examples.md for more examples.
All commands are plugin-namespaced under /passthru:.
Add a rule without hand-editing passthru.json. Canonical call:
/passthru:add user Bash "^gh api /repos/[^/]+/[^/]+/forks" "github forks api reads"
Flags: --deny (write to deny list instead of allow), --field <name> (override the default tool_input field).
Propose a generalized rule from a recent tool call in the conversation. The command scans the transcript, drafts a regex that generalizes owner / repo / version-style variables, shows matched and non-matched examples, and on confirmation hands off to the same write wrapper /passthru:add uses.
/passthru:suggest gh api
Show every rule across user and project scopes. Rules are grouped by (scope, list, source) and numbered with 1-based indexes that match what /passthru:remove expects.
/passthru:list
/passthru:list --scope user --list deny
/passthru:list --source imported
/passthru:list --tool '^Bash$'
/passthru:list --flat
/passthru:list --format json
Remove an authored rule by <scope> <list> <index>. Run /passthru:list first to see the indexes.
/passthru:remove user allow 3
/passthru:remove project deny 1
Imported rules (written by /passthru:bootstrap) are not removable here because bootstrap regenerates them on every run. To drop one, remove the corresponding permissions.allow entry from settings.json and re-run bootstrap.
Validate every rule file. Surfaces parse errors, schema violations, invalid regex, duplicates, and allow+deny conflicts.
/passthru:verify
/passthru:verify --scope user --strict
Read the audit log in a filtered table (see Audit log below). Also toggles the audit sentinel.
/passthru:log --since 1h --tail 20
/passthru:log --enable
Toggle the in-terminal permission-prompt overlay (see Overlay below), or inspect its current state plus multiplexer detection.
/passthru:overlay --status
/passthru:overlay --disable
/passthru:overlay --enable
--status prints the current enabled/disabled state, the sentinel path, and whether a supported multiplexer (tmux, kitty, wezterm) is detected and runnable on PATH.
The overlay is an in-terminal TUI popup that intercepts permission prompts before they reach Claude Code's native dialog. When the overlay fires you see a single-keystroke menu:
Passthru Permission Prompt
Tool: Bash
Input: gh api /repos/anthropics/claude-code/forks?page=2
[Y] Yes, once
[A] Yes, always (write rule)
[N] No, once
[D] No, always (deny rule)
[Esc] Skip (use native dialog)
Picking A or D drops you into a second screen where you can accept or hand-edit the proposed regex before the rule is written to passthru.json.
On by default. The overlay is enabled out of the box on every supported multiplexer. No configuration needed.
Opt-out. Drop the overlay with /passthru:overlay --disable (or touch ~/.claude/passthru.overlay.disabled). Passthru will emit permissionDecision: "ask" instead, Claude Code shows its built-in dialog, and you still get the same yes-once / yes-always / no-once / no-always outcomes via the native UI. Re-enable with /passthru:overlay --enable.
Sentinel path. ~/.claude/passthru.overlay.disabled. Absent = overlay enabled, present = overlay disabled. The /passthru:overlay command is a thin wrapper around this file.
Supported multiplexers.
| Multiplexer | Detection env var | Popup command used |
|---|---|---|
| tmux | $TMUX |
tmux display-popup -E -w 80% -h 60% |
| kitty | $KITTY_WINDOW_ID |
kitty @ launch --type=overlay |
| wezterm | $WEZTERM_PANE |
wezterm cli split-pane (adjacent pane) |
The hook picks the first detected multiplexer whose binary is also on $PATH. If none match, the hook falls through to Claude Code's native dialog.
When the overlay fires. The hook runs the normal decision pipeline first. The overlay only fires when nothing else matched:
denyrule match -> immediate deny, no overlay.allowrule match -> immediate allow, no overlay.askrule match -> overlay (or native dialog as fallback). An ask-rule match wins over the permission-mode auto-allow shortcut below, because ask expresses explicit "prompt me" intent.- No rule match -> check Claude Code's
permission_modeauto-allow rules:bypassPermissions(everything),acceptEdits+ Write/Edit within cwd,default+ read tools (Read, Grep, Glob, NotebookRead, LS) within cwd,plan+ read tools. If Claude Code would auto-allow, the hook lets the call through without prompting. Otherwise, overlay.
Known limitations.
- The mode-based auto-allow replication is best-effort and errs on the conservative side. Claude Code resolves symlinks (
realpathSync) and honorsadditionalAllowedWorkingDirs, sandbox allowlists, and internal-path predicates. The hook uses literal$CWD/prefix match and explicitly rejects/../traversal. Net effect: some calls Claude Code would auto-allow fall through to the overlay anyway (extra prompt, safe direction). No false auto-allows across the other direction. - The overlay relies on your terminal multiplexer's popup API. In screen or plain bash without any multiplexer the hook falls through to the native dialog every time. That is fine. The overlay is a UX layer, not a policy layer.
- Each overlay prompt has a 60-second timeout (
PASSTHRU_OVERLAY_TIMEOUT, configurable). If you leave the popup idle for longer, the hook treats the prompt as cancelled and hands off to the native dialog.
ask[] is a third rule list, alongside allow[] and deny[], that explicitly routes a matching tool call to a prompt. Use ask when you want to be asked, not when you want to auto-allow or auto-deny.
Schema. Ask rules live on v2 files:
{
"version": 2,
"ask": [
{ "tool": "WebFetch", "match": { "url": "^https?://internal\\." }, "reason": "prompt for internal urls" }
]
}The rule shape is identical to allow/deny. Only the list name changes. See docs/rule-format.md for the full schema.
When a match fires. The hook signals "ask the user". With the overlay enabled (and a supported multiplexer available), the overlay dialog pops up. With the overlay disabled or the multiplexer absent, the hook emits permissionDecision: "ask" and Claude Code shows its native dialog. Either way the call is paused until you decide.
Three common use cases.
-
Prompt before fetching from non-allowlisted domains. You have a blanket
WebFetchallow, but a few domains you always want to eyeball:/passthru:add --ask user WebFetch "^https?://(?!example\\.com)" "prompt for non-example-domain URLs" -
Prompt before reading outside the project directory. Narrow allow for your workspace paths paired with an ask rule that catches anything outside:
/passthru:add --ask user Read "^/Users/.*/\\.ssh" "prompt before reading anything under .ssh" -
Prompt before MCP calls from untrusted servers. You trust
mcp__gemini-cli__*outright but want to audit calls to a half-trusted MCP server:/passthru:add --ask user '^mcp__untrusted__' "prompt on all calls to the untrusted MCP server"
Decision order with allow + ask. deny wins globally. Between allow and ask, document order within the merged list decides: a narrow allow: Bash(git) declared before a broader ask: Bash(.*) wins over the ask, and a narrow ask: Bash(git push) declared before a broader allow: Bash(.*) wins over the allow. Both are "this call is OK to consider" signals, so you get to pick the ordering in the file. See docs/rule-format.md for the full semantics.
The verifier can be run without Claude Code attached:
bash scripts/verify.sh [--scope user|project|all] [--strict] [--format plain|json] [--quiet]
Exit codes:
0- clean (no errors, no warnings, or warnings without--strict).1- one or more errors (bad JSON, schema violation, invalid regex, allow+deny conflict).2- warnings only (duplicates, shadowing) and--strictis set.
Run /passthru:verify (or bash scripts/verify.sh) whenever you edit a passthru.json file by hand. The hook silently skips malformed rule files at runtime so a typo can quietly disable your rules. The verifier surfaces the failure up front.
Automatic verification already covers every machine-driven write path. The following all call scripts/write-rule.sh, which takes a backup, writes the rule, runs the verifier, and restores the backup if verification fails:
/passthru:addslash command/passthru:suggestslash commandscripts/bootstrap.sh --write
So the only time you need to run the verifier manually is after editing passthru.json with an editor.
Interpret the output as follows:
[OK] N rules across M files checked- nothing to do.[ERR] <file>:<jq-path> [rule N] <msg>- fix the listed file and re-run.[WARN] ...- duplicates or shadowing. Harmless by default. Add--strictto treat as errors.
To iterate on the plugin without installing it through the marketplace, load it straight from a working directory:
claude --plugin-dir /path/to/claude-passthru
This is the fastest dev loop. Every time you restart Claude Code the plugin is re-read from disk. No /plugin install, no cache flush, no uninstall step between iterations.
Heads-up: the plugin self-allow regex matches the canonical marketplace install path (~/.claude/plugins/.../claude-passthru/scripts/<name>.sh). When you load the plugin via --plugin-dir from a clone elsewhere on disk, that regex does not match, and slash commands like /passthru:add will hit the native permission dialog the first time. Either accept the dialog once per shell, or add a temporary one-line allow rule to your own passthru.json matching the dev path. The self-allow is intentionally narrow to prevent rogue scripts from impersonating the plugin.
See CONTRIBUTING.md for the full dev workflow including running tests and pipe-testing the hook.
The plugin can record every permission decision to a JSONL file at ~/.claude/passthru-audit.log. Audit is opt-in and off by default. When disabled, the hook does a single -e check on the sentinel file and moves on, so there is effectively zero overhead.
Enable:
touch ~/.claude/passthru.audit.enabled
or
/passthru:log --enable
Disable:
rm ~/.claude/passthru.audit.enabled
or
/passthru:log --disable
Log path: ~/.claude/passthru-audit.log (JSONL, one event per line).
Event types. From the PreToolUse hook:
allow- a passthru allow rule matched, or the overlay returnedyes_once/yes_always.deny- a passthru deny rule matched, or the overlay returnedno_once/no_always.ask- the hook emittedpermissionDecision: "ask"(ask rule matched + overlay disabled or unavailable, overlay launch failed, overlay cancelled, or unknown verdict). Claude Code's native dialog handles the prompt;PostToolUseclassifies the outcome into anasked_*event.passthrough- no rule matched. The call was passed through to the native permission system, or mode auto-allow handled it.
Each log line also carries a source field that attributes the decision:
passthru(default) - rule-driven decision or plugin self-allow.overlay- the overlay dialog emitted the verdict (yes_once,no_once,yes_always,no_always).passthru-mode- permission-mode auto-allow short-circuit (bypassPermissions,acceptEditsinside cwd,default+ read tool inside cwd,plan+ read tool).
From the PostToolUse hook, classifying what the native dialog decided for a passthrough:
asked_allowed_once- user picked "allow once" in the native dialog.asked_allowed_always- user picked "allow always" (nativesettings.jsongot a new entry).asked_denied_once- user denied once.asked_denied_always- user denied permanently.asked_allowed_unknown- outcome could not be classified (e.g. session ended mid-dialog).
From the PostToolUseFailure hook, which Claude Code routes failed tool calls through (non-zero outcomes, permission refusals, runtime errors, interrupts, timeouts):
- The
asked_denied_*events above are also emitted from this path when the failure'serrorfield carries a permission-denied token (permission denied,access denied,not allowed,blocked,denied). errored- non-permission tool failure. The log line carries anerror_typefield when CC provides one, otherwise synthesizestimeoutorinterruptedfrom the envelope flags.
View the log:
/passthru:log
/passthru:log --since 1h --event '^asked_'
bash scripts/log.sh --format raw | jq .
Rotation. None built in. The audit file grows one line per tool call when enabled. Use logrotate, cron-driven truncate, or manually rotate when it gets large.
- Disable every rule without uninstalling.
touch ~/.claude/passthru.disabledturns the plugin into a no-op (the hook sees the sentinel and returns passthrough immediately). Remove the file to re-enable. - Bad rules after a manual edit. Run
/passthru:verifyorbash scripts/verify.shto see exactly which file, path, and message failed. - Rules are not firing. Launch Claude Code with
claude --debugand watch the hook output. The handler prints its decision reason to stderr, which--debugsurfaces. - Concurrent writes or a stuck lock.
scripts/write-rule.shserializes writers under a single user-scope lock at the directory~/.claude/passthru.write.lock.d. The lock usesmkdir, which is atomic on every POSIX filesystem, so noflock(1)is required. If the process that held the lock died without releasing it, remove the directory manually (rmdir ~/.claude/passthru.write.lock.d). Lock-acquisition timeout defaults to 5 seconds and can be overridden viaPASSTHRU_WRITE_LOCK_TIMEOUT=<seconds>in the environment.
See CONTRIBUTING.md for the dev loop, test commands, and rule schema evolution policy.