Thanks for looking at claude-passthru. This doc covers the dev loop, how tests run, and the rule-schema evolution policy.
Load the plugin from a working tree instead of through the marketplace:
claude --plugin-dir /path/to/claude-passthru
Every Claude Code restart re-reads the plugin from disk. No /plugin install, no marketplace cache to flush. This is the fastest iteration loop.
The scripts and hooks honor environment overrides so bats tests and local experiments do not touch your real ~/.claude:
PASSTHRU_USER_HOME- overrides the user scope root. Default$HOME.PASSTHRU_PROJECT_DIR- overrides the project scope root. Default$PWD.PASSTHRU_WRITE_LOCK_TIMEOUT- lock acquisition timeout in seconds forscripts/write-rule.sh(and the slash commands plusbootstrap.sh --writethat call into it). Default5. Lower it in concurrency tests, raise it on slow filesystems.
All shell logic is covered by bats. Run the full suite:
bats tests/*.bats
Targeted run for one file while iterating:
bats tests/hook_handler.bats
Install bats-core 1.9+ from Homebrew (brew install bats-core) or npm (npm install -g bats).
The test fixtures live under tests/fixtures/ and cover every combination of user-scope, project-scope, authored, and imported rule files.
The PreToolUse hook reads JSON on stdin and writes a decision to stdout. You can exercise it without Claude Code attached:
echo '{
"tool_name": "Bash",
"tool_input": { "command": "gh api /repos/foo/bar/forks" }
}' | bash hooks/handlers/pre-tool-use.sh
Expected output: the hook's decision JSON ({ "hookSpecificOutput": { "permissionDecision": "allow", ... } } when a rule matches, { "continue": true } on passthrough).
Point the hook at a specific rule set via env overrides:
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
The PostToolUse handler works the same way (it reads the tool_use_id and classifies the native dialog outcome).
passthru.json files have a top-level version field. Today only version: 1 is recognized and the verifier rejects anything else.
When adding a breaking change to the schema (renaming a field, changing a field's semantics, removing a field):
- Bump the schema version from
1to2in the verifier, hook, and docs. - Add a migration path if the new format cannot be read by the old loader. At minimum, the verifier should print a clear error pointing users at an upgrade doc.
- Call the change out in the release notes (see Releases section in
CLAUDE.md).
Non-breaking additions (new optional fields, new optional top-level keys) do not require a version bump. They stay on version: 1.
scripts/verify.sh has a stable structure. Add a new check by following the existing pattern:
- Inside the per-file schema pass, add a branch that tests the condition and calls the
diaghelper:Usediag error "$file" ".allow[$idx].some_field" "$idx" "schema: some_field must be ..."diag warnfor non-fatal findings. The helper takes care of JSON vs plain output formatting and increments the right counter. - Cross-file checks (duplicates, conflicts, shadowing) live later in the script and operate on the merged rule set. Add new cross-file checks there.
- Add bats tests in
tests/verifier.batscovering the success and failure case. Fixtures go intests/fixtures/.
The readonly auto-allow list lives in hooks/common.sh across three arrays:
PASSTHRU_READONLY_COMMANDS- simple commands using the generic safety regex (^<cmd>(?:\s|$)[^<>()$\x60|{}&;\n\r]*$). Add commands here when the generic regex is sufficient (no special flags or subcommands to worry about).PASSTHRU_READONLY_TWO_WORD_COMMANDS- two-word commands likedocker psthat use the same generic safety regex with the full two-word prefix.PASSTHRU_READONLY_CUSTOM_REGEXES- full PCRE patterns for commands needing custom validation (e.g.echorejects$/backticks,findrejects-exec/-delete,jqrejects-f/--from-file).
To add a new readonly command:
- Decide which array it belongs in. Most simple commands go in
PASSTHRU_READONLY_COMMANDS. Only use a custom regex when the generic safety pattern is insufficient. - Add the entry to the appropriate array in
hooks/common.sh. - Add tests in
tests/hook_handler.batscovering both the positive case (command auto-allowed) and the negative case (dangerous variant not auto-allowed). - Run the full test suite:
bats tests/*.bats.
The list mirrors Claude Code's readOnlyValidation.ts. Check CC source when adding commands to keep the two lists in sync.
The compound command splitter (split_bash_command in hooks/common.sh) uses inline perl to tokenize Bash commands. It handles:
- Single/double quotes,
$()subshells (nested), backticks, backslash escaping - Splitting on unquoted
|,&&,||,;,& - Stripping redirections (
>,>>,<,2>&1,N>file)
The per-segment matching algorithm (match_all_segments in hooks/common.sh) implements:
- Deny: ANY segment matching a deny rule blocks the whole command
- Allow: ALL segments must match. Different segments may match different rules
- Ask: ANY segment matching ask (with no deny) triggers ask
Tests live in tests/command_splitting.bats (splitter unit tests) and tests/hook_handler.bats (integration tests for compound matching in the hook).
When modifying the splitter:
- Add tests in
tests/command_splitting.batsfirst. - The fail-safe behavior (parse errors return original command as one segment) must be preserved.
- The perl tokenizer handles all splitting and redirection stripping in a single process for performance.
The allowed_dirs field in passthru.json extends the set of trusted directories for path-based auto-allow. When adding or modifying allowed_dirs support:
load_allowed_dirsinhooks/common.shreads all four rule files and returns a deduplicated JSON array. It is separate fromload_rulesto preserve the{version, allow, deny, ask}contract._pm_path_inside_any_allowedchecks a path against both cwd and each allowed dir. It is used bypermission_mode_auto_allowsandreadonly_paths_allowed.permission_mode_auto_allowsaccepts an optional 5th parameter (allowed_dirs_json). Callers that do not pass it get the old behavior (cwd only).validate_rulestolerates theallowed_dirskey and validates entries: must be an array of non-empty strings, rejects path traversal (/../).- Bootstrap imports
additionalAllowedWorkingDirsfrom CC'ssettings.jsonviaextract_allowed_dirsand writes them toallowed_dirsinpassthru.imported.json.
main is protected on GitHub. All changes must go through pull requests. Direct pushes to main are blocked.
Commits follow the scoped Conventional Commits style: type(scope): description. Scope is always required. PR titles use the same format.
Release cuts happen from main via the release-tools:new skill. Never cut a release from a feature branch. See the Releases section in CLAUDE.md for the full procedure.