Developer-facing notes for future Claude sessions working on this repo.
.claude-plugin/
plugin.json plugin manifest (name, version, description)
marketplace.json marketplace manifest (used when published)
commands/
bootstrap.md /passthru:bootstrap slash command (wraps scripts/bootstrap.sh with dry-run + confirm)
add.md /passthru:add slash command (prompt-based)
suggest.md /passthru:suggest slash command (prompt-based)
list.md /passthru:list slash command (wraps scripts/list.sh)
remove.md /passthru:remove slash command (wraps scripts/remove-rule.sh)
verify.md /passthru:verify slash command (prompt-based)
log.md /passthru:log slash command (prompt-based)
overlay.md /passthru:overlay slash command (wraps scripts/overlay-config.sh)
hooks/
hooks.json registers PreToolUse (timeout 300s, matcher "*"), PostToolUse +
PostToolUseFailure (timeout 10s each, matcher "*"), and
SessionStart (timeout 5s, no matcher) handlers
common.sh shared library. Functions:
* load_rules / validate_rules (merge + schema-check)
* load_allowed_dirs (read + deduplicate allowed_dirs from all rule files)
* pcre_match / match_rule / find_first_match (rule matching)
* split_bash_command (compound command splitter via perl tokenizer)
* match_all_segments (per-segment matching for compound Bash commands)
* is_readonly_command / readonly_paths_allowed (readonly auto-allow)
* _pm_path_inside_any_allowed (path validation against cwd + allowed dirs)
* build_ordered_allow_ask (document-order allow/ask interleaving)
* permission_mode_auto_allows (CC mode replication with allowed dirs)
* passthru_user_home, passthru_tmpdir, passthru_iso_ts,
passthru_sha256, sanitize_tool_use_id (env + path helpers)
* audit_enabled, audit_log_path, emit_passthrough
(audit + output helpers)
* write_post_event, is_denied_response,
is_permission_error_string, entries_look_tailored,
entry_matches_call, read_settings_allow,
read_settings_deny, classify_passthrough_outcome
(post-hook classification, shared by both post handlers)
Sourced by hook handlers AND by scripts/log.sh,
scripts/verify.sh, scripts/write-rule.sh.
handlers/
pre-tool-use.sh main hook: splits compound Bash commands, checks deny per segment,
readonly auto-allow, allow/ask document-order matching (per-segment
for compound commands), mode auto-allow with allowed dirs, overlay
post-tool-use.sh classifies successful native-dialog outcomes into asked_* events.
Delegates to classify_passthrough_outcome in common.sh.
post-tool-use-failure.sh
classifies failed tool calls. Permission-denied error strings ->
asked_denied_* via the same shared helper. Other failures ->
`errored` event (carries error_type, synthesizes timeout/interrupted
from is_timeout/is_interrupt when CC omits error_type).
session-start.sh bootstrap hint. Re-fires every session while importable entries in
settings.json / settings.local.json are not yet covered by
_source_hash values in passthru.imported.json. Hash diff replaces
the old one-shot marker. Auto-silences when migration is complete.
scripts/
bootstrap.sh one-time importer from native permissions.allow into passthru.imported.json.
Supported shapes: Bash(prefix:*) | Bash(exact) | mcp__* | WebFetch(domain:X)
| WebSearch | Read/Edit/Write(path[/**]) | Skill(name). Others -> [WARN] skip.
Stamps every written rule with `_source_hash` (sha256 of the normalized
source entry) so session-start.sh can diff imported vs importable.
write-rule.sh atomic write wrapper: backup + append + verify + rollback. Also the
v1 -> v2 upgrade point: first ask write on a v1 file flips the version.
remove-rule.sh atomic remove wrapper: backup + splice + verify + rollback. Authored-only.
list.sh rule list viewer CLI with scope/list/source/index annotations. Renders
ALLOW / ASK / DENY groups; --list ask filters.
verify.sh rule verifier CLI (also invoked by write-rule.sh/remove-rule.sh and /passthru:verify).
Accepts schema v1 and v2. Rejects ask+allow and ask+deny conflicts.
log.sh audit-log viewer CLI + sentinel toggle. color_for_event covers
ask (cyan), errored (yellow), and overlay-sourced events.
overlay.sh terminal-multiplexer dispatcher. Detects tmux / kitty / wezterm via
env vars + PATH check, launches overlay-dialog.sh inside the popup.
Writes verdict to $PASSTHRU_OVERLAY_RESULT_FILE.
overlay-dialog.sh pure-bash TUI. Y/A/N/D/Esc keypress menu, optional rule editor on A/D.
Respects PASSTHRU_OVERLAY_TEST_ANSWER for hermetic tests.
PASSTHRU_OVERLAY_TIMEOUT bounds the wait (default 60s).
overlay-propose-rule.sh
regex proposer. Takes tool_name + tool_input JSON, emits a rule JSON
targeting one of four categories (Bash fully-anchored with safe char class,
Read/Edit/Write path prefix, WebFetch URL host, MCP namespace).
Unknown tool -> bare ^<Name>$ rule.
overlay-config.sh overlay sentinel toggle + multiplexer detection reporter. Backs
/passthru:overlay.
tests/
fixtures/
overlay/ stub tmux/kitty/wezterm shell scripts used by overlay tests.
*.json JSON fixture files.
overlay.bats overlay.sh + overlay-dialog.sh + overlay-propose-rule.sh coverage.
overlay_config.bats overlay-config.sh + /passthru:overlay frontmatter coverage.
post_tool_use_failure_hook.bats
PostToolUseFailure handler coverage (permission errors, generic
errored events, timeouts, interrupts, missing breadcrumb).
command_splitting.bats split_bash_command + match_all_segments coverage (compound
command splitting, redirection stripping, quote/subshell handling).
*.bats test suites (one per script or component).
docs/
rule-format.md schema reference
examples.md real-world rule examples
plans/ implementation plans (historical, not runtime)
README.md user-facing documentation
CONTRIBUTING.md contributor guide
CLAUDE.md this file
Paths honor PASSTHRU_USER_HOME and PASSTHRU_PROJECT_DIR so tests never touch the real ~/.claude.
Variables the plugin reads at runtime. Most are test-only overrides; a couple (PASSTHRU_OVERLAY_TIMEOUT, PASSTHRU_WRITE_LOCK_TIMEOUT) have user-facing meaning.
PASSTHRU_USER_HOME- override~/.claudeas the user scope root. Used by every bats test to redirect reads and writes to a temp dir. Never set in production.PASSTHRU_PROJECT_DIR- override$PWD/.claudeas the project scope root. Same use case as above. Tests set both.PASSTHRU_OVERLAY_RESULT_FILE- path the overlay dispatcher writes the verdict line(s) into. Set bypre-tool-use.shper-invocation viasanitize_tool_use_id+passthru_tmpdir. The overlay script reads the path from this env var; the hook reads back the contents after the overlay exits.PASSTHRU_OVERLAY_TEST_ANSWER- short-circuit the interactive keypress loop inoverlay-dialog.sh. Acceptsyes_once|yes_always|no_once|no_always|cancel. Used exclusively bytests/overlay.bats+tests/hook_handler.batsto exercise every branch without pseudo-tty gymnastics. Never set by the hook in production.PASSTHRU_OVERLAY_TOOL_NAME- tool name passed into the overlay dialog. Hook propagates the inboundtool_namefield verbatim.PASSTHRU_OVERLAY_TOOL_INPUT_JSON- tool input JSON (stringified) passed into the overlay dialog. Hook propagates the inboundtool_inputfield verbatim. The dialog andoverlay-propose-rule.shparse it for the suggested-rule screen.PASSTHRU_OVERLAY_TIMEOUT- seconds to wait for a user response inside the overlay. Default 60. If the user does not respond in time, the overlay exits without writing a verdict and the hook treats the prompt as cancelled (falls through to the native dialog). Setting below 60 is fine; setting above requires also raising the PreToolUse hook timeout (currently 300s).PASSTHRU_OVERLAY_LOCK_TIMEOUT- seconds to wait for another CC session's overlay to release the user-scope queue lock. Default 180. On timeout, the hook emits the ask fallback (native dialog). See the "Overlay queue lock" section below.PASSTHRU_OVERLAY_LOCK_STALE_AFTER- mtime age threshold in seconds after which an existing overlay lock is considered abandoned and auto-cleared. Default 180. Protects against SIGKILLed hooks leaving zombie locks.PASSTHRU_OVERLAY_UNALLOWED_SEGMENTS- newline-separated list of compound Bash segments that are NOT covered by readonly auto-allow or by any allow/ask rule. Set bypre-tool-use.shbefore invoking the overlay. Read byoverlay-propose-rule.shso that "yes/no always" proposals target only the uncovered portion instead of the full command's first word.PASSTHRU_WRITE_LOCK_TIMEOUT- secondsscripts/write-rule.shandscripts/remove-rule.shwait for the user-scope mkdir lock. Default 5. See the "Write-wrapper locking" section below.
All shell logic is covered by bats-core. Run the full suite:
bats tests/*.bats
Targeted run:
bats tests/hook_handler.bats
bats tests/verifier.bats
Conventions:
- Test files are named after the unit under test (
hook_handler.batscoverspre-tool-use.sh,verifier.batscoversscripts/verify.sh, etc.). - Every test creates a temp dir, sets
PASSTHRU_USER_HOMEandPASSTHRU_PROJECT_DIRto live under it, drops fixtures, and cleans up inteardown. - No external network, no real home directory, no shared state between tests.
The hook reads JSON on stdin, writes a decision to stdout, and always exits 0 (fail-open).
echo '{
"tool_name": "Bash",
"tool_input": { "command": "gh api /repos/foo/bar/forks" }
}' | bash hooks/handlers/pre-tool-use.sh
With a custom rule location:
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' \
| PASSTHRU_USER_HOME=/tmp/fakeuser PASSTHRU_PROJECT_DIR=/tmp/fakeproj \
bash hooks/handlers/pre-tool-use.sh
Expected outputs:
- Allow match:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"..."}} - Deny match: same shape with
"deny". - No match:
{"continue":true}.
scripts/verify.sh runs standalone and is also invoked by write-rule.sh after every machine-driven rule write.
bash scripts/verify.sh [flags]
Flags:
--scope user|project|all- defaultall. Which scope's files to check.--strict- warnings (duplicates, shadowing) become a non-zero exit (exit 2 instead of 0).--quiet- suppress stdout on success. Errors still go to stderr.--format plain|json- defaultplain. JSON is machine-readable.-h,--help- usage.
Exit codes:
0- clean, or warnings only without--strict.1- any error (bad JSON, schema violation, invalid regex, allow+deny conflict).2- warnings only and--strictis set.
Checks performed (in order, across the merged set):
- parse - every existing file is valid JSON.
- schema - every rule has at least one of
toolormatch, types match spec, version is1or2. v2 files may declareask[]; rules inask[]are validated with the same rule-shape checks asallow[]anddeny[]. - regex - every
toolregex and everymatch.*regex compiles in perl. - duplicates - exact-duplicate rules (same tool + match) across scopes emit a warning.
- conflict - identical
tool + matchappears in two or more ofallow[],deny[],ask[](merged) emits an error. - shadowing - within one merged
allow[],deny[], orask[]array, a later rule duplicates an earlier one. Warning.
scripts/write-rule.sh (also called by bootstrap.sh --write and the
/passthru:add, /passthru:suggest commands) and scripts/remove-rule.sh
(called by /passthru:remove) serialize concurrent mutations via a single
user-scope lock directory at ~/.claude/passthru.write.lock.d. The lock
uses mkdir, which is atomic on every POSIX filesystem we target (local
Linux/macOS plus NFS), works without any extra dependency, and polls at
100 ms intervals while waiting.
The lock-acquisition timeout is 5 seconds by default and is configurable via
PASSTHRU_WRITE_LOCK_TIMEOUT=<seconds> in the environment. Both
tests/write_rule.bats (concurrent test, lock-timeout test) and the
production write paths exercise the env override.
The lock directory lives under the user scope even for project-scope writes because it is the single per-user serialization point across concurrent project shells.
PostToolUse, PostToolUseFailure, and SessionStart are registered with
short timeouts (10s / 10s / 5s) in hooks/hooks.json. PreToolUse runs with
a 300s timeout because the hook blocks synchronously on the interactive
terminal-overlay dialog AND may also queue behind an overlay held by another
CC session on the same machine.
The 300s figure breaks down as:
- The overlay dialog (
scripts/overlay-dialog.sh) enforces its own 60s budget (PASSTHRU_OVERLAY_TIMEOUT, default 60s). - The overlay queue lock (
PASSTHRU_OVERLAY_LOCK_TIMEOUT, default 180s) waits for other sessions' overlays to complete. - Add margin for overlay launch, multiplexer roundtrip, post-dialog
rule write via
write-rule.sh, and audit line emission. - CC's hook timeout is wall-clock. Anything below the overlay's own budget plus the lock-wait budget would kill the hook mid-wait and lose the user's verdict.
The 10s baseline for non-overlay PreToolUse paths (rule match, mode auto-allow) still applies in the sense that none of them block on IO; the 300s cap only matters when the overlay is actually invoked.
For post-event handlers, the original 10s baseline continues to hold:
load_rulesshells out tojqonce per rule file (up to 4 files), once for the parse check, once for normalization, and once for the merge.find_first_matchrunsmatch_ruleper rule, which itself forks aperlPCRE check per regex.audit_write_breadcrumbsnapshots twosha256digests ofsettings.jsonfiles plus ajqinvocation to build the JSON envelope.- On cold caches, slow disks, or under heavy IO load, a hot path with 50+ rules + audit enabled has measured 2-4 seconds in the wild.
- The handler always exits 0 (fail-open) so a timeout would only ever lose audit fidelity, never block a tool call. Choosing 10s leaves 5x headroom over typical worst case.
Lower the PreToolUse timeout only after also lowering
PASSTHRU_OVERLAY_TIMEOUT (and only after profiling on target hardware).
Raising it is always safe since the handler fails open on timeout.
The overlay popup is singleton per machine: tmux/kitty/wezterm can only show one popup at a time. Two CC sessions racing for the overlay would otherwise both try to open popups and one would fail, falling through to CC's native dialog.
hooks/handlers/pre-tool-use.sh serializes overlays via a mkdir lock at
$(passthru_user_home)/passthru-overlay.lock.d. The lock MUST live under
user home, NOT $TMPDIR: on macOS $TMPDIR resolves to a per-process
/var/folders/<session-id>/.../T/ folder that is NOT shared across CC
sessions of the same user. User home is the only guaranteed shared
location.
Stale-lock recovery runs every ~2s during wait. If the existing lock's
mtime is older than PASSTHRU_OVERLAY_LOCK_STALE_AFTER (default 180s),
the lock is force-removed. This prevents a hook that was SIGKILLed
(OOM, manual kill) from blocking every subsequent overlay forever.
Env knobs:
PASSTHRU_OVERLAY_LOCK_TIMEOUT(default 180s) - how long to wait for another session's overlay before falling back to CC's native dialog.PASSTHRU_OVERLAY_LOCK_STALE_AFTER(default 180s) - mtime age at which an existing lock is considered abandoned and auto-cleared.
Passthru is one of potentially several PreToolUse hooks AND sits alongside CC's built-in permission evaluation. Understanding which decision wins in which scenario is essential for debugging "why did the native dialog appear?" complaints.
Decision cascade after PreToolUse hooks return:
- If any hook emits
permissionDecision: "allow"- CC proceeds silently. - If any hook emits
permissionDecision: "deny"- CC blocks the tool. - If a hook emits
permissionDecision: "ask"- CC shows its NATIVE dialog. This is by design: "ask" explicitly defers to CC's UI. - If all hooks pass through (
{"continue": true}) - CC evaluates its ownpermissions.allowentries fromsettings.json. If none match, CC shows its native dialog.
Implication: passthru emitting ask (either explicitly or via overlay
fall-through / lock timeout) will trigger a native dialog. Only allow
fully suppresses it. This is why the compound readonly-filter fix (go test | tail now resolves to allow instead of ask) eliminates the native
dialog cascade.
Multi-plugin hook ordering:
CC runs PreToolUse hooks in plugin registration order. Each subsequent
hook sees tool_input as MODIFIED by previous hooks. Plugins like rtk
(which rewrites go test to rtk go test) can either run before or
after passthru depending on ordering:
- rtk BEFORE passthru: passthru sees
rtk go test .... User rule for^godoes not match. Falls through to overlay. - rtk AFTER passthru: passthru sees
go test .... User rule matches, decision is "allow". CC then runs rtk which rewrites the command, CC executes the rewritten command.
If the user reports seeing the overlay for BOTH go ... and rtk go ...
variants intermittently, hook ordering is non-deterministic or multiple
rtk code paths (proxy vs rewrite) are in play. Rule coverage should
anticipate both forms or use a broader pattern.
pre-tool-use.sh sends an OSC 777 desktop notification before invoking
the overlay so the user knows a prompt is waiting. Two gotchas:
- Must write to
/dev/tty, NOT stdout. Stdout is captured by CC as the hook's JSON response and the OSC sequence would pollute (or invalidate) the JSON payload. - Inside tmux, the OSC must be wrapped in DCS passthrough:
ESC P tmux; <inner> ESC \with every innerESCdoubled. Additionally tmux needsset -g allow-passthrough onin the user's tmux.conf. Without passthrough, tmux strips the OSC and Ghostty/iTerm2 never sees it.
OSC 777 format: ESC ] 777 ; notify ; <title> ; <body> BEL. Supported
by Ghostty, iTerm2, Konsole, and most modern terminal emulators.
For Bash tool calls, the hook splits compound commands into segments before
matching. The splitter (split_bash_command in hooks/common.sh) uses
inline perl to tokenize the command respecting single quotes, double quotes,
$() subshells, backticks, and backslash escaping. It splits on unquoted
|, &&, ||, ;, and &, and strips redirections (>, >>, <,
2>&1, N>file) from each segment.
Matching after splitting follows these rules:
- Deny: each segment is checked against deny rules. ANY segment matching a deny rule causes the whole command to be denied.
- Allow: ALL segments must match allow rules (each segment may match a different rule). If any segment has no match, the command falls through.
- Ask: if any segment's first match is an ask rule (and no segment was denied), the whole command triggers ask.
Fail-safe: parse errors (unterminated quotes, etc.) return the original command as a single segment, preserving the pre-split behavior.
The splitter always runs for Bash commands. Single commands (no operators) produce one segment and are matched identically to the previous behavior.
After deny checking (deny always wins) and before allow/ask matching, the hook checks whether ALL segments of a Bash command are read-only. If so, the command is auto-allowed without needing explicit allow rules.
The readonly command list mirrors Claude Code's readOnlyValidation.ts:
- Simple commands (generic safety regex
^<cmd>(?:\s|$)[^<>()$\x60|{}&;\n\r]*$):cal,uptime,cat,head,tail,wc,stat,strings,hexdump,od,nl,id,uname,free,df,du,locale,groups,nproc,basename,dirname,realpath,cut,paste,tr,column,tac,rev,fold,expand,unexpand,fmt,comm,cmp,numfmt,readlink,diff,true,false,sleep,which,type,expr,test,getconf,seq,tsort,pr - Two-word commands (same safety regex):
docker ps,docker images - Custom regex commands:
echo(no$/backticks),pwd,whoami,ls,find(no-exec/-delete),cd,jq(no-f/--from-file),uniq,history,alias,arch,node -v,python --version,python3 --version
Path validation: after a segment matches a readonly regex, all absolute
path arguments are checked against cwd and allowed dirs via
_pm_path_inside_any_allowed. Relative paths are assumed to resolve inside
cwd. This prevents cat /etc/passwd from being auto-allowed while allowing
cat src/main.rs.
Auto-allowed commands are logged with source passthru-readonly and reason
readonly:<first-word>.
The allowed_dirs field in passthru.json extends the trusted directory set
for path-based auto-allow. It affects:
- Mode auto-allow (
permission_mode_auto_allows): Read/Edit/Write/Grep/ Glob/LS tools with paths in any allowed dir are treated the same as files inside cwd. - Readonly auto-allow (
readonly_paths_allowed): absolute path arguments in read-only Bash commands are checked against cwd AND each allowed dir.
load_allowed_dirs in hooks/common.sh reads allowed_dirs from all four
rule files, concatenates, and deduplicates. It is separate from load_rules
to preserve the {version, allow, deny, ask} contract. Bootstrap imports
Claude Code's additionalAllowedWorkingDirs from settings and writes them
to allowed_dirs in passthru.imported.json.
See docs/rule-format.md for the schema and CONTRIBUTING.md for guidance
on extending allowed_dirs support.
Agent, Skill, and Glob are always auto-allowed with an explicit allow
decision (not passthrough). This runs before rule loading (step 3b in
pre-tool-use.sh) so it is fast and cannot be affected by broken rule files.
These tools are logged with source passthru-internal.
ToolSearch, TaskCreate, and other CC-internal tools remain in the step 7
passthrough list and emit {"continue": true}.
Use the release-tools:new skill (/release-tools:new) to cut a new release. The skill handles version calculation, the GitHub release, and the description prompt.
Naming:
- Tag:
vX.Y.Z(e.g.v0.2.0). - Release title: same as tag (
v0.2.0), NOTVersion 0.2.0.
Version selection:
- Minor (
v0.1.0->v0.2.0): default for most releases. Use when a PR addsfeatcommits, new commands, new rule-schema fields, or user-visible behavior changes. - Hotfix (
v0.2.0->v0.2.1): PR containsfixcommits exclusively (no feat, no breaking changes). - Major (
v0.2.0->v1.0.0): breaking changes to rule schema (newversion), slash command names, or hook contract. Always discuss with the user before a major bump. Never pick major autonomously.
Skip releases for: chore, docs, ci, test only PRs. The changes ship with the next feature release.
Two-file version bump. Before tagging, update BOTH of:
.claude-plugin/plugin.json- theversionfield..claude-plugin/marketplace.json- theversionfield at the top level.
Both take the numeric form without the v prefix (0.2.0, not v0.2.0). Commit the bump as:
chore(release): vX.Y.Z
Then create the tag vX.Y.Z (with the v). The release-tools:new skill may or may not handle the two-file bump automatically. If it does not, do the edits manually before invoking the skill, or update the skill invocation to cover both files.
The release flow in one-line form:
- Merge the PR(s) into
main. - Bump
.claude-plugin/plugin.jsonand.claude-plugin/marketplace.jsonto the new version. - Commit
chore(release): vX.Y.Z. - Run
/release-tools:newand pick the right increment. - Verify the tag and release appear on GitHub.
- Adding a new slash command: create
commands/<name>.mdwith the YAML frontmatter (see existing commands for the pattern). The file name becomes/passthru:<name>automatically. - Adding a new hook event: register it in
hooks/hooks.jsonand add a handler underhooks/handlers/. Reusehooks/common.shhelpers where possible. - Adding a new verifier check: see
CONTRIBUTING.mdsection "Adding a new verifier check". - Adding a new rule type or schema field: see
CONTRIBUTING.mdsection "Rule schema evolution". - Changing the overlay UI or keyboard flow:
scripts/overlay-dialog.shis the TUI,scripts/overlay.shis the multiplexer dispatcher, andscripts/overlay-propose-rule.shproposes the regex on A/D. Test viaPASSTHRU_OVERLAY_TEST_ANSWER; seetests/overlay.batsfor the stub-tmux pattern. - Changing ask-rule semantics: the merged document-order logic sits in
hooks/common.sh(find_first_match) andhooks/handlers/pre-tool-use.sh. Ask rule parsing + validation is invalidate_rules+load_rules. The verifier's conflict and shadowing checks inscripts/verify.shmust also coverask[]. - Adding a new overlay multiplexer backend: add detection + launch lines in
scripts/overlay.sh(search for the tmux / kitty / wezterm branches) and a stub fixture intests/fixtures/overlay/. The shared detector helper lives inhooks/common.sh(detect_overlay_multiplexer). - Adding a new readonly command: add the command to
PASSTHRU_READONLY_COMMANDS(simple),PASSTHRU_READONLY_TWO_WORD_COMMANDS(two-word), orPASSTHRU_READONLY_CUSTOM_REGEXES(custom regex) inhooks/common.sh. Test viatests/hook_handler.bats. SeeCONTRIBUTING.mdsection "Extending the readonly command list". - Changing compound command splitting: the splitter is
split_bash_commandinhooks/common.sh(inline perl). The per-segment matching logic ismatch_all_segmentsin the same file. Test viatests/command_splitting.bats. - Working with allowed dirs: see
CONTRIBUTING.mdsection "Working withallowed_dirs". Key functions areload_allowed_dirs,_pm_path_inside_any_allowed, andpermission_mode_auto_allows(5th parameter) inhooks/common.sh.