diff --git a/CLAUDE.md b/CLAUDE.md index 2469084..27ff35f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,14 @@ hooks/ 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 @@ -36,7 +43,9 @@ hooks/ Sourced by hook handlers AND by scripts/log.sh, scripts/verify.sh, scripts/write-rule.sh. handlers/ - pre-tool-use.sh main hook: loads rules, matches, emits allow/deny/passthrough + 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 @@ -71,8 +80,9 @@ scripts/ 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 prefix, Read/Edit/Write path, - WebFetch URL host, MCP namespace). Unknown tool -> bare ^$ rule. + 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 ^$ rule. overlay-config.sh overlay sentinel toggle + multiplexer detection reporter. Backs /passthru:overlay. tests/ @@ -84,6 +94,8 @@ tests/ 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 @@ -244,6 +256,90 @@ 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. +## Compound command splitting + +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. + +## Readonly Bash command auto-allow + +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 `^(?:\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:`. + +## Allowed directories + +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. + +## Internal tool auto-allow + +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}`. + ## Releases 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. @@ -291,3 +387,6 @@ The release flow in one-line form: * Changing the overlay UI or keyboard flow: `scripts/overlay-dialog.sh` is the TUI, `scripts/overlay.sh` is the multiplexer dispatcher, and `scripts/overlay-propose-rule.sh` proposes the regex on A/D. Test via `PASSTHRU_OVERLAY_TEST_ANSWER`; see `tests/overlay.bats` for the stub-tmux pattern. * Changing ask-rule semantics: the merged document-order logic sits in `hooks/common.sh` (`find_first_match`) and `hooks/handlers/pre-tool-use.sh`. Ask rule parsing + validation is in `validate_rules` + `load_rules`. The verifier's conflict and shadowing checks in `scripts/verify.sh` must also cover `ask[]`. * 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 in `tests/fixtures/overlay/`. The shared detector helper lives in `hooks/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), or `PASSTHRU_READONLY_CUSTOM_REGEXES` (custom regex) in `hooks/common.sh`. Test via `tests/hook_handler.bats`. See `CONTRIBUTING.md` section "Extending the readonly command list". +* Changing compound command splitting: the splitter is `split_bash_command` in `hooks/common.sh` (inline perl). The per-segment matching logic is `match_all_segments` in the same file. Test via `tests/command_splitting.bats`. +* Working with allowed dirs: see `CONTRIBUTING.md` section "Working with `allowed_dirs`". Key functions are `load_allowed_dirs`, `_pm_path_inside_any_allowed`, and `permission_mode_auto_allows` (5th parameter) in `hooks/common.sh`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb24c45..e7072dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,55 @@ Non-breaking additions (new optional fields, new optional top-level keys) do not 2. Cross-file checks (duplicates, conflicts, shadowing) live later in the script and operate on the merged rule set. Add new cross-file checks there. 3. Add bats tests in `tests/verifier.bats` covering the success and failure case. Fixtures go in `tests/fixtures/`. +## Extending the readonly command list + +The readonly auto-allow list lives in `hooks/common.sh` across three arrays: + +* `PASSTHRU_READONLY_COMMANDS` - simple commands using the generic safety regex (`^(?:\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 like `docker ps` that 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. `echo` rejects `$`/backticks, `find` rejects `-exec`/`-delete`, `jq` rejects `-f`/`--from-file`). + +To add a new readonly command: + +1. 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. +2. Add the entry to the appropriate array in `hooks/common.sh`. +3. Add tests in `tests/hook_handler.bats` covering both the positive case (command auto-allowed) and the negative case (dangerous variant not auto-allowed). +4. 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. + +## Extending the compound command splitter + +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: + +1. Add tests in `tests/command_splitting.bats` first. +2. The fail-safe behavior (parse errors return original command as one segment) must be preserved. +3. The perl tokenizer handles all splitting and redirection stripping in a single process for performance. + +## Working with `allowed_dirs` + +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_dirs` in `hooks/common.sh` reads all four rule files and returns a deduplicated JSON array. It is separate from `load_rules` to preserve the `{version, allow, deny, ask}` contract. +* `_pm_path_inside_any_allowed` checks a path against both cwd and each allowed dir. It is used by `permission_mode_auto_allows` and `readonly_paths_allowed`. +* `permission_mode_auto_allows` accepts an optional 5th parameter (`allowed_dirs_json`). Callers that do not pass it get the old behavior (cwd only). +* `validate_rules` tolerates the `allowed_dirs` key and validates entries: must be an array of non-empty strings, rejects path traversal (`/../`). +* Bootstrap imports `additionalAllowedWorkingDirs` from CC's `settings.json` via `extract_allowed_dirs` and writes them to `allowed_dirs` in `passthru.imported.json`. + ## Branch policy `main` is protected on GitHub. All changes must go through pull requests. Direct pushes to `main` are blocked. diff --git a/README.md b/README.md index d05613e..4a5ed29 100644 --- a/README.md +++ b/README.md @@ -30,11 +30,15 @@ More examples: shape-matching a `gh api` endpoint across any owner/repo pair, al ## What you can do * **Regex-based Bash prefixes.** Auto-allow a directory of scripts, a shell pipeline, or any command family the native glob syntax cannot express. +* **Compound command splitting.** `ls | head && echo done` is split into three segments. Each segment is matched independently. Deny on ANY segment blocks the whole command. Allow requires ALL segments to match. Mirrors Claude Code's `splitCommand()` approach. +* **Read-only command auto-allow.** Common read-only commands (`cat`, `head`, `tail`, `ls`, `wc`, `stat`, `diff`, `jq`, etc.) are auto-allowed without explicit rules when their path arguments stay inside the working directory or allowed dirs. Mirrors Claude Code's `makeRegexForSafeCommand()` pattern. * **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. +* **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. Proposed Bash rules are fully anchored with CC's safe character class to block compound operator injection. +* **Additional allowed directories.** Extend the trusted directory set beyond cwd via `allowed_dirs` in passthru.json. Bootstrap imports Claude Code's `additionalAllowedWorkingDirs` automatically. +* **Internal tool auto-allow.** Agent, Skill, and Glob are auto-allowed without rules or prompts. No more "Use skill?" confirmations. * **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:verify` to catch bad JSON, invalid regex, and allow/deny conflicts before they silently disable rules. * **First-run bootstrap.** One-shot `/passthru:bootstrap` command (or `scripts/bootstrap.sh` for scripting) that converts existing native `permissions.allow` entries into passthru rules. A `SessionStart` hint fires whenever `settings.json` has importable entries that are not yet in `passthru.imported.json` and auto-silences after the next bootstrap run. @@ -86,6 +90,8 @@ Native rules solve the common case. They fall short when: `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. +For Bash commands, passthru splits compound commands (piped, chained with `&&`/`||`/`;`/`&`) into segments and matches each one independently. Read-only commands (`cat`, `head`, `ls`, `wc`, etc.) are auto-allowed when their path arguments stay inside the working directory or configured allowed directories. + Works across every tool Claude Code exposes (`Bash`, `PowerShell`, `Read`, `Edit`, `Write`, `WebFetch`, MCP tools, and so on). ## First-run bootstrap @@ -294,17 +300,61 @@ The hook picks the first detected multiplexer whose binary is also on `$PATH`. I **When the overlay fires.** The hook runs the normal decision pipeline first. The overlay only fires when nothing else matched: -1. `deny` rule match -> immediate deny, no overlay. -2. `allow` rule match -> immediate allow, no overlay. -3. `ask` rule 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. -4. No rule match -> check Claude Code's `permission_mode` auto-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. +1. `deny` rule match -> immediate deny, no overlay. For compound Bash commands, ANY segment matching deny blocks the whole command. +2. Read-only auto-allow -> immediate allow, no overlay. ALL segments must be readonly with valid paths. +3. `allow` rule match -> immediate allow, no overlay. For compound Bash commands, ALL segments must match. +4. `ask` rule 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. +5. No rule match -> check Claude Code's `permission_mode` auto-allow rules: `bypassPermissions` (everything), `acceptEdits` + Write/Edit within cwd or allowed dirs, `default` + read tools (Read, Grep, Glob, NotebookRead, LS) within cwd or allowed dirs, `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 honors `additionalAllowedWorkingDirs`, 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 mode-based auto-allow replication is best-effort and errs on the conservative side. Claude Code resolves symlinks (`realpathSync`) and honors sandbox allowlists and internal-path predicates. The hook uses literal `$CWD/` prefix match, checks `allowed_dirs` (imported from Claude Code's `additionalAllowedWorkingDirs` via bootstrap), 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. +## Compound command splitting + +Bash commands containing pipes, logical operators, or semicolons are split into segments before matching. Each segment is matched independently against your rules. + +``` +echo hello && rm -rf / # split into: ["echo hello", "rm -rf /"] +cat file.txt | head -n 10 # split into: ["cat file.txt", "head -n 10"] +ls > /tmp/out # split into: ["ls"] (redirections stripped) +echo 'foo && bar' # single segment (quoted operators preserved) +``` + +Matching rules for compound commands: + +* **Deny** on ANY segment blocks the whole command. A deny rule matching `^rm` on the second segment of `echo hello && rm -rf /` denies the entire command. +* **Allow** requires ALL segments to match. Different segments may match different allow rules. If any segment has no matching allow rule, the command falls through to the overlay. +* **Ask** on ANY segment (with no deny) triggers ask for the whole command. + +The splitter respects single quotes, double quotes, `$()` subshells, backticks, and backslash escaping. Parse failures (unterminated quotes, etc.) fall back to treating the whole command as a single segment, preserving the pre-split behavior. + +## Read-only command auto-allow + +Common read-only Bash commands are auto-allowed without explicit rules when their path arguments stay inside the working directory or configured allowed directories. This runs after deny checking (deny always wins) and before allow/ask rule matching. + +Auto-allowed commands include: `cat`, `head`, `tail`, `wc`, `stat`, `ls`, `diff`, `du`, `df`, `realpath`, `readlink`, `basename`, `dirname`, `find` (without `-exec`/`-delete`), `jq` (without `-f`/`--from-file`), `echo` (without `$`/backticks), `docker ps`, `docker images`, and more. The full list mirrors Claude Code's `readOnlyValidation.ts`. + +**Path validation.** After a command matches the readonly pattern, all absolute path arguments are checked: + +* Absolute paths starting with `/` must be inside cwd or an `allowed_dirs` entry. +* Relative paths (no leading `/`) are assumed to resolve inside cwd and are allowed. +* Flag arguments (starting with `-`) are skipped. + +Examples: + +``` +cat src/main.rs # auto-allowed (relative path) +cat /Users/me/project/file.txt # auto-allowed when cwd is /Users/me/project +cat /etc/passwd # NOT auto-allowed (outside cwd) +cat file.txt | head -n 10 # auto-allowed (both segments readonly, relative paths) +cat file.txt | rm -rf / # NOT auto-allowed (rm is not readonly) +``` + +A deny rule always overrides readonly auto-allow. If you deny `^cat`, then `cat src/main.rs` is denied even though `cat` is readonly. + ## Ask rules `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. diff --git a/docs/plans/20260416-bash-security-auto-allow.md b/docs/plans/completed/20260416-bash-security-auto-allow.md similarity index 67% rename from docs/plans/20260416-bash-security-auto-allow.md rename to docs/plans/completed/20260416-bash-security-auto-allow.md index 45d68fe..6a1903c 100644 --- a/docs/plans/20260416-bash-security-auto-allow.md +++ b/docs/plans/completed/20260416-bash-security-auto-allow.md @@ -2,7 +2,7 @@ ## Overview - Harden Bash command matching by splitting compound commands and matching each segment independently, mirroring Claude Code's approach -- Auto-allow Agent and Skill tools as internal tools (explicit allow, not passthrough) +- Auto-allow Agent, Skill, and Glob tools as internal tools (explicit allow, not passthrough) - Auto-allow read-only Bash commands (cat, head, tail, etc.) when path arguments are inside cwd or allowed dirs, using CC's safety regex pattern - Add `$` anchoring to overlay-proposed Bash regexes - Support additional allowed directories for path-based auto-allow (Read/Edit/Write/Grep/Glob/LS and readonly Bash commands) @@ -16,7 +16,7 @@ - passthru's `match_rule` currently matches the entire command string against regex. No splitting. - `overlay-propose-rule.sh` proposes `^\s` for Bash. No `$` anchor. Bare commands (e.g. `ls`) don't match. - `permission_mode_auto_allows` only checks `$cwd`. CC also checks `additionalAllowedWorkingDirs`. -- Internal tool pass-through list emits `{"continue": true}` for Skill (triggers CC's native prompt). Agent is not handled at all (falls through to overlay). +- Internal tool pass-through list emits `{"continue": true}` for Skill (triggers CC's native prompt). Agent and Glob are not handled at all (fall through to overlay). ## Development Approach - **testing approach**: Regular (code first, then tests) @@ -85,11 +85,11 @@ Path extraction: after matching the readonly regex, extract all non-flag tokens Checked AFTER deny (deny always wins) and AFTER compound splitting (operates on segments). Checked BEFORE allow/ask document-order matching. This means deny rules can block read-only commands, but read-only commands don't need explicit allow rules. -### Auto-allow Agent + Skill -Add Agent and Skill to a new explicit-allow step early in the handler (before rule loading). Agent is not currently handled at all (falls through to overlay). Skill is currently in the internal tool passthrough list (emits `{"continue": true}`, which triggers CC's native "Use skill?" prompt). Both will emit `permissionDecision: "allow"` so CC never shows its own confirmation dialog. ToolSearch and the remaining CC-internal tools stay in the existing passthrough list. +### Auto-allow Agent, Skill, and Glob +Add Agent, Skill, and Glob to a new explicit-allow step early in the handler (before rule loading). Agent is not currently handled at all (falls through to overlay). Skill is currently in the internal tool passthrough list (emits `{"continue": true}`, which triggers CC's native "Use skill?" prompt). Glob is a file pattern matching tool that should be auto-allowed like Agent and Skill. All three will emit `permissionDecision: "allow"` so CC never shows its own confirmation dialog. ToolSearch and the remaining CC-internal tools stay in the existing passthrough list. ### Overlay proposal anchoring -Change Bash proposals from `^\s` to `^(\s.*)?$`. This is fully anchored (both `^` and `$`) and handles bare commands (`ls`) and commands with args (`ls -la /tmp`). +Change Bash proposals from `^\s` to `^(\s[^<>()$\x60|{}&;\n\r]*)?$`. This is fully anchored (both `^` and `$`), handles bare commands (`ls`) and commands with args (`ls -la /tmp`), and uses CC's safe character class to block compound command injection (e.g. `ls && evil` will not match a rule meant for `ls`). Read/Edit/Write proposals use `^/` (intentionally a prefix for path matching, no `$`). WebFetch/WebSearch proposals use `^https?://` (intentionally a prefix for URL matching, no `$`). No changes needed for these. @@ -149,25 +149,25 @@ Both authored and imported passthru.json files may declare `allowed_dirs`. `load - Modify: `hooks/common.sh` (add `split_bash_command` function) - Create: `tests/command_splitting.bats` -- [ ] add `split_bash_command ` function to `hooks/common.sh` using inline perl -- [ ] implement quote-aware splitting by `|`, `&&`, `||`, `;`, `&` (respect single/double quotes, `$()`, backticks, backslash escaping) -- [ ] strip redirections (`>`, `>>`, `<`, `2>&1`, `2>/dev/null`) from each segment inside the perl tokenizer -- [ ] output NUL-separated segments, filter empty segments -- [ ] fail-safe: parse errors return original command as single segment -- [ ] write tests for single commands (no split needed, returns 1 segment) -- [ ] write tests for pipe splitting: `ls | head` -> `["ls", "head"]` -- [ ] write tests for `&&` and `||` splitting -- [ ] write tests for `;` and `&` splitting -- [ ] write tests for quoted strings preserved: `echo 'foo && bar'` -> single segment -- [ ] write tests for double-quoted strings preserved: `echo "foo | bar"` -> single segment -- [ ] write tests for `$()` subshell preserved: `echo $(foo | bar)` -> single segment -- [ ] write tests for nested subshell: `echo $(cat $(find . -name "*.txt"))` -> single segment -- [ ] write tests for backtick subshell preserved: `` echo `foo | bar` `` -> single segment -- [ ] write tests for redirection stripping: `ls > /tmp/out` -> `["ls"]` -- [ ] write tests for stderr redirect stripping: `cmd 2>&1` -> `["cmd"]` -- [ ] write tests for mixed: `curl url | head && echo done` -> `["curl url", "head", "echo done"]` -- [ ] write test for parse failure fallback (malformed quoting returns original as single segment) -- [ ] run tests - must pass before next task +- [x] add `split_bash_command ` function to `hooks/common.sh` using inline perl +- [x] implement quote-aware splitting by `|`, `&&`, `||`, `;`, `&` (respect single/double quotes, `$()`, backticks, backslash escaping) +- [x] strip redirections (`>`, `>>`, `<`, `2>&1`, `2>/dev/null`) from each segment inside the perl tokenizer +- [x] output NUL-separated segments, filter empty segments +- [x] fail-safe: parse errors return original command as single segment +- [x] write tests for single commands (no split needed, returns 1 segment) +- [x] write tests for pipe splitting: `ls | head` -> `["ls", "head"]` +- [x] write tests for `&&` and `||` splitting +- [x] write tests for `;` and `&` splitting +- [x] write tests for quoted strings preserved: `echo 'foo && bar'` -> single segment +- [x] write tests for double-quoted strings preserved: `echo "foo | bar"` -> single segment +- [x] write tests for `$()` subshell preserved: `echo $(foo | bar)` -> single segment +- [x] write tests for nested subshell: `echo $(cat $(find . -name "*.txt"))` -> single segment +- [x] write tests for backtick subshell preserved: `` echo `foo | bar` `` -> single segment +- [x] write tests for redirection stripping: `ls > /tmp/out` -> `["ls"]` +- [x] write tests for stderr redirect stripping: `cmd 2>&1` -> `["cmd"]` +- [x] write tests for mixed: `curl url | head && echo done` -> `["curl url", "head", "echo done"]` +- [x] write test for parse failure fallback (malformed quoting returns original as single segment) +- [x] run tests - must pass before next task ### Task 2: Integrate splitter into pre-tool-use matching @@ -176,16 +176,16 @@ Both authored and imported passthru.json files may declare `allowed_dirs`. `load - Modify: `hooks/common.sh` (add `match_all_segments` helper) - Modify: `tests/hook_handler.bats` (add compound command test cases) -- [ ] add `match_all_segments ` helper that implements the per-segment-first-match algorithm described in Solution Overview -- [ ] in step 5 (deny matching): for Bash tool, split command into segments via `split_bash_command`, check each segment against deny rules. ANY segment matching deny -> deny whole command -- [ ] in step 6 (allow/ask matching): for Bash tool with multiple segments, use `match_all_segments` instead of the current single-match loop. For single-segment commands, use the existing loop (no behavior change) -- [ ] write tests: deny rule on second segment blocks compound command (`echo hello && rm -rf /` with deny on `^rm`) -- [ ] write tests: allow rule on first segment only does NOT allow compound command -- [ ] write tests: allow rules covering ALL segments allows compound command (two different rules covering two segments) -- [ ] write tests: ask rule on any segment triggers ask for compound -- [ ] write tests: one segment matches allow, another has no match -> falls through to overlay -- [ ] write tests: single command (no operators) works identically to current behavior -- [ ] run full test suite - must pass before next task +- [x] add `match_all_segments ` helper that implements the per-segment-first-match algorithm described in Solution Overview +- [x] in step 5 (deny matching): for Bash tool, split command into segments via `split_bash_command`, check each segment against deny rules. ANY segment matching deny -> deny whole command +- [x] in step 6 (allow/ask matching): for Bash tool with multiple segments, use `match_all_segments` instead of the current single-match loop. For single-segment commands, use the existing loop (no behavior change) +- [x] write tests: deny rule on second segment blocks compound command (`echo hello && rm -rf /` with deny on `^rm`) +- [x] write tests: allow rule on first segment only does NOT allow compound command +- [x] write tests: allow rules covering ALL segments allows compound command (two different rules covering two segments) +- [x] write tests: ask rule on any segment triggers ask for compound +- [x] write tests: one segment matches allow, another has no match -> falls through to overlay +- [x] write tests: single command (no operators) works identically to current behavior +- [x] run full test suite - must pass before next task ### Task 3: Read-only Bash command auto-allow @@ -194,43 +194,47 @@ Both authored and imported passthru.json files may declare `allowed_dirs`. `load - Modify: `hooks/handlers/pre-tool-use.sh` (add readonly check after deny, before allow/ask) - Modify: `tests/hook_handler.bats` (add readonly auto-allow tests) -- [ ] add `PASSTHRU_READONLY_COMMANDS` array in `hooks/common.sh` mirroring CC's simple command list -- [ ] add `PASSTHRU_READONLY_REGEXES` array for commands needing custom patterns (echo, pwd, ls, find, cd, jq, etc.) -- [ ] add `is_readonly_command ` function: iterates full PCRE list against the segment (not first-word lookup). Returns 0 if readonly, 1 otherwise -- [ ] add `readonly_paths_allowed ` function: extracts non-flag tokens, checks absolute paths against cwd + allowed dirs. Returns 0 if all paths allowed, 1 if any outside -- [ ] for compound commands: split first (Task 1), then check ALL segments. All must be readonly AND have valid paths -- [ ] insert readonly check in pre-tool-use.sh after deny (step 5) and before allow/ask (step 6). Operates on segments, not raw command -- [ ] emit explicit allow with reason "passthru readonly: " and audit source "passthru-readonly" -- [ ] write tests: `cat src/main.rs` auto-allowed (relative path, inside cwd) -- [ ] write tests: `cat /Users/me/project/src/main.rs` auto-allowed when cwd is `/Users/me/project` -- [ ] write tests: `cat /etc/passwd` NOT auto-allowed (absolute path outside cwd) -- [ ] write tests: `head -n 10 file.txt` auto-allowed (relative path) -- [ ] write tests: `ls /Users/me/project/docs/` auto-allowed when cwd is `/Users/me/project` -- [ ] write tests: `ls /tmp/random` NOT auto-allowed (outside cwd) -- [ ] write tests: `cat file.txt | head` auto-allowed (both segments readonly, relative paths) -- [ ] write tests: `cat file.txt | rm -rf /` NOT auto-allowed (rm is not readonly) -- [ ] write tests: deny rule overrides readonly auto-allow -- [ ] write tests: `echo "safe string"` auto-allowed, `echo $(dangerous)` not auto-allowed -- [ ] write tests: `docker ps` matches `docker ps` regex, `docker exec` does NOT -- [ ] write tests: readonly + allowed_dirs integration (path in allowed dir is auto-allowed) -- [ ] run full test suite - must pass before next task - -### Task 4: Auto-allow Agent and Skill tools +- [x] add `PASSTHRU_READONLY_COMMANDS` array in `hooks/common.sh` mirroring CC's simple command list +- [x] add `PASSTHRU_READONLY_REGEXES` array for commands needing custom patterns (echo, pwd, ls, find, cd, jq, etc.) +- [x] add `is_readonly_command ` function: iterates full PCRE list against the segment (not first-word lookup). Returns 0 if readonly, 1 otherwise +- [x] add `readonly_paths_allowed ` function: extracts non-flag tokens, checks absolute paths against cwd + allowed dirs. Returns 0 if all paths allowed, 1 if any outside +- [x] for compound commands: split first (Task 1), then check ALL segments. All must be readonly AND have valid paths +- [x] insert readonly check in pre-tool-use.sh after deny (step 5) and before allow/ask (step 6). Operates on segments, not raw command +- [x] emit explicit allow with reason "passthru readonly: " and audit source "passthru-readonly" +- [x] write tests: `cat src/main.rs` auto-allowed (relative path, inside cwd) +- [x] write tests: `cat /Users/me/project/src/main.rs` auto-allowed when cwd is `/Users/me/project` +- [x] write tests: `cat /etc/passwd` NOT auto-allowed (absolute path outside cwd) +- [x] write tests: `head -n 10 file.txt` auto-allowed (relative path) +- [x] write tests: `ls /Users/me/project/docs/` auto-allowed when cwd is `/Users/me/project` +- [x] write tests: `ls /tmp/random` NOT auto-allowed (outside cwd) +- [x] write tests: `cat file.txt | head` auto-allowed (both segments readonly, relative paths) +- [x] write tests: `cat file.txt | rm -rf /` NOT auto-allowed (rm is not readonly) +- [x] write tests: deny rule overrides readonly auto-allow +- [x] write tests: `echo "safe string"` auto-allowed, `echo $(dangerous)` not auto-allowed +- [x] write tests: `docker ps` matches `docker ps` regex, `docker exec` does NOT +- [x] write tests: readonly + allowed_dirs integration (path in allowed dir is auto-allowed) +- [x] run full test suite - must pass before next task + +### Task 4: Auto-allow Agent, Skill, and Glob tools **Files:** -- Modify: `hooks/handlers/pre-tool-use.sh` (add Agent/Skill to explicit allow, remove Skill from passthrough list) -- Modify: `tests/hook_handler.bats` (add Agent/Skill auto-allow tests) - -- [ ] add new step between current step 3 (plugin self-allow) and step 4 (load rules): internal tool explicit allow -- [ ] emit `permissionDecision: "allow"` with reason "passthru internal: " for Agent and Skill -- [ ] remove Skill from existing step 7 internal tool passthrough list (it moves to the new explicit allow step) -- [ ] keep ToolSearch, TaskCreate, AskUserQuestion, and all other CC-internal tools in step 7 passthrough list -- [ ] audit Agent and Skill as source "passthru-internal" -- [ ] write tests: Agent tool call returns explicit allow decision (not passthrough) -- [ ] write tests: Skill tool call returns explicit allow decision (not passthrough) -- [ ] write tests: ToolSearch still returns passthrough (not allow) -- [ ] write tests: TaskCreate still returns passthrough (not allow) -- [ ] run full test suite - must pass before next task +- Modify: `hooks/handlers/pre-tool-use.sh` (add Agent/Skill/Glob to explicit allow, remove Skill from passthrough list) +- Modify: `tests/hook_handler.bats` (add Agent/Skill/Glob auto-allow tests) + +- [x] add new step between current step 3 (plugin self-allow) and step 4 (load rules): internal tool explicit allow +- [x] emit `permissionDecision: "allow"` with reason "passthru internal: " for Agent, Skill, and Glob +- [x] remove Skill from existing step 7 internal tool passthrough list (it moves to the new explicit allow step) +- [x] keep ToolSearch, TaskCreate, AskUserQuestion, and all other CC-internal tools in step 7 passthrough list +- [x] audit Agent, Skill, and Glob as source "passthru-internal" +- [x] write tests: Agent tool call returns explicit allow decision (not passthrough) +- [x] write tests: Skill tool call returns explicit allow decision (not passthrough) +- [x] write tests: Glob tool call returns explicit allow decision (not passthrough) +- [x] write tests: ToolSearch still returns passthrough (not allow) +- [x] write tests: TaskCreate still returns passthrough (not allow) +- [x] write tests: Agent/Skill/Glob audit logged with source passthru-internal +- [x] write tests: Agent bypasses rule loading (works even with broken rules) +- [x] write tests: Skill bypasses deny rules (checked before rule matching) +- [x] run full test suite - must pass before next task ### Task 5: Anchor overlay-proposed Bash regexes @@ -238,14 +242,14 @@ Both authored and imported passthru.json files may declare `allowed_dirs`. `load - Modify: `scripts/overlay-propose-rule.sh` (fix Bash category anchoring) - Modify: `tests/overlay.bats` (update/add overlay proposal tests) -- [ ] change Bash category from `^\s` to `^(\s.*)?$` -- [ ] this matches bare commands (`ls`), commands with args (`ls -la`), and is fully anchored -- [ ] Read/Edit/Write proposals: intentionally left as prefix (`^/`), no change needed -- [ ] WebFetch/WebSearch proposals: intentionally left as prefix (`^https?://`), no change needed -- [ ] write tests: proposed rule for `ls` matches `ls` and `ls -la` but not `ls && evil` -- [ ] write tests: proposed rule for `git status` matches bare invocation -- [ ] write tests: proposed rule for bare command (no args) matches exact command -- [ ] run full test suite - must pass before next task +- [x] change Bash category from `^\s` to `^(\s[safe-chars]*)?$` (uses CC-style safe character class `[^<>()$\x60|{}&;\n\r]` instead of `.*` to block compound operator injection) +- [x] this matches bare commands (`ls`), commands with args (`ls -la`), and is fully anchored +- [x] Read/Edit/Write proposals: intentionally left as prefix (`^/`), no change needed +- [x] WebFetch/WebSearch proposals: intentionally left as prefix (`^https?://`), no change needed +- [x] write tests: proposed rule for `ls` matches `ls` and `ls -la` but not `ls && evil` +- [x] write tests: proposed rule for `git status` matches bare invocation +- [x] write tests: proposed rule for bare command (no args) matches exact command +- [x] run full test suite - must pass before next task ### Task 6: Additional allowed directories @@ -259,40 +263,40 @@ Both authored and imported passthru.json files may declare `allowed_dirs`. `load - Modify: `tests/bootstrap.bats` (add import tests) - Modify: `tests/verifier.bats` (add allowed_dirs validation tests) -- [ ] add `load_allowed_dirs` function to `hooks/common.sh`: reads `allowed_dirs` from all four rule files, concatenates, deduplicates. Returns JSON array on stdout. Separate from `load_rules` to preserve `{version, allow, deny, ask}` contract -- [ ] add `_pm_path_inside_any_allowed ` helper: checks path against cwd first, then each allowed dir. Returns 0 if inside any -- [ ] update `permission_mode_auto_allows` to accept allowed dirs JSON as 5th parameter -- [ ] update pre-tool-use.sh: call `load_allowed_dirs` once, pass result to `permission_mode_auto_allows` and readonly path validation -- [ ] add bootstrap support: read `additionalAllowedWorkingDirs` from CC settings files (user + project), write to `allowed_dirs` in passthru.imported.json -- [ ] update `validate_rules` to tolerate and validate `allowed_dirs` key: must be array of non-empty strings, reject path traversal (`/../`) -- [ ] update docs/rule-format.md with `allowed_dirs` documentation -- [ ] update CONTRIBUTING.md with guidance on `allowed_dirs` usage -- [ ] write tests: Read tool auto-allowed for file in additional allowed dir -- [ ] write tests: Write tool auto-allowed in acceptEdits mode for file in additional allowed dir -- [ ] write tests: Grep tool auto-allowed for path in additional allowed dir -- [ ] write tests: file outside all allowed dirs falls through to overlay -- [ ] write tests: bootstrap imports additionalAllowedWorkingDirs from settings -- [ ] write tests: verify.sh validates allowed_dirs field (valid array, rejects traversal) -- [ ] write tests: verify.sh accepts passthru.json without allowed_dirs (backward compatible) -- [ ] write tests: readonly auto-allow uses allowed dirs for path validation -- [ ] run full test suite - must pass before next task +- [x] add `load_allowed_dirs` function to `hooks/common.sh`: reads `allowed_dirs` from all four rule files, concatenates, deduplicates. Returns JSON array on stdout. Separate from `load_rules` to preserve `{version, allow, deny, ask}` contract +- [x] add `_pm_path_inside_any_allowed ` helper: checks path against cwd first, then each allowed dir. Returns 0 if inside any +- [x] update `permission_mode_auto_allows` to accept allowed dirs JSON as 5th parameter +- [x] update pre-tool-use.sh: call `load_allowed_dirs` once, pass result to `permission_mode_auto_allows` and readonly path validation +- [x] add bootstrap support: read `additionalAllowedWorkingDirs` from CC settings files (user + project), write to `allowed_dirs` in passthru.imported.json +- [x] update `validate_rules` to tolerate and validate `allowed_dirs` key: must be array of non-empty strings, reject path traversal (`/../`) +- [x] update docs/rule-format.md with `allowed_dirs` documentation +- [x] update CONTRIBUTING.md with guidance on `allowed_dirs` usage +- [x] write tests: Read tool auto-allowed for file in additional allowed dir +- [x] write tests: Write tool auto-allowed in acceptEdits mode for file in additional allowed dir +- [x] write tests: Grep tool auto-allowed for path in additional allowed dir +- [x] write tests: file outside all allowed dirs falls through to overlay +- [x] write tests: bootstrap imports additionalAllowedWorkingDirs from settings +- [x] write tests: verify.sh validates allowed_dirs field (valid array, rejects traversal) +- [x] write tests: verify.sh accepts passthru.json without allowed_dirs (backward compatible) +- [x] write tests: readonly auto-allow uses allowed dirs for path validation +- [x] run full test suite - must pass before next task ### Task 7: Verify acceptance criteria -- [ ] run full test suite: `bats tests/*.bats` - all pass -- [ ] spot-check: compound command deny on any segment -- [ ] spot-check: compound command allow requires all segments -- [ ] spot-check: readonly `cat src/file` auto-allowed, `cat /etc/passwd` not -- [ ] spot-check: Agent and Skill get explicit allow -- [ ] spot-check: overlay proposals anchored with `$` -- [ ] spot-check: additional allowed dirs work for path-based tools and readonly Bash +- [x] run full test suite: `bats tests/*.bats` - all pass +- [x] spot-check: compound command deny on any segment +- [x] spot-check: compound command allow requires all segments +- [x] spot-check: readonly `cat src/file` auto-allowed, `cat /etc/passwd` not +- [x] spot-check: Agent and Skill get explicit allow +- [x] spot-check: overlay proposals anchored with `$` +- [x] spot-check: additional allowed dirs work for path-based tools and readonly Bash ### Task 8: [Final] Update documentation and release -- [ ] update CLAUDE.md with new patterns (readonly auto-allow, compound splitting, allowed dirs) -- [ ] update README.md with new features -- [ ] update CONTRIBUTING.md with guidance on extending readonly command list and compound splitter -- [ ] move this plan to `docs/plans/completed/` +- [x] update CLAUDE.md with new patterns (readonly auto-allow, compound splitting, allowed dirs) +- [x] update README.md with new features +- [x] update CONTRIBUTING.md with guidance on extending readonly command list and compound splitter +- [x] move this plan to `docs/plans/completed/` ## Post-Completion diff --git a/docs/rule-format.md b/docs/rule-format.md index 2e88fb5..2103155 100644 --- a/docs/rule-format.md +++ b/docs/rule-format.md @@ -53,6 +53,24 @@ Use `ask[]` when you want explicit prompts for a tool call rather than either si All of `allow`, `deny`, and `ask` default to empty arrays when missing. +### `allowed_dirs` (array, optional) + +Array of absolute directory paths that extend the set of trusted locations for path-based auto-allow checks. When present, Read/Edit/Write/Grep/Glob/LS tools operating on files inside any `allowed_dirs` entry are treated the same as files inside the working directory for mode-based auto-allow. Read-only Bash commands (`cat`, `head`, `ls`, etc.) also check `allowed_dirs` when validating absolute path arguments. + +```json +{ + "version": 2, + "allowed_dirs": ["/opt/shared-data", "/home/user/reference"], + "allow": [], + "deny": [], + "ask": [] +} +``` + +Both authored and imported rule files may declare `allowed_dirs`. During loading, arrays from all four rule files are concatenated and deduplicated. Bootstrap imports Claude Code's `additionalAllowedWorkingDirs` from `settings.json` and writes them to `allowed_dirs` in `passthru.imported.json`. + +Each entry must be a non-empty string. Paths containing `/../` (path traversal) are rejected by the verifier. Files without `allowed_dirs` are backward compatible (treated as an empty array). + ## Rule object fields Each entry in `allow[]`, `deny[]`, or `ask[]` is an object with these fields. At least one of `tool` or `match` is required. diff --git a/hooks/common.sh b/hooks/common.sh index b03d661..44caa60 100644 --- a/hooks/common.sh +++ b/hooks/common.sh @@ -8,6 +8,12 @@ # pcre_match - PCRE match via perl. 0=match, 1=no-match, 2=bad-regex. # match_rule - 0=match, 1=no-match, 2=bad-regex. # find_first_match - first matching rule on stdout. +# load_allowed_dirs - collect allowed_dirs arrays from rule files, emit JSON on stdout. +# split_bash_command - split a compound shell command into segments on stdout. +# is_readonly_command - 0 if segment is a recognized read-only command. +# readonly_paths_allowed - 0 if all paths in segment fall inside allowed dirs. +# match_all_segments - match every segment against rules, emit merged verdict on stdout. +# _pm_path_inside_any_allowed - 0 if path is inside any allowed directory. # # All output is plain ASCII. Errors go to stderr. Functions return non-zero on failure # without calling `exit` so callers can decide how to recover (hook handler fails open, @@ -340,7 +346,7 @@ overlay_available() { [ -n "$mux" ] } -# permission_mode_auto_allows +# permission_mode_auto_allows [allowed_dirs_json] # # Returns 0 if Claude Code itself would auto-allow this tool call in the given # permission mode, 1 otherwise. @@ -376,7 +382,10 @@ overlay_available() { # Unknown mode: 1 (fail-safe: run the overlay). # _pm_path_inside_cwd: return 0 when path is literally inside cwd and free of # ../ traversal. We do NOT canonicalize paths, so a symlink inside cwd -# pointing outside cwd still passes - documented as a known limitation. +# pointing outside cwd still passes. This matches CC's own pathValidation.ts +# which also uses literal prefix checks without resolving symlinks. +# KNOWN LIMITATION: resolving symlinks would require spawning readlink -f or +# realpath per path token, adding process overhead per tool call. # # Hoisted out of permission_mode_auto_allows so it is defined once per shell # rather than re-defined on every tool call. @@ -384,11 +393,18 @@ _pm_path_inside_cwd() { local p="$1" c="$2" [ -z "$p" ] && return 1 [ -z "$c" ] && return 1 + # Strip trailing slashes from the directory so "/opt/shared/" becomes + # "/opt/shared" and the glob below works correctly. + c="${c%/}" + [ -z "$c" ] && return 1 # Reject `..` traversal anywhere in the path (including the middle). case "$p" in *'/../'*|*'/..') return 1 ;; esac - # Literal prefix check: path must start with "$cwd/" (not just "$cwd"). + # Exact match (path IS the directory) or literal prefix (descendant). + if [ "$p" = "$c" ]; then + return 0 + fi case "$p" in "$c"/*) return 0 ;; esac @@ -396,7 +412,7 @@ _pm_path_inside_cwd() { } permission_mode_auto_allows() { - local mode="$1" tool_name="$2" tool_input="$3" cwd="$4" + local mode="$1" tool_name="$2" tool_input="$3" cwd="$4" allowed_dirs_json="${5:-[]}" # A bypassPermissions session auto-allows every tool call - mirror that. if [ "$mode" = "bypassPermissions" ]; then @@ -414,7 +430,7 @@ permission_mode_auto_allows() { Write|Edit|NotebookEdit|MultiEdit) local fp fp="$(jq -r '.file_path // ""' <<<"$tool_input" 2>/dev/null || printf '')" - if _pm_path_inside_cwd "$fp" "$cwd"; then + if _pm_path_inside_any_allowed "$fp" "$cwd" "$allowed_dirs_json"; then return 0 fi return 1 @@ -422,7 +438,7 @@ permission_mode_auto_allows() { Read|NotebookRead) local fp fp="$(jq -r '.file_path // .notebook_path // ""' <<<"$tool_input" 2>/dev/null || printf '')" - if _pm_path_inside_cwd "$fp" "$cwd"; then + if _pm_path_inside_any_allowed "$fp" "$cwd" "$allowed_dirs_json"; then return 0 fi return 1 @@ -433,7 +449,7 @@ permission_mode_auto_allows() { if [ -z "$gp" ]; then return 0 fi - if _pm_path_inside_cwd "$gp" "$cwd"; then + if _pm_path_inside_any_allowed "$gp" "$cwd" "$allowed_dirs_json"; then return 0 fi return 1 @@ -448,7 +464,7 @@ permission_mode_auto_allows() { Read|NotebookRead) local fp fp="$(jq -r '.file_path // .notebook_path // ""' <<<"$tool_input" 2>/dev/null || printf '')" - if _pm_path_inside_cwd "$fp" "$cwd"; then + if _pm_path_inside_any_allowed "$fp" "$cwd" "$allowed_dirs_json"; then return 0 fi return 1 @@ -460,7 +476,7 @@ permission_mode_auto_allows() { if [ -z "$gp" ]; then return 0 fi - if _pm_path_inside_cwd "$gp" "$cwd"; then + if _pm_path_inside_any_allowed "$gp" "$cwd" "$allowed_dirs_json"; then return 0 fi return 1 @@ -1030,6 +1046,37 @@ validate_rules() { fi fi + # Validate allowed_dirs (optional). Must be an array of non-empty strings. + # Reject path traversal (/../) in entries. + local ad_type + ad_type="$(jq -r '.allowed_dirs | type' <<<"$merged" 2>/dev/null)" + if [ "$ad_type" != "null" ]; then + if [ "$ad_type" != "array" ]; then + printf '[ERR] .allowed_dirs must be an array, got %s\n' "$ad_type" >&2 + return 2 + fi + local ad_report + ad_report="$(jq -r ' + (.allowed_dirs // []) | to_entries[] | + (if (.value | type) != "string" then + "allowed_dirs[\(.key)]: value must be a string" + elif (.value | length) == 0 then + "allowed_dirs[\(.key)]: value must be non-empty" + elif (.value | startswith("/") | not) then + "allowed_dirs[\(.key)]: must be an absolute path (start with /)" + elif (.value | test("/(\\.\\.)(/|$)")) then + "allowed_dirs[\(.key)]: path traversal (/../) not allowed" + else empty end) + ' <<<"$merged" 2>/dev/null)" + if [ -n "$ad_report" ]; then + while IFS= read -r line; do + [ -z "$line" ] && continue + printf '[ERR] schema: %s\n' "$line" >&2 + done <<<"$ad_report" + return 2 + fi + fi + # Per-rule schema checks for allow[], deny[], and (on v2) ask[]. # We pass the version as a jq arg so the same filter handles both schemas: # v2 walks ask[] too, v1 never does. @@ -1084,6 +1131,779 @@ validate_rules() { return 0 } +# --------------------------------------------------------------------------- +# load_allowed_dirs +# --------------------------------------------------------------------------- +# +# Reads the optional `allowed_dirs` key from all four rule files (user-authored, +# user-imported, project-authored, project-imported), concatenates them, and +# deduplicates. Returns a JSON array on stdout. +# +# Separate from `load_rules` to preserve the `{version, allow, deny, ask}` +# contract that validate_rules, build_ordered_allow_ask, and callers depend on. +# The IO cost is negligible (4 small JSON files, already in filesystem cache +# from load_rules). +# +# Missing files, empty files, and files without `allowed_dirs` contribute +# nothing. Malformed JSON is silently skipped (same fail-open as load_rules). +load_allowed_dirs() { + local files=( + "$(passthru_user_authored_path)" + "$(passthru_user_imported_path)" + "$(passthru_project_authored_path)" + "$(passthru_project_imported_path)" + ) + + local all_dirs="[]" + local f dir_arr + for f in "${files[@]}"; do + [ -f "$f" ] && [ -s "$f" ] || continue + dir_arr="$(jq -c '.allowed_dirs // []' "$f" 2>/dev/null || printf '[]')" + [ "$dir_arr" = "[]" ] && continue + all_dirs="$(jq -cn --argjson a "$all_dirs" --argjson b "$dir_arr" '$a + $b')" + done + + # Deduplicate (sorted by jq unique). + jq -c '[.[] | select(type == "string" and length > 0)] | unique' <<<"$all_dirs" 2>/dev/null || printf '[]\n' +} + +# --------------------------------------------------------------------------- +# Read-only Bash command auto-allow +# --------------------------------------------------------------------------- +# +# Mirrors Claude Code's readOnlyValidation.ts. Simple commands use the generic +# safety regex: ^(?:\s|$)[^<>()$`|{}&;\n\r]*$ +# Commands needing custom patterns have hand-written PCRE entries. +# +# is_readonly_command +# Returns 0 if the segment matches a readonly command PCRE, 1 otherwise. +# +# readonly_paths_allowed [allowed_dirs_json] +# After a segment passes is_readonly_command, extract non-flag tokens and +# verify all absolute paths are inside cwd or allowed dirs. Returns 0 if +# all paths are valid, 1 if any absolute path is outside. + +# Generic safety regex template. The command name is substituted in. +# Pattern: ^(?:\s|$)[^<>()$`|{}&;\n\r]*$ +# This rejects any shell metacharacters in the arguments. +_PASSTHRU_READONLY_SAFE_SUFFIX='(?:\s|$)[^<>()$`|{}&;\n\r]*$' + +# Simple commands that use the generic safety regex. +PASSTHRU_READONLY_COMMANDS=( + 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 that use the generic safety regex with full prefix. +PASSTHRU_READONLY_TWO_WORD_COMMANDS=( + "docker ps" + "docker images" +) + +# Custom regex commands. Each entry is a full PCRE to match against the segment. +PASSTHRU_READONLY_CUSTOM_REGEXES=( + # echo: safe subset. No $, backticks, or $() in arguments. + "^echo(\s|$)[^<>()\$\x60|{}&;\n\r]*$" + # pwd: bare or with safe-char args only. + "^pwd(\s[^<>()\$\x60|{}&;\n\r]*)?$" + # whoami: bare only. + "^whoami$" + # ls: no dangerous chars (same safe suffix). + "^ls(\s|$)[^<>()\$\x60|{}&;\n\r]*$" + # find: no -exec, -delete, -execdir, -fprint, -fprintf, -fls (all write to files). + "^find(\s|$)(?!.*(-exec\b|-execdir\b|-delete\b|-fprint\b|-fprintf\b|-fls\b))[^<>()\$\x60|{}&;\n\r]*$" + # cd: no expansion chars. + "^cd(\s|$)[^<>()\$\x60|{}&;\n\r]*$" + # jq: no -f/--from-file/--rawfile/--slurpfile (prevent file reads). + "^jq(\s|$)(?!.*(\s-f\s|\s--from-file\b|\s--rawfile\b|\s--slurpfile\b))[^<>()\$\x60|{}&;\n\r]*$" + # uniq: flags only or with stdin. + "^uniq(\s|$)[^<>()\$\x60|{}&;\n\r]*$" + # history: bare or with numeric arg. + "^history(\s+[0-9]+)?$" + # alias: bare or with name. + "^alias(\s|$)[^<>()\$\x60|{}&;\n\r]*$" + # arch: bare only. + "^arch$" + # node version checks. + "^node\s+(-v|--version)$" + # python version checks. + "^python\s+--version$" + "^python3\s+--version$" +) + +# is_readonly_command +# Returns 0 if the segment matches any readonly command pattern, 1 otherwise. +is_readonly_command() { + local segment="$1" + [ -z "$segment" ] && return 1 + + # Check simple commands with generic safety regex. + local cmd + for cmd in "${PASSTHRU_READONLY_COMMANDS[@]}"; do + if pcre_match "$segment" "^${cmd}${_PASSTHRU_READONLY_SAFE_SUFFIX}"; then + return 0 + fi + done + + # Check two-word commands with generic safety regex. + local two_word + for two_word in "${PASSTHRU_READONLY_TWO_WORD_COMMANDS[@]}"; do + if pcre_match "$segment" "^${two_word}${_PASSTHRU_READONLY_SAFE_SUFFIX}"; then + return 0 + fi + done + + # Check custom regex commands. + local pattern + for pattern in "${PASSTHRU_READONLY_CUSTOM_REGEXES[@]}"; do + if pcre_match "$segment" "$pattern"; then + return 0 + fi + done + + return 1 +} + +# readonly_paths_allowed [allowed_dirs_json] +# After a segment passes is_readonly_command, extract non-flag tokens and +# verify all absolute paths are inside cwd or allowed dirs. +# Returns 0 if all paths are valid, 1 if any absolute path is outside. +readonly_paths_allowed() { + local segment="$1" cwd="$2" allowed_dirs_json="${3:-[]}" + [ -z "$segment" ] && return 0 + [ -z "$cwd" ] && return 1 + + # Tokenize by whitespace. Simple split is sufficient here because the + # segment already passed the readonly regex which rejects shell metacharacters. + # Skip the command name (first token or first two for two-word commands) + # and flag tokens (starting with -). + local tokens=() + read -ra tokens <<< "$segment" + local token_count="${#tokens[@]}" + [ "$token_count" -le 1 ] && return 0 + + # Determine how many leading tokens to skip (command name). + local skip=1 + # Check if this is a two-word command. + if [ "$token_count" -ge 2 ]; then + local first_two="${tokens[0]} ${tokens[1]}" + local tw + for tw in "${PASSTHRU_READONLY_TWO_WORD_COMMANDS[@]}"; do + if [ "$first_two" = "$tw" ]; then + skip=2 + break + fi + done + fi + + local i token stripped + for ((i = skip; i < token_count; i++)); do + token="${tokens[$i]}" + # Skip flag tokens. + case "$token" in + -*) continue ;; + esac + # Strip surrounding quotes (single or double) from the token so that + # paths like "/etc/passwd" or '/etc/passwd' are recognized as absolute. + # Also strip a leading-only quote. read -ra splits on whitespace, so + # a quoted multi-word path like "../secret dir/file" tokenizes into + # "\"../secret" and "dir/file\"". Without stripping the orphaned leading + # quote, traversal patterns like ../ are hidden behind the quote char. + stripped="$token" + case "$stripped" in + \"*\") stripped="${stripped#\"}"; stripped="${stripped%\"}" ;; + \'*\') stripped="${stripped#\'}"; stripped="${stripped%\'}" ;; + \"*) stripped="${stripped#\"}" ;; + \'*) stripped="${stripped#\'}" ;; + esac + # Reject relative paths containing .. traversal. These can escape cwd + # without starting with / (e.g. cat ../../../etc/passwd, ls .., + # find .. -name secret). + case "$stripped" in + '..'|../*|*/../*|*/..) return 1 ;; + esac + # Reject tilde-prefixed paths. Bash expands ~ to $HOME before execution, + # so `cat ~/.ssh/id_rsa` reads from /Users/foo/.ssh/id_rsa even though + # the token does not start with /. Also covers ~user (home of another + # user), ~+ ($PWD), and ~- ($OLDPWD). Reject any token starting with ~. + case "$stripped" in + "~"*) return 1 ;; + esac + # Validate absolute paths (starting with /). + case "$stripped" in + /*) + if ! _pm_path_inside_any_allowed "$stripped" "$cwd" "$allowed_dirs_json"; then + return 1 + fi + ;; + esac + # Relative paths without traversal are assumed to resolve inside cwd. + done + + return 0 +} + +# _pm_path_inside_any_allowed +# Returns 0 if the path is inside cwd or any allowed dir, 1 otherwise. +_pm_path_inside_any_allowed() { + local p="$1" cwd="$2" allowed_dirs_json="${3:-[]}" + + # Check cwd first. + if _pm_path_inside_cwd "$p" "$cwd"; then + return 0 + fi + + # Check allowed dirs. + if [ -n "$allowed_dirs_json" ] && [ "$allowed_dirs_json" != "[]" ] && [ "$allowed_dirs_json" != "null" ]; then + local dir + while IFS= read -r dir; do + [ -z "$dir" ] && continue + if _pm_path_inside_cwd "$p" "$dir"; then + return 0 + fi + done < <(jq -r '.[]? // empty' <<< "$allowed_dirs_json" 2>/dev/null) + fi + + return 1 +} + +# --------------------------------------------------------------------------- +# has_redirect +# --------------------------------------------------------------------------- +# +# Usage: has_redirect +# Returns 0 if the command contains an unquoted redirection (> >> or <), +# 1 otherwise. Uses perl with the same quoting-aware parser as +# split_bash_command so quoted `>` or `<` inside strings are not false +# positives. +# +# Purpose: split_bash_command strips redirections before emitting segments. +# A command like `cat file > /tmp/out` becomes segment `cat file` which +# passes is_readonly_command. But CC executes the ORIGINAL command with +# the redirection, so a write actually happens. Similarly, input redirects +# (`wc < /etc/passwd`) collapse to `wc` after stripping, hiding the path +# from readonly_paths_allowed. This function detects any unquoted redirect +# in the raw command so the readonly auto-allow block can reject such +# commands. +has_redirect() { + local cmd="$1" + [ -z "$cmd" ] && return 1 + + perl -e ' +use strict; +use warnings; +my $input = $ARGV[0]; +my $len = length($input); +my $pos = 0; + +while ($pos < $len) { + my $ch = substr($input, $pos, 1); + + # Backslash escape. + if ($ch eq "\\") { $pos += 2; next; } + + # Single-quoted string: skip entirely. + if ($ch eq "'\''") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "'\''") { $pos++; } + $pos++ if $pos < $len; + next; + } + + # Double-quoted string: skip, respecting backslash escapes inside. + if ($ch eq "\"") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "\"") { + if (substr($input, $pos, 1) eq "\\") { $pos += 2; next; } + $pos++; + } + $pos++ if $pos < $len; + next; + } + + # $() subshell: skip nested content. + if ($ch eq "\$" && $pos + 1 < $len && substr($input, $pos + 1, 1) eq "(") { + $pos++; + my $depth = 0; + while ($pos < $len) { + my $sch = substr($input, $pos, 1); + if ($sch eq "\\") { $pos += 2; next; } + if ($sch eq "'\''") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "'\''") { $pos++; } + $pos++ if $pos < $len; + next; + } + if ($sch eq "\"") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "\"") { + if (substr($input, $pos, 1) eq "\\") { $pos += 2; next; } + $pos++; + } + $pos++ if $pos < $len; + next; + } + if ($sch eq "(") { $depth++; $pos++; next; } + if ($sch eq ")") { + if ($depth <= 1) { $pos++; last; } + $depth--; $pos++; next; + } + $pos++; + } + next; + } + + # Backtick subshell: skip. + if ($ch eq "`") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "`") { + if (substr($input, $pos, 1) eq "\\") { $pos += 2; next; } + $pos++; + } + $pos++ if $pos < $len; + next; + } + + # Unquoted > or >>. Skip >&N patterns (fd duplication, not file output) + # when the target is a bare digit (e.g. 2>&1). But >& followed by a + # non-digit IS an output redirect (e.g. >& file). + if ($ch eq ">") { + # Check for >& pattern. + my $next_pos = $pos + 1; + # Skip >> to get to the character after. + $next_pos++ if $next_pos < $len && substr($input, $next_pos, 1) eq ">"; + if ($next_pos < $len && substr($input, $next_pos, 1) eq "&") { + # >&N where N is a digit: fd duplication, not a file write. + if ($next_pos + 1 < $len && substr($input, $next_pos + 1, 1) =~ /^\d$/) { + $pos = $next_pos + 2; + next; + } + } + # This is an output redirect to a file. + exit 0; + } + + # Unquoted <. Skip < inside + # the heredoc payload will be misclassified as a redirect, causing the + # command to fall through to ask instead of auto-allow. This is + # safety-conservative (more restrictive, not less) and heredoc commands + # in Claude Code Bash tool calls are uncommon, so we accept the false + # positive rather than adding complex heredoc-body parsing. + if ($ch eq "<") { + if ($pos + 1 < $len && substr($input, $pos + 1, 1) eq "<") { + # << or <<<: heredoc / herestring, not an input file redirect. + # Skip past the second (and optional third) < so the main loop + # does not re-examine them. + $pos += 2; + $pos++ if $pos < $len && substr($input, $pos, 1) eq "<"; + next; + } + # This is an input redirect from a file. + exit 0; + } + + $pos++; +} + +# No unquoted redirect found. +exit 1; +' "$cmd" +} + +# --------------------------------------------------------------------------- +# split_bash_command +# --------------------------------------------------------------------------- +# +# Usage: split_bash_command +# Output: NUL-separated segments on stdout. Each segment is a subcommand with +# redirections stripped. Empty segments (from consecutive operators) are +# filtered out. +# +# The splitter uses perl (already a dependency for pcre_match) to tokenize the +# command respecting: +# - single quotes ('...') +# - double quotes ("...") +# - $() subshells (nested) +# - backtick subshells (`...`) +# - backslash escaping +# +# Splits on unquoted: | && || ; & +# Strips redirections: > >> < 2>&1 2>/dev/null N>&M N>file etc. +# +# Fail-safe: on parse error (unterminated quotes, etc.) returns the original +# command as a single segment. This preserves current behavior (full command +# matched as-is). +split_bash_command() { + local cmd="$1" + [ -z "$cmd" ] && return 0 + + perl -e ' +use strict; +use warnings; + +my $input = $ARGV[0]; +my $len = length($input); +my $pos = 0; +my @segments = (); +my $current = ""; +my $error = 0; + +# Tokenize character by character, tracking quoting context. +while ($pos < $len) { + my $ch = substr($input, $pos, 1); + + # Backslash escape (outside single quotes). + if ($ch eq "\\") { + if ($pos + 1 < $len) { + $current .= substr($input, $pos, 2); + $pos += 2; + next; + } else { + # Trailing backslash: keep it. + $current .= $ch; + $pos++; + next; + } + } + + # Single-quoted string: consume until closing single quote. + if ($ch eq "'\''") { + my $start = $pos; + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "'\''") { + $pos++; + } + if ($pos >= $len) { + # Unterminated single quote: fail-safe. + $error = 1; + last; + } + $current .= substr($input, $start, $pos - $start + 1); + $pos++; + next; + } + + # Double-quoted string: consume until closing double quote, respecting + # backslash escapes and $() / backtick nesting inside. + if ($ch eq "\"") { + my $start = $pos; + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "\"") { + my $dch = substr($input, $pos, 1); + if ($dch eq "\\") { + $pos += 2; # skip escaped char + next; + } + if ($dch eq "\$" && $pos + 1 < $len && substr($input, $pos + 1, 1) eq "(") { + # $() inside double quotes: find matching paren. + $pos++; # skip $ + my $depth = 0; + while ($pos < $len) { + my $sch = substr($input, $pos, 1); + if ($sch eq "\\") { $pos += 2; next; } + if ($sch eq "'\''") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "'\''") { $pos++; } + $pos++ if $pos < $len; + next; + } + if ($sch eq "(") { $depth++; $pos++; next; } + if ($sch eq ")") { + if ($depth <= 1) { $pos++; last; } + $depth--; + $pos++; + next; + } + $pos++; + } + next; + } + if ($dch eq "`") { + # backtick inside double quotes. + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "`") { + if (substr($input, $pos, 1) eq "\\") { $pos += 2; next; } + $pos++; + } + $pos++ if $pos < $len; + next; + } + $pos++; + } + if ($pos >= $len) { + $error = 1; + last; + } + $current .= substr($input, $start, $pos - $start + 1); + $pos++; + next; + } + + # $() subshell (outside quotes): track nested parens. + if ($ch eq "\$" && $pos + 1 < $len && substr($input, $pos + 1, 1) eq "(") { + my $start = $pos; + $pos++; # skip $ + my $depth = 0; + while ($pos < $len) { + my $sch = substr($input, $pos, 1); + if ($sch eq "\\") { $pos += 2; next; } + if ($sch eq "'\''") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "'\''") { $pos++; } + $pos++ if $pos < $len; + next; + } + if ($sch eq "\"") { + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "\"") { + if (substr($input, $pos, 1) eq "\\") { $pos += 2; next; } + $pos++; + } + $pos++ if $pos < $len; + next; + } + if ($sch eq "(") { $depth++; $pos++; next; } + if ($sch eq ")") { + if ($depth <= 1) { $pos++; last; } + $depth--; + $pos++; + next; + } + $pos++; + } + $current .= substr($input, $start, $pos - $start); + next; + } + + # Backtick subshell (outside quotes). + if ($ch eq "`") { + my $start = $pos; + $pos++; + while ($pos < $len && substr($input, $pos, 1) ne "`") { + if (substr($input, $pos, 1) eq "\\") { $pos += 2; next; } + $pos++; + } + if ($pos >= $len) { + $error = 1; + last; + } + $current .= substr($input, $start, $pos - $start + 1); + $pos++; + next; + } + + # Pipe operator: | or || + if ($ch eq "|") { + if ($pos + 1 < $len && substr($input, $pos + 1, 1) eq "|") { + # || operator + push @segments, $current; + $current = ""; + $pos += 2; + next; + } + # single pipe | + push @segments, $current; + $current = ""; + $pos++; + next; + } + + # && operator or bare & (background). But NOT part of N>&M redirection. + if ($ch eq "&") { + if ($pos + 1 < $len && substr($input, $pos + 1, 1) eq "&") { + push @segments, $current; + $current = ""; + $pos += 2; + next; + } + # Check if this & is part of a >&N redirection pattern (e.g. 2>&1). + # Look back: if the preceding non-space content ends with > or N>, + # this is a redirection target, not a command separator. + if ($current =~ /\d*>\s*$/) { + $current .= $ch; + $pos++; + next; + } + # bare & (background) + push @segments, $current; + $current = ""; + $pos++; + next; + } + + # ; operator + if ($ch eq ";") { + push @segments, $current; + $current = ""; + $pos++; + next; + } + + # Default: accumulate. + $current .= $ch; + $pos++; +} + +if ($error) { + # Fail-safe: return original command as single segment. + print $ARGV[0]; + print "\0"; + exit 0; +} + +# Push final segment. +push @segments, $current; + +# Strip redirections from each segment (quote-aware), trim whitespace, +# filter empty. The old approach used regex substitution which is not +# quote-aware and corrupts strings like: echo "hello > world". +# +# This replacement scans character-by-character, tracks quoting context +# (single-quote, double-quote, $()-subshell, backtick), and only strips +# redirect operators + their targets when outside any quoting context. +for my $seg (@segments) { + my $slen = length($seg); + my $si = 0; + my $out = ""; + + while ($si < $slen) { + my $sc = substr($seg, $si, 1); + + # --- quoting contexts: pass through verbatim --- + if ($sc eq "'\''") { + my $qs = $si; + $si++; + while ($si < $slen && substr($seg, $si, 1) ne "'\''") { $si++; } + $si++ if $si < $slen; # closing quote + $out .= substr($seg, $qs, $si - $qs); + next; + } + if ($sc eq "\"") { + my $qs = $si; + $si++; + while ($si < $slen && substr($seg, $si, 1) ne "\"") { + if (substr($seg, $si, 1) eq "\\") { $si += 2; next; } + $si++; + } + $si++ if $si < $slen; # closing quote + $out .= substr($seg, $qs, $si - $qs); + next; + } + if ($sc eq "\$" && $si + 1 < $slen && substr($seg, $si + 1, 1) eq "(") { + my $qs = $si; + $si++; # skip $ + my $sd = 0; + while ($si < $slen) { + my $ssc = substr($seg, $si, 1); + if ($ssc eq "(") { $sd++; $si++; next; } + if ($ssc eq ")") { $sd--; $si++; last if $sd <= 0; next; } + if ($ssc eq "\\") { $si += 2; next; } + $si++; + } + $out .= substr($seg, $qs, $si - $qs); + next; + } + if ($sc eq "`") { + my $qs = $si; + $si++; + while ($si < $slen && substr($seg, $si, 1) ne "`") { + if (substr($seg, $si, 1) eq "\\") { $si += 2; next; } + $si++; + } + $si++ if $si < $slen; + $out .= substr($seg, $qs, $si - $qs); + next; + } + + # --- outside quotes: detect redirect operators --- + # Collect optional leading digits (fd number). + my $rstart = $si; + my $dpos = $si; + while ($dpos < $slen && substr($seg, $dpos, 1) =~ /\d/) { $dpos++; } + + if ($dpos < $slen && (substr($seg, $dpos, 1) eq ">" || substr($seg, $dpos, 1) eq "<")) { + my $op = substr($seg, $dpos, 1); + my $ri = $dpos + 1; + + if ($op eq ">") { + # >>file or >&N or >file + if ($ri < $slen && substr($seg, $ri, 1) eq ">") { + $ri++; # >> + } elsif ($ri < $slen && substr($seg, $ri, 1) eq "&") { + # >&N (fd dup) + $ri++; + while ($ri < $slen && substr($seg, $ri, 1) =~ /\d/) { $ri++; } + $si = $ri; + next; + } + } + # at EOL). + if ($ri > $target_start) { + $si = $ri; + next; + } + } + + # Not a redirect, accumulate the character. + $out .= $sc; + $si++; + } + + # Trim leading/trailing whitespace. + $out =~ s/^\s+//; + $out =~ s/\s+$//; + + # Skip empty segments. + next if $out eq ""; + + print $out; + print "\0"; +} +' "$cmd" +} + # --------------------------------------------------------------------------- # pcre_match # --------------------------------------------------------------------------- @@ -1253,3 +2073,116 @@ find_first_match() { return 0 } + +# --------------------------------------------------------------------------- +# match_all_segments +# --------------------------------------------------------------------------- +# +# Usage: match_all_segments +# +# Implements per-segment first-match algorithm for compound Bash commands. +# Each segment is walked independently against the ordered allow/ask entries +# (same document-order list that build_ordered_allow_ask produces). For each +# segment, the first matching entry's list type ("allow" or "ask") is recorded. +# +# Decision logic: +# - If ANY segment's first match is "ask", the whole command is "ask". +# Outputs the ask-matched entry on stdout. +# - If ALL segments' first matches are "allow", the whole command is "allow". +# Outputs the first segment's allow-matched entry on stdout. +# - If ANY segment has NO match at all, fall through (no output on stdout). +# +# Output (stdout): one line of TAB-separated fields when a decision is reached: +# \t\t\t +# where decision is "allow" or "ask". +# Empty stdout means at least one segment had no match (fall through). +# +# Return: +# 0 on clean traversal (check stdout for emptiness) +# 2 on regex compile failure in any rule (fail-open) +# +# Segments are passed as positional arguments after the first two (ordered JSON +# and tool_name). This avoids bash 4.3+ namerefs (local -n) for compatibility +# with bash 3.2 on stock macOS. +# The ordered_entries_json is the same JSON array of {list, merged_idx, rule} +# objects that build_ordered_allow_ask emits. +match_all_segments() { + local ordered="$1" + local tool_name="$2" + shift 2 + + local _mas_segments=("$@") + local seg_count="${#_mas_segments[@]}" + if [ "$seg_count" -eq 0 ]; then + return 0 + fi + + local ordered_count + ordered_count="$(jq -r 'if type == "array" then length else 0 end' <<<"$ordered" 2>/dev/null)" + [ -z "$ordered_count" ] && ordered_count=0 + + if [ "$ordered_count" -eq 0 ]; then + # No rules at all: no match for any segment. + return 0 + fi + + # For each segment, find the first matching entry in the ordered list. + # Track the overall result: "allow" if all segments allow, "ask" if any ask, + # "" if any segment has no match. + local overall="allow" + local first_allow_entry="" # entry from first segment's allow match + local ask_entry="" # entry from any segment's ask match + local seg_idx seg seg_input entry list_type rule mrc + + for ((seg_idx = 0; seg_idx < seg_count; seg_idx++)); do + seg="${_mas_segments[$seg_idx]}" + # Build a synthetic tool_input with the segment as the command. + seg_input="$(jq -cn --arg c "$seg" '{command: $c}')" + + local found=0 + local i + for ((i = 0; i < ordered_count; i++)); do + entry="$(jq -c --argjson i "$i" '.[$i]' <<<"$ordered" 2>/dev/null)" + list_type="$(jq -r '.list // ""' <<<"$entry" 2>/dev/null)" + rule="$(jq -c '.rule // {}' <<<"$entry" 2>/dev/null)" + + mrc=0 + match_rule "$tool_name" "$seg_input" "$rule" || mrc=$? + if [ "$mrc" -eq 2 ]; then + return 2 + fi + if [ "$mrc" -eq 0 ]; then + found=1 + if [ "$list_type" = "ask" ]; then + overall="ask" + ask_entry="$entry" + elif [ "$seg_idx" -eq 0 ] && [ -z "$first_allow_entry" ]; then + first_allow_entry="$entry" + fi + break + fi + done + + if [ "$found" -eq 0 ]; then + # This segment has no match at all. Fall through. + return 0 + fi + done + + # Emit the decision. + if [ "$overall" = "ask" ]; then + local a_list a_idx a_rule + a_list="$(jq -r '.list // ""' <<<"$ask_entry" 2>/dev/null)" + a_idx="$(jq -r '.merged_idx // 0' <<<"$ask_entry" 2>/dev/null)" + a_rule="$(jq -c '.rule // {}' <<<"$ask_entry" 2>/dev/null)" + printf '%s\t%s\t%s\t%s\n' "ask" "$a_list" "$a_idx" "$a_rule" + elif [ "$overall" = "allow" ] && [ -n "$first_allow_entry" ]; then + local f_list f_idx f_rule + f_list="$(jq -r '.list // ""' <<<"$first_allow_entry" 2>/dev/null)" + f_idx="$(jq -r '.merged_idx // 0' <<<"$first_allow_entry" 2>/dev/null)" + f_rule="$(jq -c '.rule // {}' <<<"$first_allow_entry" 2>/dev/null)" + printf '%s\t%s\t%s\t%s\n' "allow" "$f_list" "$f_idx" "$f_rule" + fi + + return 0 +} diff --git a/hooks/handlers/pre-tool-use.sh b/hooks/handlers/pre-tool-use.sh index 49dde37..314b61a 100755 --- a/hooks/handlers/pre-tool-use.sh +++ b/hooks/handlers/pre-tool-use.sh @@ -338,6 +338,20 @@ if [ "$TOOL_NAME" = "Bash" ]; then fi fi +# --- 3b. Internal tool explicit allow -------------------------------------- +# Agent, Skill, and Glob are Claude Code internals that passthru always allows. +# They emit a real "allow" decision (not passthrough) so CC never shows its own +# confirmation dialog. This step runs before rule loading so it is fast and +# cannot be affected by broken rule files. ToolSearch and other CC-internal +# tools stay in the step 7 passthrough list (they use {"continue": true}). +case "$TOOL_NAME" in + Agent|Skill|Glob|WebSearch) + emit_decision "allow" "passthru internal: ${TOOL_NAME}" + audit_write_line "allow" "$TOOL_NAME" "passthru internal: ${TOOL_NAME}" "" "" "$TOOL_USE_ID" "passthru-internal" + exit 0 + ;; +esac + # --- 4. Load + validate rules ---------------------------------------------- # load_rules or validate_rules failure -> fail open with stderr diagnostic. # Capture stdout directly; load_rules sends parse errors to stderr (which we @@ -362,20 +376,34 @@ if ! validate_rules "$MERGED" 2>/dev/null; then exit 0 fi +# --- 4a. Load allowed dirs ------------------------------------------------ +# Load additional allowed directories from rule files. This is separate from +# load_rules to preserve the {version, allow, deny, ask} contract. The result +# is passed to permission_mode_auto_allows and readonly path validation. +ALLOWED_DIRS_JSON="$(load_allowed_dirs 2>/dev/null || printf '[]')" + +# --- 4b. Split Bash compound commands into segments ----------------------- +# For Bash tool calls, split the command into segments (subcommands) so each +# segment is matched independently. Non-Bash tools skip this step and use +# single-segment matching (current behavior preserved). +BASH_SEGMENTS=() +BASH_SEGMENT_COUNT=0 +if [ "$TOOL_NAME" = "Bash" ]; then + BASH_CMD="$(jq -r '.command // ""' <<<"$TOOL_INPUT" 2>/dev/null)" + if [ -n "$BASH_CMD" ]; then + while IFS= read -r -d '' _seg; do + BASH_SEGMENTS+=("$_seg") + done < <(split_bash_command "$BASH_CMD") + BASH_SEGMENT_COUNT="${#BASH_SEGMENTS[@]}" + fi +fi + # --- 5. Match deny first --------------------------------------------------- +# For Bash compound commands, check EACH segment against deny rules. +# ANY segment matching deny -> deny the whole command. DENY_RULES="$(jq -c '.deny // []' <<<"$MERGED" 2>/dev/null)" [ -z "$DENY_RULES" ] && DENY_RULES='[]' -DENY_HIT="" -if DENY_HIT="$(find_first_match "$DENY_RULES" "$TOOL_NAME" "$TOOL_INPUT" 2>/dev/null)"; then - : # normal path -else - # rc=2 (bad regex). Fail open. - printf '[passthru] deny rule regex error; passing through\n' >&2 - emit_passthrough - exit 0 -fi - # rule_pattern_summary: emit a human-readable summary of the rule's pattern # field for log output. Format: # - .tool only -> "" @@ -394,6 +422,36 @@ rule_pattern_summary() { ' <<<"$rule" 2>/dev/null } +DENY_HIT="" +if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 1 ]; then + # Compound command: check each segment independently against deny rules. + for _seg in "${BASH_SEGMENTS[@]}"; do + _seg_input="$(jq -cn --arg c "$_seg" '{command: $c}')" + _seg_deny="" + if _seg_deny="$(find_first_match "$DENY_RULES" "$TOOL_NAME" "$_seg_input" 2>/dev/null)"; then + : + else + printf '[passthru] deny rule regex error; passing through\n' >&2 + emit_passthrough + exit 0 + fi + if [ -n "$_seg_deny" ]; then + DENY_HIT="$_seg_deny" + break + fi + done +else + # Single command or non-Bash tool: original behavior. + if DENY_HIT="$(find_first_match "$DENY_RULES" "$TOOL_NAME" "$TOOL_INPUT" 2>/dev/null)"; then + : # normal path + else + # rc=2 (bad regex). Fail open. + printf '[passthru] deny rule regex error; passing through\n' >&2 + emit_passthrough + exit 0 + fi +fi + if [ -n "$DENY_HIT" ]; then # find_first_match returns "\t" so we split here. DENY_IDX="${DENY_HIT%%$'\t'*}" @@ -410,6 +468,40 @@ if [ -n "$DENY_HIT" ]; then exit 0 fi +# --- 5b. Read-only Bash command auto-allow --------------------------------- +# After deny (deny always wins), check if ALL segments are read-only commands +# with valid paths. If so, emit an explicit allow. This runs before allow/ask +# matching so read-only commands do not need explicit allow rules. +# +# Guard: if the ORIGINAL command (pre-split) contains unquoted redirects +# (> >> or <), skip readonly auto-allow entirely. split_bash_command strips +# redirections from segments, so `cat file > /tmp/out` becomes `cat file` +# and `wc < /etc/passwd` becomes `wc`. Both would pass is_readonly_command +# and readonly_paths_allowed. But CC executes the original command with the +# redirect, so the actual I/O differs from what the segment shows. +if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 0 ] \ + && ! has_redirect "$BASH_CMD"; then + _all_readonly=1 + for _seg in "${BASH_SEGMENTS[@]}"; do + if ! is_readonly_command "$_seg"; then + _all_readonly=0 + break + fi + if ! readonly_paths_allowed "$_seg" "$CC_CWD" "$ALLOWED_DIRS_JSON"; then + _all_readonly=0 + break + fi + done + if [ "$_all_readonly" -eq 1 ]; then + # Extract the first word of the first segment for the reason message. + _ro_first="${BASH_SEGMENTS[0]%% *}" + MSG="passthru readonly: ${_ro_first}" + emit_decision "allow" "$MSG" + audit_write_line "allow" "$TOOL_NAME" "readonly:${_ro_first}" "" "" "$TOOL_USE_ID" "passthru-readonly" + exit 0 + fi +fi + # --- 6. Match allow + ask in document order -------------------------------- # build_ordered_allow_ask returns a JSON array of {list, merged_idx, rule} # entries that walk each source file's allow[] and ask[] in the order they @@ -425,6 +517,11 @@ fi # 4. no match + mode auto-allows -> passthrough (CC handles it). # 5. no match + mode does NOT auto-allow -> overlay path. # +# For compound Bash commands (multi-segment): use match_all_segments which +# checks each segment independently. ALL segments must match for allow; +# ANY segment matching ask triggers ask; ANY segment without a match falls +# through. +# # We set OVERLAY_REASON when we decide overlay is the next step. Empty => # no overlay needed. Carries the audit reason / pattern / rule_index through # to the overlay-result dispatch. @@ -440,54 +537,98 @@ ORDERED_COUNT="$(jq -r 'if type == "array" then length else 0 end' <<<"$ORDERED" MATCHED="" # "allow" | "ask" | "" if [ "$ORDERED_COUNT" -gt 0 ]; then - i=0 - while [ "$i" -lt "$ORDERED_COUNT" ]; do - ENTRY="$(jq -c --argjson i "$i" '.[$i]' <<<"$ORDERED" 2>/dev/null)" - LIST_TYPE="$(jq -r '.list // ""' <<<"$ENTRY" 2>/dev/null)" - RULE="$(jq -c '.rule // {}' <<<"$ENTRY" 2>/dev/null)" - RULE_IDX="$(jq -r '.merged_idx // 0' <<<"$ENTRY" 2>/dev/null)" - - # Catch match_rule's return code without letting `set -e` abort the loop - # on the expected "no match" (rc=1) path. - mrc=0 - match_rule "$TOOL_NAME" "$TOOL_INPUT" "$RULE" || mrc=$? - if [ "$mrc" -eq 2 ]; then - # Invalid regex in allow/ask rule. Fail open so a single bad rule - # cannot block every tool call. - printf '[passthru] %s rule regex error; passing through\n' "$LIST_TYPE" >&2 + if [ "$TOOL_NAME" = "Bash" ] && [ "$BASH_SEGMENT_COUNT" -gt 1 ]; then + # Compound Bash command: use per-segment matching algorithm. + _MAS_RESULT="" + _mas_rc=0 + _MAS_RESULT="$(match_all_segments "$ORDERED" "$TOOL_NAME" "${BASH_SEGMENTS[@]}" 2>/dev/null)" || _mas_rc=$? + if [ "$_mas_rc" -eq 2 ]; then + printf '[passthru] compound allow/ask rule regex error; passing through\n' >&2 emit_passthrough exit 0 fi - if [ "$mrc" -eq 0 ]; then - MATCHED="$LIST_TYPE" - REASON="$(jq -r '.reason // ""' <<<"$RULE" 2>/dev/null)" - PATTERN="$(rule_pattern_summary "$RULE")" + if [ -n "$_MAS_RESULT" ]; then + # Parse TAB-separated: decision\tlist_type\tmerged_idx\trule_json + _MAS_DECISION="${_MAS_RESULT%%$'\t'*}" + _MAS_REST="${_MAS_RESULT#*$'\t'}" + _MAS_REST2="${_MAS_REST#*$'\t'}" + _MAS_RULE_IDX="${_MAS_REST2%%$'\t'*}" + _MAS_RULE="${_MAS_REST2#*$'\t'}" + + REASON="$(jq -r '.reason // ""' <<<"$_MAS_RULE" 2>/dev/null)" + PATTERN="$(rule_pattern_summary "$_MAS_RULE")" OVERLAY_REASON="$REASON" - OVERLAY_RULE_IDX="$RULE_IDX" + OVERLAY_RULE_IDX="$_MAS_RULE_IDX" OVERLAY_PATTERN="$PATTERN" - if [ "$LIST_TYPE" = "allow" ]; then + + if [ "$_MAS_DECISION" = "allow" ]; then + MATCHED="allow" if [ -n "$REASON" ]; then MSG="passthru allow: ${REASON}" else MSG="passthru allow: matched rule [${PATTERN}]" fi emit_decision "allow" "$MSG" - audit_write_line "allow" "$TOOL_NAME" "$REASON" "$RULE_IDX" "$PATTERN" "$TOOL_USE_ID" + audit_write_line "allow" "$TOOL_NAME" "$REASON" "$_MAS_RULE_IDX" "$PATTERN" "$TOOL_USE_ID" exit 0 + elif [ "$_MAS_DECISION" = "ask" ]; then + MATCHED="ask" + # Falls through to the overlay path below. fi - # ask match: break out of the loop and head to the overlay path. - break fi - i=$((i + 1)) - done + # No match or empty result: MATCHED stays "" -> falls through to overlay. + else + # Single command or non-Bash tool: existing document-order walk. + i=0 + while [ "$i" -lt "$ORDERED_COUNT" ]; do + ENTRY="$(jq -c --argjson i "$i" '.[$i]' <<<"$ORDERED" 2>/dev/null)" + LIST_TYPE="$(jq -r '.list // ""' <<<"$ENTRY" 2>/dev/null)" + RULE="$(jq -c '.rule // {}' <<<"$ENTRY" 2>/dev/null)" + RULE_IDX="$(jq -r '.merged_idx // 0' <<<"$ENTRY" 2>/dev/null)" + + # Catch match_rule's return code without letting `set -e` abort the loop + # on the expected "no match" (rc=1) path. + mrc=0 + match_rule "$TOOL_NAME" "$TOOL_INPUT" "$RULE" || mrc=$? + if [ "$mrc" -eq 2 ]; then + # Invalid regex in allow/ask rule. Fail open so a single bad rule + # cannot block every tool call. + printf '[passthru] %s rule regex error; passing through\n' "$LIST_TYPE" >&2 + emit_passthrough + exit 0 + fi + if [ "$mrc" -eq 0 ]; then + MATCHED="$LIST_TYPE" + REASON="$(jq -r '.reason // ""' <<<"$RULE" 2>/dev/null)" + PATTERN="$(rule_pattern_summary "$RULE")" + OVERLAY_REASON="$REASON" + OVERLAY_RULE_IDX="$RULE_IDX" + OVERLAY_PATTERN="$PATTERN" + if [ "$LIST_TYPE" = "allow" ]; then + if [ -n "$REASON" ]; then + MSG="passthru allow: ${REASON}" + else + MSG="passthru allow: matched rule [${PATTERN}]" + fi + emit_decision "allow" "$MSG" + audit_write_line "allow" "$TOOL_NAME" "$REASON" "$RULE_IDX" "$PATTERN" "$TOOL_USE_ID" + exit 0 + fi + # ask match: break out of the loop and head to the overlay path. + break + fi + i=$((i + 1)) + done + fi fi # --- 7. Internal tool pass-through ----------------------------------------- # Some tools are Claude Code internals (schema loading, task management, etc.) # that should never trigger the overlay. Pass them through unconditionally. +# Agent, Skill, and Glob are handled earlier (step 3b) with explicit allow. if [ "$MATCHED" != "ask" ]; then case "$TOOL_NAME" in - ToolSearch|Skill|TaskCreate|TaskUpdate|TaskGet|TaskList|TaskOutput|TaskStop|\ + ToolSearch|TaskCreate|TaskUpdate|TaskGet|TaskList|TaskOutput|TaskStop|\ AskUserQuestion|SendMessage|EnterPlanMode|ExitPlanMode|ScheduleWakeup|\ CronCreate|CronDelete|CronList|Monitor|LSP|RemoteTrigger|\ EnterWorktree|ExitWorktree|TeamCreate|TeamDelete) @@ -504,7 +645,7 @@ fi # does not fire for routine operations. Passthru emits allow (not continue), # keeping the decision on our side rather than falling through to CC. if [ "$MATCHED" != "ask" ]; then - if permission_mode_auto_allows "$PERMISSION_MODE" "$TOOL_NAME" "$TOOL_INPUT" "$CC_CWD" 2>/dev/null; then + if permission_mode_auto_allows "$PERMISSION_MODE" "$TOOL_NAME" "$TOOL_INPUT" "$CC_CWD" "$ALLOWED_DIRS_JSON" 2>/dev/null; then MSG="passthru mode-allow: ${PERMISSION_MODE:-default}" emit_decision "allow" "$MSG" audit_write_line "allow" "$TOOL_NAME" "mode:${PERMISSION_MODE:-default}" "" "" "$TOOL_USE_ID" "passthru-mode" @@ -610,6 +751,44 @@ export PASSTHRU_OVERLAY_TOOL_NAME="$TOOL_NAME" export PASSTHRU_OVERLAY_TOOL_INPUT_JSON="$TOOL_INPUT" export PASSTHRU_OVERLAY_CWD="$CC_CWD" +# --- Overlay queue lock ------------------------------------------------------- +# CC can fire multiple PreToolUse hooks concurrently (parallel tool calls). +# Only one overlay popup can be visible at a time in a given multiplexer. +# Without serialization, the second+ hook falls through to CC's native dialog. +# We use a mkdir-based lock to queue concurrent overlay invocations. +_OVERLAY_LOCK="${_tmpdir}/passthru-overlay.lock.d" +_OVERLAY_LOCK_TIMEOUT="${PASSTHRU_OVERLAY_LOCK_TIMEOUT:-90}" +_overlay_lock_acquired=0 + +_release_overlay_lock() { + if [ "$_overlay_lock_acquired" -eq 1 ]; then + rm -rf "$_OVERLAY_LOCK" 2>/dev/null || true + _overlay_lock_acquired=0 + fi +} + +# Acquire the lock. Poll at 200ms intervals up to the timeout. +_lock_start="$(date +%s)" +while true; do + if mkdir "$_OVERLAY_LOCK" 2>/dev/null; then + _overlay_lock_acquired=1 + # Ensure lock is released even on unexpected exits (ERR trap, signals). + trap '_release_overlay_lock; printf "[passthru] unexpected error in pre-tool-use.sh\n" >&2; emit_passthrough; exit 0' ERR + trap '_release_overlay_lock' EXIT + break + fi + _now="$(date +%s)" + if [ $((_now - _lock_start)) -ge "$_OVERLAY_LOCK_TIMEOUT" ]; then + printf '[passthru] overlay lock timeout after %ds; falling back to native dialog\n' "$_OVERLAY_LOCK_TIMEOUT" >&2 + emit_ask_fallback "overlay lock timeout" + fi + sleep 0.2 +done + +# Send a desktop notification so the user knows a permission prompt is waiting. +# OSC 777 is supported by Ghostty, iTerm2, and other modern terminals. +printf '\033]777;notify;passthru;permission prompt: %s\a' "$TOOL_NAME" 2>/dev/null || true + # Invoke the overlay and capture its exit code. We have an ERR trap in place # (converts unexpected errors to fail-open passthrough), so we cannot rely on # `set +e` alone: the trap fires on any non-zero exit regardless. Disable the @@ -619,7 +798,10 @@ set +e bash "$OVERLAY_SH" OVERLAY_RC=$? set -e -trap 'printf "[passthru] unexpected error in pre-tool-use.sh\n" >&2; emit_passthrough; exit 0' ERR +trap '_release_overlay_lock; printf "[passthru] unexpected error in pre-tool-use.sh\n" >&2; emit_passthrough; exit 0' ERR + +# Release the lock so the next queued overlay can proceed. +_release_overlay_lock if [ "$OVERLAY_RC" -ne 0 ]; then # Launch failure (rc 1 = no multiplexer detected at launch time, rc 2 = diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh index 282ae4e..c8159da 100755 --- a/scripts/bootstrap.sh +++ b/scripts/bootstrap.sh @@ -458,15 +458,34 @@ dedup_rules() { ' } +# --------------------------------------------------------------------------- +# Extract additionalAllowedWorkingDirs from a settings file. +# Returns a JSON array of directory strings, or "[]" if absent/empty. +# --------------------------------------------------------------------------- +extract_allowed_dirs() { + local settings="$1" + [ -f "$settings" ] || { printf '[]\n'; return 0; } + jq -c '(.env.additionalAllowedWorkingDirs // []) | map(select(type == "string" and length > 0))' \ + "$settings" 2>/dev/null || printf '[]\n' +} + # --------------------------------------------------------------------------- # Produce the imported JSON document for a scope. # Arg $1: JSON array of rule objects. -# Stdout: full `{version:1, allow:[...], deny:[]}` document. +# Arg $2 (optional): JSON array of allowed_dirs. +# Stdout: full `{version:1, allow:[...], deny:[]}` document, optionally with +# `allowed_dirs` if the array is non-empty. # --------------------------------------------------------------------------- wrap_document() { local rules="$1" - jq -cn --argjson allow "$rules" \ - '{version:1, allow:$allow, deny:[]}' + local allowed_dirs="${2:-[]}" + if [ "$allowed_dirs" = "[]" ] || [ -z "$allowed_dirs" ]; then + jq -cn --argjson allow "$rules" \ + '{version:1, allow:$allow, deny:[]}' + else + jq -cn --argjson allow "$rules" --argjson ad "$allowed_dirs" \ + '{version:1, allow:$allow, deny:[], allowed_dirs:$ad}' + fi } # --------------------------------------------------------------------------- @@ -475,10 +494,13 @@ wrap_document() { USER_RULES="[]" PROJECT_RULES="[]" +USER_ALLOWED_DIRS="[]" +PROJECT_ALLOWED_DIRS="[]" if [ "$SCOPE" = "all" ] || [ "$SCOPE" = "user" ]; then user_converted="$(convert_settings_file "$USER_SETTINGS")" USER_RULES="$user_converted" + USER_ALLOWED_DIRS="$(extract_allowed_dirs "$USER_SETTINGS")" fi if [ "$SCOPE" = "all" ] || [ "$SCOPE" = "project" ]; then @@ -491,13 +513,20 @@ if [ "$SCOPE" = "all" ] || [ "$SCOPE" = "project" ]; then --argjson a "$proj_shared" \ --argjson b "$proj_local" \ '$a + $b')" + # Merge allowed dirs from both project settings files and deduplicate. + proj_shared_dirs="$(extract_allowed_dirs "$PROJECT_SETTINGS_SHARED")" + proj_local_dirs="$(extract_allowed_dirs "$PROJECT_SETTINGS_LOCAL")" + PROJECT_ALLOWED_DIRS="$(jq -cn \ + --argjson a "$proj_shared_dirs" \ + --argjson b "$proj_local_dirs" \ + '$a + $b | unique')" fi USER_RULES="$(printf '%s' "$USER_RULES" | dedup_rules)" PROJECT_RULES="$(printf '%s' "$PROJECT_RULES" | dedup_rules)" -USER_DOC="$(wrap_document "$USER_RULES")" -PROJECT_DOC="$(wrap_document "$PROJECT_RULES")" +USER_DOC="$(wrap_document "$USER_RULES" "$USER_ALLOWED_DIRS")" +PROJECT_DOC="$(wrap_document "$PROJECT_RULES" "$PROJECT_ALLOWED_DIRS")" # --------------------------------------------------------------------------- # Dry-run: pretty-print the proposed output to stdout. diff --git a/scripts/overlay-propose-rule.sh b/scripts/overlay-propose-rule.sh index b26d054..d468ddf 100755 --- a/scripts/overlay-propose-rule.sh +++ b/scripts/overlay-propose-rule.sh @@ -12,7 +12,7 @@ # # Categories: # 1. Bash(command=...) -> tool: "Bash", match.command: -# "^\\s" +# "^(\\s[safe]*)?\$" # 2. Read/Edit/Write(file_path=...) -> tool: "^(Read|Edit|Write)$", # match.file_path: "^" # 3. WebFetch/WebSearch(url=...) -> tool: "^(WebFetch|WebSearch)$", @@ -93,7 +93,11 @@ if [ "$TOOL_NAME" = "Bash" ]; then emit_fallback exit 0 fi - pattern="^$(escape_regex "$first_word")\\s" + # Fully-anchored pattern: ^cmd(\s[safe-chars])?$ where safe-chars + # mirrors CC's makeRegexForSafeCommand character class (no shell + # operators, no expansion triggers). This prevents compound command + # injection (e.g. "ls && evil") from matching a rule meant for "ls". + pattern="^$(escape_regex "$first_word")(\\s[^<>()\\$\x60|{}&;\\n\\r]*)?\$" jq -cn --arg tool "Bash" --arg pat "$pattern" \ '{tool: $tool, match: {command: $pat}}' exit 0 diff --git a/scripts/verify.sh b/scripts/verify.sh index 075cb4b..9c57af6 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -305,6 +305,45 @@ for ((fi = 0; fi < ${#PARSED_FILES[@]}; fi++)); do diag error "$file" ".version" "" "schema: unsupported version $ver (expected 1 or 2)" fi + # allowed_dirs[] validation (optional key, any schema version). + # Read from the raw file since normalization drops allowed_dirs. + # Skip if the file is empty (already normalized to synthetic {}). + raw_ad_type="null" + [ -s "$file" ] && raw_ad_type="$(jq -r '.allowed_dirs | type' "$file" 2>/dev/null)" + if [ "$raw_ad_type" != "null" ]; then + if [ "$raw_ad_type" != "array" ]; then + diag error "$file" ".allowed_dirs" "" "schema: allowed_dirs must be an array (got $raw_ad_type)" + else + n_ad="$(jq -r '.allowed_dirs | length' "$file" 2>/dev/null)" + for ((ai = 0; ai < n_ad; ai++)); do + ad_val_type="$(jq -r ".allowed_dirs[$ai] | type" "$file" 2>/dev/null)" + if [ "$ad_val_type" != "string" ]; then + diag error "$file" ".allowed_dirs[$ai]" "" "schema: allowed_dirs entry must be a string (got $ad_val_type)" + continue + fi + ad_val="$(jq -r ".allowed_dirs[$ai]" "$file" 2>/dev/null)" + if [ -z "$ad_val" ]; then + diag error "$file" ".allowed_dirs[$ai]" "" "schema: allowed_dirs entry must be non-empty" + continue + fi + case "$ad_val" in + /*) + # absolute path - ok, continue to further checks + ;; + *) + diag error "$file" ".allowed_dirs[$ai]" "" "schema: allowed_dirs entry must be an absolute path (start with /)" + continue + ;; + esac + case "$ad_val" in + *'/../'*|*'/..') + diag error "$file" ".allowed_dirs[$ai]" "" "schema: allowed_dirs entry contains path traversal (/../)" + ;; + esac + done + fi + fi + # Allow[] rules. n_allow="$(jq -r '.allow | length' <<<"$normalized")" for ((i = 0; i < n_allow; i++)); do diff --git a/tests/bootstrap.bats b/tests/bootstrap.bats index 7cbf124..c253d7b 100644 --- a/tests/bootstrap.bats +++ b/tests/bootstrap.bats @@ -831,6 +831,63 @@ EOF done } +# --------------------------------------------------------------------------- +# additionalAllowedWorkingDirs import +# --------------------------------------------------------------------------- + +@test "bootstrap: imports additionalAllowedWorkingDirs from user settings" { + cat > "$USER_ROOT/.claude/settings.json" <<'EOF' +{ + "permissions": {"allow": ["Bash(ls:*)"]}, + "env": {"additionalAllowedWorkingDirs": ["/opt/shared", "/data/reference"]} +} +EOF + run_boot --user-only --write + [ "$status" -eq 0 ] + [ -f "$(user_imported)" ] + # Check that allowed_dirs are present in the imported file. + run jq -r '.allowed_dirs | length' "$(user_imported)" + [ "$output" = "2" ] + run jq -r '.allowed_dirs[0]' "$(user_imported)" + [ "$output" = "/opt/shared" ] + run jq -r '.allowed_dirs[1]' "$(user_imported)" + [ "$output" = "/data/reference" ] +} + +@test "bootstrap: no additionalAllowedWorkingDirs -> no allowed_dirs key in output" { + cat > "$USER_ROOT/.claude/settings.json" <<'EOF' +{"permissions": {"allow": ["Bash(ls:*)"]}} +EOF + run_boot --user-only --write + [ "$status" -eq 0 ] + # allowed_dirs should NOT be present. + run jq 'has("allowed_dirs")' "$(user_imported)" + [ "$output" = "false" ] +} + +@test "bootstrap: project scope merges allowed dirs from shared + local settings" { + cat > "$PROJ_ROOT/.claude/settings.json" <<'EOF' +{ + "permissions": {"allow": []}, + "env": {"additionalAllowedWorkingDirs": ["/opt/shared"]} +} +EOF + cat > "$PROJ_ROOT/.claude/settings.local.json" <<'EOF' +{ + "permissions": {"allow": []}, + "env": {"additionalAllowedWorkingDirs": ["/opt/shared", "/data/local"]} +} +EOF + run_boot --project-only --write + [ "$status" -eq 0 ] + # Deduplicated: /opt/shared appears once, /data/local once. + run jq -r '.allowed_dirs | length' "$(proj_imported)" + [ "$output" = "2" ] + # Both dirs present (order from jq unique is sorted). + run jq -r '.allowed_dirs | sort | join(",")' "$(proj_imported)" + [ "$output" = "/data/local,/opt/shared" ] +} + @test "bootstrap: re-running --write is idempotent at the hash level" { printf '%s\n' '{"permissions":{"allow":["Bash(ls:*)","Bash(echo hello)"]}}' \ > "$USER_ROOT/.claude/settings.json" diff --git a/tests/command_splitting.bats b/tests/command_splitting.bats new file mode 100644 index 0000000..36ab63d --- /dev/null +++ b/tests/command_splitting.bats @@ -0,0 +1,448 @@ +#!/usr/bin/env bats + +# tests/command_splitting.bats +# Validates hooks/common.sh split_bash_command function: +# - single commands (no split) +# - pipe splitting +# - && and || splitting +# - ; and & splitting +# - quoted string preservation (single, double, $(), backtick) +# - redirection stripping +# - mixed compound commands +# - parse failure fallback (returns original as single segment) + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + + # Synthetic scope roots so sourcing common.sh path helpers can't touch real ~/.claude. + TMP="$(mktemp -d -t passthru-test.XXXXXX)" + USER_ROOT="$TMP/user" + PROJ_ROOT="$TMP/proj" + mkdir -p "$USER_ROOT/.claude" "$PROJ_ROOT/.claude" + export PASSTHRU_USER_HOME="$USER_ROOT" + export PASSTHRU_PROJECT_DIR="$PROJ_ROOT" + + # shellcheck disable=SC1090 + source "$REPO_ROOT/hooks/common.sh" +} + +teardown() { + [ -n "${TMP:-}" ] && rm -rf "$TMP" +} + +# Helper: collect NUL-separated output into a bash array. +# Usage: collect_segments +# After call, SEGMENTS array holds the segments, SEGMENT_COUNT the count. +collect_segments() { + SEGMENTS=() + SEGMENT_COUNT=0 + local seg + while IFS= read -r -d '' seg; do + SEGMENTS+=("$seg") + SEGMENT_COUNT=$((SEGMENT_COUNT + 1)) + done < <(split_bash_command "$1") +} + +# --------------------------------------------------------------------------- +# Single commands (no split needed, returns 1 segment) +# --------------------------------------------------------------------------- + +@test "split: single command returns 1 segment" { + collect_segments "ls -la" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "ls -la" ] +} + +@test "split: bare command returns 1 segment" { + collect_segments "ls" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "ls" ] +} + +@test "split: complex single command preserves arguments" { + collect_segments "grep -r 'pattern' /some/path" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "grep -r 'pattern' /some/path" ] +} + +# --------------------------------------------------------------------------- +# Pipe splitting: ls | head -> ["ls", "head"] +# --------------------------------------------------------------------------- + +@test "split: pipe splits into two segments" { + collect_segments "ls | head" + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = "ls" ] + [ "${SEGMENTS[1]}" = "head" ] +} + +@test "split: multi-pipe splits correctly" { + collect_segments "cat file | grep foo | wc -l" + [ "$SEGMENT_COUNT" -eq 3 ] + [ "${SEGMENTS[0]}" = "cat file" ] + [ "${SEGMENTS[1]}" = "grep foo" ] + [ "${SEGMENTS[2]}" = "wc -l" ] +} + +# --------------------------------------------------------------------------- +# && and || splitting +# --------------------------------------------------------------------------- + +@test "split: && splits into segments" { + collect_segments "mkdir -p dir && cd dir" + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = "mkdir -p dir" ] + [ "${SEGMENTS[1]}" = "cd dir" ] +} + +@test "split: || splits into segments" { + collect_segments "test -f file || echo missing" + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = "test -f file" ] + [ "${SEGMENTS[1]}" = "echo missing" ] +} + +@test "split: mixed && and || splits all" { + collect_segments "cmd1 && cmd2 || cmd3" + [ "$SEGMENT_COUNT" -eq 3 ] + [ "${SEGMENTS[0]}" = "cmd1" ] + [ "${SEGMENTS[1]}" = "cmd2" ] + [ "${SEGMENTS[2]}" = "cmd3" ] +} + +# --------------------------------------------------------------------------- +# ; and & splitting +# --------------------------------------------------------------------------- + +@test "split: semicolon splits into segments" { + collect_segments "echo hello; echo world" + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = "echo hello" ] + [ "${SEGMENTS[1]}" = "echo world" ] +} + +@test "split: background & splits into segments" { + collect_segments "sleep 10 & echo foreground" + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = "sleep 10" ] + [ "${SEGMENTS[1]}" = "echo foreground" ] +} + +# --------------------------------------------------------------------------- +# Quoted strings preserved (operators inside quotes are NOT split points) +# --------------------------------------------------------------------------- + +@test "split: single-quoted string preserved" { + collect_segments "echo 'foo && bar'" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "echo 'foo && bar'" ] +} + +@test "split: single-quoted pipe preserved" { + collect_segments "echo 'foo | bar'" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "echo 'foo | bar'" ] +} + +@test "split: double-quoted string preserved" { + collect_segments 'echo "foo | bar"' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo "foo | bar"' ] +} + +@test "split: double-quoted && preserved" { + collect_segments 'echo "foo && bar"' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo "foo && bar"' ] +} + +# --------------------------------------------------------------------------- +# $() subshell preserved +# --------------------------------------------------------------------------- + +@test "split: dollar-paren subshell preserved" { + collect_segments 'echo $(foo | bar)' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo $(foo | bar)' ] +} + +@test "split: nested dollar-paren subshell preserved" { + collect_segments 'echo $(cat $(find . -name "*.txt"))' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo $(cat $(find . -name "*.txt"))' ] +} + +@test "split: dollar-paren with && inside preserved" { + collect_segments 'result=$(cmd1 && cmd2)' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'result=$(cmd1 && cmd2)' ] +} + +# --------------------------------------------------------------------------- +# Backtick subshell preserved +# --------------------------------------------------------------------------- + +@test "split: backtick subshell preserved" { + collect_segments 'echo `foo | bar`' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo `foo | bar`' ] +} + +@test "split: backtick with && inside preserved" { + collect_segments 'echo `cmd1 && cmd2`' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo `cmd1 && cmd2`' ] +} + +# --------------------------------------------------------------------------- +# Redirection stripping +# --------------------------------------------------------------------------- + +@test "split: stdout redirect stripped" { + collect_segments "ls > /tmp/out" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "ls" ] +} + +@test "split: append redirect stripped" { + collect_segments "echo hello >> /tmp/log" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "echo hello" ] +} + +@test "split: stdin redirect stripped" { + collect_segments "sort < /tmp/input" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "sort" ] +} + +@test "split: stderr redirect 2>&1 stripped" { + collect_segments "cmd 2>&1" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "cmd" ] +} + +@test "split: stderr redirect to file stripped" { + collect_segments "cmd 2>/dev/null" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "cmd" ] +} + +@test "split: multiple redirects stripped" { + collect_segments "cmd > /tmp/out 2>&1" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "cmd" ] +} + +# --------------------------------------------------------------------------- +# Quote-aware redirection stripping (> inside quotes preserved) +# --------------------------------------------------------------------------- + +@test "split: > inside double quotes not stripped" { + collect_segments 'echo "hello > world"' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo "hello > world"' ] +} + +@test "split: > inside single quotes not stripped" { + collect_segments "echo 'hello > world'" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "echo 'hello > world'" ] +} + +@test "split: > inside \$() subshell not stripped" { + collect_segments 'echo "$(cat > /dev/null)"' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo "$(cat > /dev/null)"' ] +} + +@test "split: > inside backticks not stripped" { + collect_segments 'echo "`cat > /dev/null`"' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo "`cat > /dev/null`"' ] +} + +@test "split: quoted > preserved but unquoted > stripped in same segment" { + collect_segments "echo 'hello > world' > /tmp/out" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "echo 'hello > world'" ] +} + +@test "split: compound with quoted > preserved correctly" { + collect_segments 'echo "hello > world" && pwd' + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = 'echo "hello > world"' ] + [ "${SEGMENTS[1]}" = "pwd" ] +} + +@test "split: << heredoc preserved (not stripped)" { + collect_segments 'cat <<< "hello"' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'cat <<< "hello"' ] +} + +# --------------------------------------------------------------------------- +# Mixed compound commands +# --------------------------------------------------------------------------- + +@test "split: curl | head && echo done" { + collect_segments "curl url | head && echo done" + [ "$SEGMENT_COUNT" -eq 3 ] + [ "${SEGMENTS[0]}" = "curl url" ] + [ "${SEGMENTS[1]}" = "head" ] + [ "${SEGMENTS[2]}" = "echo done" ] +} + +@test "split: pipe with redirect in first segment" { + collect_segments "ls 2>/dev/null | head" + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = "ls" ] + [ "${SEGMENTS[1]}" = "head" ] +} + +@test "split: semicolons and pipes mixed" { + collect_segments "echo start; ls | grep foo; echo end" + [ "$SEGMENT_COUNT" -eq 4 ] + [ "${SEGMENTS[0]}" = "echo start" ] + [ "${SEGMENTS[1]}" = "ls" ] + [ "${SEGMENTS[2]}" = "grep foo" ] + [ "${SEGMENTS[3]}" = "echo end" ] +} + +# --------------------------------------------------------------------------- +# Parse failure fallback (returns original as single segment) +# --------------------------------------------------------------------------- + +@test "split: unterminated single quote returns original as fallback" { + collect_segments "echo 'unterminated" + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = "echo 'unterminated" ] +} + +@test "split: unterminated double quote returns original as fallback" { + collect_segments 'echo "unterminated' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo "unterminated' ] +} + +@test "split: unterminated backtick returns original as fallback" { + collect_segments 'echo `unterminated' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo `unterminated' ] +} + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +@test "split: empty command returns nothing" { + collect_segments "" + [ "$SEGMENT_COUNT" -eq 0 ] +} + +@test "split: whitespace-only between operators filtered" { + collect_segments "echo hello ; ; echo world" + [ "$SEGMENT_COUNT" -eq 2 ] + [ "${SEGMENTS[0]}" = "echo hello" ] + [ "${SEGMENTS[1]}" = "echo world" ] +} + +@test "split: backslash-escaped pipe not treated as operator" { + collect_segments 'echo foo\|bar' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo foo\|bar' ] +} + +@test "split: dollar-paren inside double quotes preserved" { + collect_segments 'echo "$(ls | head)"' + [ "$SEGMENT_COUNT" -eq 1 ] + [ "${SEGMENTS[0]}" = 'echo "$(ls | head)"' ] +} + +# =========================================================================== +# has_redirect +# =========================================================================== + +@test "redirect: simple > detected" { + has_redirect "cat file > /tmp/out" +} + +@test "redirect: >> detected" { + has_redirect "echo ok >> /tmp/log" +} + +@test "redirect: 2> stderr redirect detected" { + has_redirect "cmd 2> /tmp/err" +} + +@test "redirect: no redirect in plain command" { + run has_redirect "cat file.txt" + [ "$status" -eq 1 ] +} + +@test "redirect: > inside single quotes not detected" { + run has_redirect "echo 'hello > world'" + [ "$status" -eq 1 ] +} + +@test "redirect: > inside double quotes not detected" { + run has_redirect 'echo "hello > world"' + [ "$status" -eq 1 ] +} + +@test "redirect: > inside \$() subshell not detected at top level" { + run has_redirect 'echo $(cat > /dev/null)' + [ "$status" -eq 1 ] +} + +@test "redirect: > inside backticks not detected at top level" { + run has_redirect 'echo `cat > /dev/null`' + [ "$status" -eq 1 ] +} + +@test "redirect: 2>&1 fd duplication not detected as file redirect" { + run has_redirect "cmd 2>&1" + [ "$status" -eq 1 ] +} + +@test "redirect: >&2 fd duplication not detected as file redirect" { + run has_redirect "echo error >&2" + [ "$status" -eq 1 ] +} + +@test "redirect: empty command returns 1" { + run has_redirect "" + [ "$status" -eq 1 ] +} + +# --- Input redirect tests --- + +@test "redirect: simple < detected" { + has_redirect "wc < /etc/passwd" +} + +@test "redirect: < with fd number detected" { + has_redirect "cmd 0< /dev/null" +} + +@test "redirect: < inside single quotes not detected" { + run has_redirect "echo 'a < b'" + [ "$status" -eq 1 ] +} + +@test "redirect: < inside double quotes not detected" { + run has_redirect 'echo "a < b"' + [ "$status" -eq 1 ] +} + +@test "redirect: << heredoc not detected as input redirect" { + run has_redirect 'cat < overlay path. # With no multiplexer available in the test env, the overlay fallback # emits permissionDecision:"ask" so CC surfaces its native dialog. - run_handler '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + run_handler '{"tool_name":"Bash","tool_input":{"command":"make build"}}' [ "$status" -eq 0 ] # Stderr warning about missing multiplexer is lumped into $output by `run`. [[ "$output" == *"no supported multiplexer"* ]] @@ -78,8 +79,18 @@ audit_log() { } @test "handler: allow match emits allow decision JSON" { - place "$USER_ROOT/.claude/passthru.json" "user-only.json" - run_handler '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' + # Use a non-readonly command with an explicit allow rule so the readonly + # auto-allow step does not intercept before the rule-match path. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 1, + "allow": [ + { "tool": "Bash", "match": { "command": "^make(\\s|$)" }, "reason": "build tool" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"make build"}}' [ "$status" -eq 0 ] out="$output" event="$(jq -r '.hookSpecificOutput.hookEventName' <<<"$out")" @@ -87,7 +98,7 @@ audit_log() { reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$out")" [ "$event" = "PreToolUse" ] [ "$decision" = "allow" ] - [ "$reason" = "passthru allow: safe read-only listing" ] + [ "$reason" = "passthru allow: build tool" ] } @test "handler: deny match emits deny decision JSON" { @@ -303,9 +314,19 @@ EOF } @test "audit enabled: allow match writes one JSONL line, no breadcrumb" { - place "$USER_ROOT/.claude/passthru.json" "user-only.json" + # Use a non-readonly command with an explicit allow rule so the readonly + # auto-allow step does not intercept before the rule-match audit path. enable_audit - run_handler '{"tool_name":"Bash","tool_input":{"command":"ls -la"},"tool_use_id":"t1"}' + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 1, + "allow": [ + { "tool": "Bash", "match": { "command": "^make(\\s|$)" }, "reason": "build tool" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"make build"},"tool_use_id":"t1"}' [ "$status" -eq 0 ] [ -f "$(audit_log)" ] # Exactly one line. @@ -322,7 +343,7 @@ EOF run jq -r '.tool' <<<"$line" [ "$output" = "Bash" ] run jq -r '.reason' <<<"$line" - [ "$output" = "safe read-only listing" ] + [ "$output" = "build tool" ] run jq -r '.tool_use_id' <<<"$line" [ "$output" = "t1" ] # No breadcrumb for non-passthrough decisions. @@ -461,9 +482,19 @@ EOF } @test "audit log: full schema check on allow line (.ts ISO, .rule_index int, .pattern non-empty)" { - place "$USER_ROOT/.claude/passthru.json" "user-only.json" + # Use a non-readonly command with a custom rule to avoid the readonly + # auto-allow path intercepting before the rule-match audit path. enable_audit - run_handler '{"tool_name":"Bash","tool_input":{"command":"ls -la"},"tool_use_id":"schema-allow"}' + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 1, + "allow": [ + { "tool": "Bash", "match": { "command": "^make(\\s|$)" }, "reason": "allow make" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"make build"},"tool_use_id":"schema-allow"}' [ "$status" -eq 0 ] line="$(head -n1 "$(audit_log)")" # ts is ISO 8601 Z form: YYYY-MM-DDTHH:MM:SSZ. @@ -519,10 +550,11 @@ EOF @test "handler: invalid regex in allow rule -> fail-open passthrough + stderr" { # Same shape as above but the bad regex is in allow[]. Deny[] is empty so # the handler reaches the allow check, hits rc=2, and falls through. + # Use a non-readonly command so the readonly auto-allow step does not intercept. cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' {"version":1,"allow":[{"tool":"Bash","match":{"command":"[unclosed"}}],"deny":[]} EOF - run_handler '{"tool_name":"Bash","tool_input":{"command":"ls"}}' + run_handler '{"tool_name":"Bash","tool_input":{"command":"make build"}}' [ "$status" -eq 0 ] [[ "$output" == *'{"continue": true}'* ]] [[ "$output" == *"allow rule regex error"* ]] || [[ "$output" == *"regex compile failure"* ]] @@ -1051,16 +1083,17 @@ run_handler_in_stub_root() { [ "$decision" = "deny" ] } -@test "mode: default + WebSearch -> overlay path entered" { - setup_overlay_stub "no_once" +@test "mode: default + WebSearch -> explicit allow (internal tool)" { ti='{"query":"what is claude code"}' payload="$(make_mode_payload 'WebSearch' "$ti" 'default' "$PROJ_ROOT")" - run_handler_in_stub_root "$payload" + run_handler "$payload" [ "$status" -eq 0 ] json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" [ -n "$json_line" ] decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" - [ "$decision" = "deny" ] + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"passthru internal"* ]] } # Path-traversal safety ------------------------------------------------------ @@ -1104,7 +1137,8 @@ run_handler_in_stub_root() { setup_overlay_refuses_invocation touch "$USER_ROOT/.claude/passthru.overlay.disabled" # Bash in default mode -> no auto-allow, would go to overlay. - ti='{"command":"ls"}' + # Use a non-readonly command (make) so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(make_mode_payload 'Bash' "$ti" 'default' "$PROJ_ROOT")" run_handler_in_stub_root "$payload" [ "$status" -eq 0 ] @@ -1120,8 +1154,9 @@ run_handler_in_stub_root() { @test "overlay: no multiplexer env -> stderr warning + native ask, overlay NOT invoked" { # No TMUX/KITTY/WEZTERM -> overlay_available returns 1 -> we warn to # stderr and emit permissionDecision:"ask". The stub must NOT run. + # Use a non-readonly command so the readonly auto-allow step does not intercept. setup_overlay_refuses_invocation - ti='{"command":"ls"}' + ti='{"command":"make build"}' payload="$(make_mode_payload 'Bash' "$ti" 'default' "$PROJ_ROOT")" run_handler_in_stub_root "$payload" [ "$status" -eq 0 ] @@ -1251,7 +1286,8 @@ EOF chmod +x "$BIN/tmux" export PATH="$BIN:$PATH" - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(make_mode_payload 'Bash' "$ti" 'default' "$PROJ_ROOT")" run_handler_in_stub_root "$payload" [ "$status" -eq 0 ] @@ -1275,7 +1311,8 @@ EOF chmod +x "$BIN/tmux" export PATH="$BIN:$PATH" - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(make_mode_payload 'Bash' "$ti" 'default' "$PROJ_ROOT")" run_handler_in_stub_root "$payload" [ "$status" -eq 0 ] @@ -1406,8 +1443,10 @@ EOF @test "audit: bypassPermissions mode logs source=passthru-mode (mode auto-allow)" { # bypassPermissions auto-allows everything. Passthru emits allow with # source=passthru-mode, keeping the decision on our side. + # Use a non-readonly command so the readonly auto-allow step does not intercept + # before the mode-based auto-allow step. enable_audit - ti='{"command":"ls"}' + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'bypassPermissions' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tMODE"}')" run_handler "$payload" @@ -1431,7 +1470,8 @@ EOF enable_audit setup_overlay_refuses_invocation touch "$USER_ROOT/.claude/passthru.overlay.disabled" - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'default' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tOVD"}')" run_handler_in_stub_root "$payload" @@ -1442,7 +1482,8 @@ EOF @test "breadcrumb: overlay-unavailable ask path drops a breadcrumb" { enable_audit setup_overlay_refuses_invocation - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'default' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tOVU"}')" run_handler_in_stub_root "$payload" @@ -1453,7 +1494,8 @@ EOF @test "breadcrumb: overlay-launch-failure ask path drops a breadcrumb" { enable_audit setup_overlay_stub "ignored" 1 - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'default' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tOVL"}')" run_handler_in_stub_root "$payload" @@ -1464,7 +1506,8 @@ EOF @test "breadcrumb: overlay-cancel ask path drops a breadcrumb" { enable_audit setup_overlay_stub "cancel" - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'default' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tOVC"}')" run_handler_in_stub_root "$payload" @@ -1476,7 +1519,8 @@ EOF enable_audit # Stub writes a verdict we do not recognize so the *) branch fires. setup_overlay_stub "banana" - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'default' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tOVK"}')" run_handler_in_stub_root "$payload" @@ -1492,7 +1536,8 @@ EOF enable_audit setup_overlay_stub "ignored" rm -f "$CLAUDE_PLUGIN_ROOT/scripts/overlay.sh" - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'default' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tOVM"}')" run_handler_in_stub_root "$payload" @@ -1554,7 +1599,8 @@ EOF # sha maps to asked_allowed_once via classify_passthrough_outcome. enable_audit setup_overlay_stub "cancel" - ti='{"command":"ls"}' + # Use a non-readonly command so the readonly auto-allow step does not intercept. + ti='{"command":"make build"}' payload="$(jq -cn --arg t 'Bash' --argjson ti "$ti" --arg m 'default' --arg c "$PROJ_ROOT" \ '{tool_name:$t,tool_input:$ti,permission_mode:$m,cwd:$c,tool_use_id:"tCHAIN"}')" run_handler_in_stub_root "$payload" @@ -1576,3 +1622,922 @@ EOF # Breadcrumb must be unlinked after PostToolUse consumed it. [ ! -f "$TMPDIR/passthru-pre-tCHAIN.json" ] } + +# =========================================================================== +# Task 2: Compound command splitting integration +# =========================================================================== + +@test "compound: deny rule on second segment blocks compound command" { + # `echo hello && rm -rf /` must be denied because the deny rule on ^rm + # matches the second segment, even though the first segment is benign. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^echo(\\s|$)" }, "reason": "allow echo" } + ], + "deny": [ + { "tool": "Bash", "match": { "command": "^rm " }, "reason": "no rm" } + ] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"echo hello && rm -rf /"}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "deny" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [[ "$reason" == *"no rm"* ]] +} + +@test "compound: allow rule on first segment only does NOT allow compound command" { + # `ls -la && curl evil.example` should NOT be allowed because only the + # first segment matches an allow rule. The second segment has no match, + # so it falls through to overlay/native dialog. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^ls(\\s|$)" }, "reason": "allow ls" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"ls -la && curl evil.example"}}' + [ "$status" -eq 0 ] + # Should fall through to overlay path. With no multiplexer, gets ask fallback. + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "compound: allow rules covering ALL segments allows compound command" { + # Two different rules covering two different segments: both must match + # for the compound command to be allowed. Use non-readonly commands (make, + # npm) so the readonly auto-allow step does not intercept. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^make(\\s|$)" }, "reason": "allow make" }, + { "tool": "Bash", "match": { "command": "^npm(\\s|$)" }, "reason": "allow npm" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"make build && npm test"}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + # First segment's rule is used for audit. The first segment is "make build" + # which matches the "allow make" rule. + [[ "$reason" == *"allow make"* ]] +} + +@test "compound: ask rule on any segment triggers ask for compound" { + # `ls -la && gh pr list` with an ask rule on ^gh should trigger ask + # for the whole compound command, even though the first segment matches + # an allow rule. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^ls(\\s|$)" }, "reason": "allow ls" } + ], + "ask": [ + { "tool": "Bash", "match": { "command": "^gh " }, "reason": "confirm gh" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"ls -la && gh pr list"}}' + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"confirm gh"* ]] +} + +@test "compound: one segment matches allow, another has no match -> falls through to overlay" { + # `cat file.txt | some-unknown-cmd` where only cat has an allow rule. + # The second segment has no match, so the whole command falls through. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^cat(\\s|$)" }, "reason": "allow cat" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"cat file.txt | some-unknown-cmd"}}' + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "compound: single command (no operators) works identically to current behavior" { + # A single command without operators should use the existing single-match + # loop, producing the same result as before. Use a non-readonly command + # so the readonly auto-allow step does not intercept. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^make(\\s|$)" }, "reason": "allow make" } + ], + "deny": [] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"make build"}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [[ "$reason" == *"allow make"* ]] +} + +@test "compound: single-segment command denied through non-compound path" { + # A single command without operators goes through the original single-match + # path (the else branch at BASH_SEGMENT_COUNT <= 1). Verify deny still works + # correctly on this path after compound splitting was introduced. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [], + "deny": [ + { "tool": "Bash", "match": { "command": "^rm " }, "reason": "no rm" } + ] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/data"}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "deny" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [[ "$reason" == *"no rm"* ]] +} + +@test "compound: deny on piped segment blocks whole pipe chain" { + # `echo ok | rm -rf /` must be denied because rm matches a deny rule. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "Bash", "match": { "command": "^echo(\\s|$)" }, "reason": "allow echo" } + ], + "deny": [ + { "tool": "Bash", "match": { "command": "^rm " }, "reason": "no rm" } + ] +} +EOF + run_handler '{"tool_name":"Bash","tool_input":{"command":"echo ok | rm -rf /"}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "deny" ] +} + +@test "compound: non-Bash tool ignores splitting (no behavior change)" { + # WebFetch should not be affected by splitting logic at all. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [ + { "tool": "WebFetch", "match": { "url": "^https://example\\.com" }, "reason": "allow example" } + ], + "deny": [] +} +EOF + payload='{"tool_name":"WebFetch","tool_input":{"url":"https://example.com/api"}}' + run_handler "$payload" + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] +} + +# =========================================================================== +# Task 3: Read-only Bash command auto-allow +# =========================================================================== + +@test "readonly: cat src/main.rs auto-allowed (relative path, inside cwd)" { + run_handler "$(jq -cn --arg c "cat src/main.rs" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"readonly"* ]] + [[ "$reason" == *"cat"* ]] +} + +@test "readonly: cat /proj/src/main.rs auto-allowed when cwd is /proj" { + run_handler "$(jq -cn --arg c "cat $PROJ_ROOT/src/main.rs" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"readonly"* ]] +} + +@test "readonly: cat /etc/passwd NOT auto-allowed (absolute path outside cwd)" { + run_handler "$(jq -cn '{tool_name:"Bash",tool_input:{command:"cat /etc/passwd"},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + # Should fall through to overlay path (no readonly auto-allow). + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat with path traversal NOT auto-allowed" { + # cat /proj/../etc/passwd uses /../ traversal to escape cwd. The + # readonly_paths_allowed helper delegates to _pm_path_inside_cwd which + # rejects paths containing /../. This must NOT be auto-allowed. + run_handler "$(jq -cn --arg c "cat $PROJ_ROOT/../etc/passwd" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: head -n 10 file.txt auto-allowed (relative path)" { + run_handler "$(jq -cn --arg c "head -n 10 file.txt" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"readonly"* ]] +} + +@test "readonly: ls /proj/docs/ auto-allowed when cwd is /proj" { + run_handler "$(jq -cn --arg c "ls $PROJ_ROOT/docs/" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"readonly"* ]] +} + +@test "readonly: ls /tmp/random NOT auto-allowed (outside cwd)" { + run_handler "$(jq -cn '{tool_name:"Bash",tool_input:{command:"ls /tmp/random"},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat file.txt | head auto-allowed (both segments readonly, relative paths)" { + run_handler "$(jq -cn --arg c "cat file.txt | head" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"readonly"* ]] +} + +@test "readonly: cat file.txt | rm -rf / NOT auto-allowed (rm is not readonly)" { + run_handler "$(jq -cn --arg c "cat file.txt | rm -rf /" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + # rm is not readonly. Since rm -rf / matches no allow rule either, falls + # through to overlay. But there is also no deny rule in this test, so + # the outcome depends on whether the deny check catches rm. Without an + # explicit deny rule, it falls to overlay. + [ "$decision" = "ask" ] +} + +@test "readonly: deny rule overrides readonly auto-allow" { + # Even though cat is a readonly command, an explicit deny rule on ^cat + # takes priority (deny runs before readonly check). + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [], + "deny": [ + { "tool": "Bash", "match": { "command": "^cat " }, "reason": "no cat" } + ] +} +EOF + run_handler "$(jq -cn --arg c "cat src/file.txt" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "deny" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [[ "$reason" == *"no cat"* ]] +} + +@test "readonly: echo safe string auto-allowed" { + run_handler "$(jq -cn --arg c 'echo safe string' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"readonly"* ]] +} + +@test "readonly: echo with dollar-paren NOT auto-allowed" { + # echo $(dangerous) contains $() which the safety regex rejects. + run_handler '{"tool_name":"Bash","tool_input":{"command":"echo $(dangerous)"},"cwd":"'"$PROJ_ROOT"'"}' + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: echo with dollar-sign variable NOT auto-allowed" { + # echo $HOME exposes environment variables via shell expansion. + run_handler '{"tool_name":"Bash","tool_input":{"command":"echo $HOME"},"cwd":"'"$PROJ_ROOT"'"}' + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: docker ps matches docker ps regex" { + run_handler "$(jq -cn --arg c "docker ps" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$json_line")" + [[ "$reason" == *"readonly"* ]] +} + +@test "readonly: docker exec does NOT match docker ps regex" { + run_handler "$(jq -cn --arg c "docker exec -it mycontainer bash" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + # docker exec is not a readonly command, falls through to overlay. + [ "$decision" = "ask" ] +} + +@test "readonly: find -fprint NOT auto-allowed (writes to file)" { + run_handler "$(jq -cn --arg c 'find . -fprint out.txt' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: find -fprintf NOT auto-allowed (writes to file)" { + run_handler "$(jq -cn --arg c 'find . -fprintf out.txt %p' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: find -fls NOT auto-allowed (writes to file)" { + run_handler "$(jq -cn --arg c 'find . -fls out.txt' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: find -name still auto-allowed (benign predicate)" { + run_handler "$(jq -cn --arg c 'find . -name *.txt' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "allow" ] +} + +@test "readonly: allowed_dirs integration - _pm_path_inside_any_allowed with extra dir" { + # Unit test for the allowed_dirs path-checking function. Task 6 will wire + # this into the hook via load_allowed_dirs; this test verifies the function + # itself works correctly with an allowed_dirs JSON array. + source "$REPO_ROOT/hooks/common.sh" + local cwd="/home/user/project" + local allowed='["/opt/extra","/data/shared"]' + # Path inside cwd: allowed. + _pm_path_inside_any_allowed "/home/user/project/src/main.rs" "$cwd" "$allowed" + # Path inside an extra allowed dir: allowed. + _pm_path_inside_any_allowed "/opt/extra/config.json" "$cwd" "$allowed" + _pm_path_inside_any_allowed "/data/shared/docs/readme.md" "$cwd" "$allowed" + # Path outside all: not allowed. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; _pm_path_inside_any_allowed '/etc/passwd' '/home/user/project' '[\"$PROJ_ROOT\"]'" + [ "$status" -ne 0 ] +} + +@test "readonly: _pm_path_inside_cwd matches path equal to cwd itself" { + source "$REPO_ROOT/hooks/common.sh" + # Exact match: path IS the directory. + _pm_path_inside_cwd "/home/user/project" "/home/user/project" + # Descendant still works. + _pm_path_inside_cwd "/home/user/project/sub/file" "/home/user/project" + # Different path still rejected. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; _pm_path_inside_cwd '/etc/passwd' '/home/user/project'" + [ "$status" -ne 0 ] +} + +@test "readonly: _pm_path_inside_cwd handles trailing slash on directory" { + source "$REPO_ROOT/hooks/common.sh" + # Trailing slash on the directory must not break descendant matching. + _pm_path_inside_cwd "/opt/shared/file.txt" "/opt/shared/" + # Exact match with trailing slash also works. + _pm_path_inside_cwd "/opt/shared" "/opt/shared/" + # Unrelated path still rejected. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; _pm_path_inside_cwd '/etc/passwd' '/opt/shared/'" + [ "$status" -ne 0 ] +} + +@test "readonly: _pm_path_inside_any_allowed with trailing-slash allowed_dirs entry" { + source "$REPO_ROOT/hooks/common.sh" + local cwd="/home/user/project" + local allowed='["/opt/shared/"]' + # Path inside allowed dir with trailing slash must still match. + _pm_path_inside_any_allowed "/opt/shared/docs/readme.md" "$cwd" "$allowed" + # Exact match of the dir itself. + _pm_path_inside_any_allowed "/opt/shared" "$cwd" "$allowed" +} + +@test "readonly: allowed_dirs integration - readonly_paths_allowed with extra dir" { + # Unit test: readonly_paths_allowed should accept paths in allowed dirs. + source "$REPO_ROOT/hooks/common.sh" + local cwd="$PROJ_ROOT" + local allowed="[\"$TMP/extra\"]" + # cat with a path in the allowed dir should pass. + readonly_paths_allowed "cat $TMP/extra/file.txt" "$cwd" "$allowed" + # cat with a path outside all dirs should fail. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'cat /etc/passwd' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] +} + +@test "readonly: readonly_paths_allowed rejects tilde paths" { + # Unit test: tilde-prefixed paths must be rejected because Bash expands + # ~ to $HOME before execution. Without this check, ~/.ssh/id_rsa would + # be treated as a relative path inside cwd. + source "$REPO_ROOT/hooks/common.sh" + # ~/... must be rejected. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'cat ~/.ssh/id_rsa' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] + # Bare ~ must be rejected. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'ls ~' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] + # Normal relative path still allowed (treated as inside cwd). + readonly_paths_allowed "cat src/main.rs" "$PROJ_ROOT" "[]" +} + +@test "readonly: readonly_paths_allowed rejects ~user, ~+, ~- expansions" { + # Bash expands ~root to the root user home dir, ~+ to $PWD, ~- to $OLDPWD. + # All tilde-prefixed tokens must be rejected, not just ~ and ~/. + source "$REPO_ROOT/hooks/common.sh" + # ~root -> /var/root (or similar). + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'cat ~root/.ssh/id_rsa' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] + # ~+ -> $PWD. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'cat ~+/file' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] + # ~- -> $OLDPWD. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'cat ~-/file' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] + # ~nobody -> another user home. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'ls ~nobody' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] +} + +@test "readonly: readonly_paths_allowed rejects bare .. traversal" { + # A bare ".." token (without trailing /) escapes cwd just like ../path. + source "$REPO_ROOT/hooks/common.sh" + # ls .. lists the parent directory. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'ls ..' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] + # find .. -name secret searches the parent tree. + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'find .. -name secret' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] +} + +@test "readonly: readonly_paths_allowed rejects quoted multi-word traversal paths" { + # read -ra splits on whitespace, breaking a quoted multi-word path like + # "../secret dir/file" into tokens with orphaned quotes. The leading-only + # quote strip must expose the ../ pattern for the traversal guard. + source "$REPO_ROOT/hooks/common.sh" + run bash -c "source '$REPO_ROOT/hooks/common.sh'; readonly_paths_allowed 'cat \"../secret dir/file\"' '$PROJ_ROOT' '[]'" + [ "$status" -ne 0 ] +} + +@test "readonly: audit log records passthru-readonly source" { + enable_audit + run_handler "$(jq -cn --arg c "cat src/file.txt" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'",tool_use_id:"tRO"}')" + [ "$status" -eq 0 ] + [ -f "$(audit_log)" ] + line="$(head -n1 "$(audit_log)")" + run jq -r '.event' <<<"$line" + [ "$output" = "allow" ] + run jq -r '.source' <<<"$line" + [ "$output" = "passthru-readonly" ] + run jq -r '.reason' <<<"$line" + [[ "$output" == *"readonly"* ]] + [[ "$output" == *"cat"* ]] + run jq -r '.tool_use_id' <<<"$line" + [ "$output" = "tRO" ] +} + +# =========================================================================== +# Task 4: Auto-allow Agent, Skill, and Glob tools +# =========================================================================== + +@test "internal-allow: Agent tool returns explicit allow decision (not passthrough)" { + run_handler '{"tool_name":"Agent","tool_input":{}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [ "$reason" = "passthru internal: Agent" ] + # Must NOT be a passthrough ({"continue":true}). + run jq -e '.continue' <<<"$output" + [ "$status" -ne 0 ] +} + +@test "internal-allow: Skill tool returns explicit allow decision (not passthrough)" { + run_handler '{"tool_name":"Skill","tool_input":{}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [ "$reason" = "passthru internal: Skill" ] + # Must NOT be a passthrough. + run jq -e '.continue' <<<"$output" + [ "$status" -ne 0 ] +} + +@test "internal-allow: Glob tool returns explicit allow decision (not passthrough)" { + run_handler '{"tool_name":"Glob","tool_input":{}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [ "$reason" = "passthru internal: Glob" ] + # Must NOT be a passthrough. + run jq -e '.continue' <<<"$output" + [ "$status" -ne 0 ] +} + +@test "internal-allow: ToolSearch still returns passthrough (not allow)" { + run_handler '{"tool_name":"ToolSearch","tool_input":{}}' + [ "$status" -eq 0 ] + run jq -r '.continue' <<<"$output" + [ "$output" = "true" ] + # Must NOT have hookSpecificOutput (not an explicit allow). + run jq -e '.hookSpecificOutput' <<<"$output" + [ "$status" -ne 0 ] +} + +@test "internal-allow: TaskCreate still returns passthrough (not allow)" { + run_handler '{"tool_name":"TaskCreate","tool_input":{}}' + [ "$status" -eq 0 ] + run jq -r '.continue' <<<"$output" + [ "$output" = "true" ] + # Must NOT have hookSpecificOutput (not an explicit allow). + run jq -e '.hookSpecificOutput' <<<"$output" + [ "$status" -ne 0 ] +} + +@test "internal-allow: Agent audit logged with source passthru-internal" { + enable_audit + run_handler '{"tool_name":"Agent","tool_input":{},"tool_use_id":"tAgent"}' + [ "$status" -eq 0 ] + [ -f "$(audit_log)" ] + line="$(head -n1 "$(audit_log)")" + run jq -r '.event' <<<"$line" + [ "$output" = "allow" ] + run jq -r '.source' <<<"$line" + [ "$output" = "passthru-internal" ] + run jq -r '.reason' <<<"$line" + [ "$output" = "passthru internal: Agent" ] + run jq -r '.tool' <<<"$line" + [ "$output" = "Agent" ] + run jq -r '.tool_use_id' <<<"$line" + [ "$output" = "tAgent" ] +} + +@test "internal-allow: Skill audit logged with source passthru-internal" { + enable_audit + run_handler '{"tool_name":"Skill","tool_input":{},"tool_use_id":"tSkill"}' + [ "$status" -eq 0 ] + [ -f "$(audit_log)" ] + line="$(head -n1 "$(audit_log)")" + run jq -r '.event' <<<"$line" + [ "$output" = "allow" ] + run jq -r '.source' <<<"$line" + [ "$output" = "passthru-internal" ] + run jq -r '.reason' <<<"$line" + [ "$output" = "passthru internal: Skill" ] + run jq -r '.tool' <<<"$line" + [ "$output" = "Skill" ] +} + +@test "internal-allow: Glob audit logged with source passthru-internal" { + enable_audit + run_handler '{"tool_name":"Glob","tool_input":{},"tool_use_id":"tGlob"}' + [ "$status" -eq 0 ] + [ -f "$(audit_log)" ] + line="$(head -n1 "$(audit_log)")" + run jq -r '.event' <<<"$line" + [ "$output" = "allow" ] + run jq -r '.source' <<<"$line" + [ "$output" = "passthru-internal" ] + run jq -r '.reason' <<<"$line" + [ "$output" = "passthru internal: Glob" ] + run jq -r '.tool' <<<"$line" + [ "$output" = "Glob" ] +} + +@test "internal-allow: Agent bypasses rule loading (works even with broken rules)" { + # Write invalid JSON to the rule file. Rule loading would fail. + printf 'NOT VALID JSON' > "$USER_ROOT/.claude/passthru.json" + run_handler '{"tool_name":"Agent","tool_input":{}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] +} + +# =========================================================================== +# Task 6: Additional allowed directories +# =========================================================================== + +@test "allowed-dirs: Read tool auto-allowed for file in additional allowed dir" { + # Create a passthru.json with allowed_dirs pointing to an extra dir. + cat > "$USER_ROOT/.claude/passthru.json" < "$USER_ROOT/.claude/passthru.json" < "$USER_ROOT/.claude/passthru.json" < "$USER_ROOT/.claude/passthru.json" < "$USER_ROOT/.claude/passthru.json" < /tmp/out NOT auto-allowed (output redirect bypass)" { + # split_bash_command strips redirections, leaving `cat file` which passes + # is_readonly_command. But the original command writes to /tmp/out. The + # has_output_redirect guard must catch this and skip readonly auto-allow. + run_handler "$(jq -cn --arg c "cat file.txt > /tmp/out" '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: echo ok >> ~/.ssh/config NOT auto-allowed (append redirect bypass)" { + # Append redirect >> is also an output redirect. + run_handler "$(jq -cn --arg c 'echo ok >> ~/.ssh/config' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: echo with quoted > NOT auto-allowed (safety regex rejects >)" { + # The quote-aware tokenizer correctly preserves the segment as + # echo "hello > world", but the echo safety regex categorically + # rejects > in its character class (even inside quotes). This is the + # expected conservative behavior: the user gets an overlay prompt. + run_handler "$(jq -cn --arg c 'echo "hello > world"' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat ../../../etc/passwd NOT auto-allowed (relative path traversal)" { + # Relative paths with ../ can traverse outside cwd. readonly_paths_allowed + # must reject tokens containing ../ patterns. + run_handler "$(jq -cn --arg c 'cat ../../../etc/passwd' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat \"../secret\" NOT auto-allowed (quoted relative traversal)" { + # Even with quotes around the path, ../ traversal should be caught. + run_handler "$(jq -cn --arg c 'cat "../secret"' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat \"/etc/passwd\" NOT auto-allowed (quoted absolute path outside cwd)" { + # Quoted absolute paths like "/etc/passwd" must be detected as absolute + # after quote stripping. Without the fix, the leading " causes the token + # to be treated as a relative path. + run_handler "$(jq -cn --arg c 'cat "/etc/passwd"' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: ls .. NOT auto-allowed (bare dotdot traversal)" { + # A bare ".." without trailing / still escapes cwd. + run_handler "$(jq -cn --arg c 'ls ..' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat ~root/.ssh/id_rsa NOT auto-allowed (tilde user expansion)" { + # ~root expands to the root user home dir. Must not be auto-allowed. + run_handler "$(jq -cn --arg c 'cat ~root/.ssh/id_rsa' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat \"../secret dir/file\" NOT auto-allowed (quoted multi-word traversal)" { + # Multi-word quoted path with traversal. Whitespace splitting breaks the + # quotes but the orphaned-quote strip must still expose the traversal. + run_handler "$(jq -cn --arg c 'cat "../secret dir/file"' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: wc < /etc/passwd NOT auto-allowed (input redirect bypass)" { + # split_bash_command strips input redirections, leaving `wc` which passes + # is_readonly_command. But CC executes the original command which reads + # /etc/passwd via the redirect. The has_redirect guard must catch this. + run_handler "$(jq -cn --arg c 'wc < /etc/passwd' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat ~/.ssh/id_rsa NOT auto-allowed (tilde expansion bypass)" { + # Bash expands ~ to $HOME before execution. The token ~/.ssh/id_rsa does + # not start with / so without the tilde guard it would be treated as a + # relative path inside cwd. + run_handler "$(jq -cn --arg c 'cat ~/.ssh/id_rsa' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: head ~/secrets.txt NOT auto-allowed (tilde home dir)" { + run_handler "$(jq -cn --arg c 'head ~/secrets.txt' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "readonly: cat with herestring NOT auto-allowed (safety regex rejects <)" { + # The quote-aware tokenizer correctly preserves the full segment as + # cat <<< "hello". The cat safety regex rejects < in its character + # class, so the segment fails is_readonly_command. This is the expected + # conservative behavior. The old non-quote-aware stripper corrupted the + # segment to just "cat" which then passed. + run_handler "$(jq -cn --arg c 'cat <<< "hello"' '{tool_name:"Bash",tool_input:{command:$c},cwd:"'"$PROJ_ROOT"'"}')" + [ "$status" -eq 0 ] + json_line="$(printf '%s\n' "$output" | grep -o '{"hookSpecificOutput".*}' | head -n1)" + [ -n "$json_line" ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" + [ "$decision" = "ask" ] +} + +@test "internal-allow: Skill bypasses deny rules (checked before rule matching)" { + # Place a deny rule that would match Skill by tool name regex. + cat > "$USER_ROOT/.claude/passthru.json" <<'JSON' +{ + "version": 2, + "deny": [{"tool": "^Skill$", "reason": "should not fire"}], + "allow": [], + "ask": [] +} +JSON + run_handler '{"tool_name":"Skill","tool_input":{}}' + [ "$status" -eq 0 ] + decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" + [ "$decision" = "allow" ] + reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")" + [ "$reason" = "passthru internal: Skill" ] +} diff --git a/tests/overlay.bats b/tests/overlay.bats index e3bb76a..7ce7612 100644 --- a/tests/overlay.bats +++ b/tests/overlay.bats @@ -262,7 +262,7 @@ restricted_path() { # overlay-propose-rule.sh category coverage # =========================================================================== -@test "propose-rule: Bash command -> ^\\s match" { +@test "propose-rule: Bash command -> fully anchored safe-char match" { run bash "$PROPOSER" "Bash" '{"command":"gh api /repos/foo"}' [ "$status" -eq 0 ] run jq -r '.tool' <<<"$output" @@ -270,14 +270,16 @@ restricted_path() { # Re-run to grab match.command. run bash "$PROPOSER" "Bash" '{"command":"gh api /repos/foo"}' run jq -r '.match.command' <<<"$output" - [ "$output" = "^gh\\s" ] + # Pattern uses CC-style safe character class, no shell operators allowed. + # The \$ inside the character class is valid PCRE (literal dollar). + [ "$output" = '^gh(\s[^<>()\$\x60|{}&;\n\r]*)?$' ] } @test "propose-rule: Bash rm command keeps first token" { run bash "$PROPOSER" "Bash" '{"command":"rm -rf /tmp/foo"}' [ "$status" -eq 0 ] run jq -r '.match.command' <<<"$output" - [ "$output" = "^rm\\s" ] + [ "$output" = '^rm(\s[^<>()\$\x60|{}&;\n\r]*)?$' ] } @test "propose-rule: Read file_path -> parent-dir prefix match" { @@ -358,6 +360,58 @@ restricted_path() { [ "$output" = "^Read$" ] } +# =========================================================================== +# overlay-propose-rule.sh: Bash anchoring validation +# =========================================================================== + +@test "propose-rule: Bash proposed regex matches bare command (ls)" { + run bash "$PROPOSER" "Bash" '{"command":"ls"}' + [ "$status" -eq 0 ] + local pat + pat="$(jq -r '.match.command' <<<"$output")" + # The pattern must match bare "ls" (no args). + run perl -e "exit('ls' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 0 ] + # Must also match "ls -la". + run perl -e "exit('ls -la' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 0 ] + # Must NOT match "ls && evil" (compound command leaking past anchor). + run perl -e "exit('ls && evil' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 1 ] +} + +@test "propose-rule: Bash proposed regex for 'git status' matches bare invocation" { + run bash "$PROPOSER" "Bash" '{"command":"git status"}' + [ "$status" -eq 0 ] + local pat + pat="$(jq -r '.match.command' <<<"$output")" + # Must match bare "git" (just the first word). + run perl -e "exit('git' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 0 ] + # Must match "git status". + run perl -e "exit('git status' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 0 ] + # Must match "git log --oneline". + run perl -e "exit('git log --oneline' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 0 ] +} + +@test "propose-rule: Bash proposed regex for bare command (no args) matches exact command" { + run bash "$PROPOSER" "Bash" '{"command":"whoami"}' + [ "$status" -eq 0 ] + local pat + pat="$(jq -r '.match.command' <<<"$output")" + # Must match bare "whoami". + run perl -e "exit('whoami' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 0 ] + # Must NOT match "whoami; evil". + run perl -e "exit('whoami; evil' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 1 ] + # Must match "whoami --help" (has args after whitespace). + run perl -e "exit('whoami --help' =~ /$pat/ ? 0 : 1)" + [ "$status" -eq 0 ] +} + # =========================================================================== # Concurrency: two overlay invocations with distinct result files # =========================================================================== diff --git a/tests/verifier.bats b/tests/verifier.bats index 4d29a29..15fea01 100644 --- a/tests/verifier.bats +++ b/tests/verifier.bats @@ -572,6 +572,134 @@ EOF [[ "$output" == *"files"* ]] } +# --------------------------------------------------------------------------- +# allowed_dirs validation +# --------------------------------------------------------------------------- + +@test "allowed_dirs: valid array accepted" { + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allowed_dirs": ["/opt/shared", "/data/reference"], + "allow": [{"tool":"Bash","match":{"command":"^ls"}}], + "deny": [], + "ask": [] +} +EOF + run_verify + [ "$status" -eq 0 ] + [[ "$output" == *"[OK]"* ]] +} + +@test "allowed_dirs: passthru.json without allowed_dirs is backward compatible" { + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allow": [{"tool":"Bash","match":{"command":"^ls"}}], + "deny": [], + "ask": [] +} +EOF + run_verify + [ "$status" -eq 0 ] + [[ "$output" == *"[OK]"* ]] +} + +@test "allowed_dirs: non-array type -> error" { + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allowed_dirs": "/opt/shared", + "allow": [], + "deny": [], + "ask": [] +} +EOF + run bash -c "bash '$VERIFY' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"allowed_dirs"* ]] + [[ "$output" == *"array"* ]] +} + +@test "allowed_dirs: non-string entry -> error" { + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allowed_dirs": [123, "/valid"], + "allow": [], + "deny": [], + "ask": [] +} +EOF + run bash -c "bash '$VERIFY' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"allowed_dirs"* ]] + [[ "$output" == *"string"* ]] +} + +@test "allowed_dirs: empty string entry -> error" { + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allowed_dirs": [""], + "allow": [], + "deny": [], + "ask": [] +} +EOF + run bash -c "bash '$VERIFY' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"allowed_dirs"* ]] + [[ "$output" == *"non-empty"* ]] +} + +@test "allowed_dirs: path traversal (/../) -> error" { + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allowed_dirs": ["/opt/../etc/passwd"], + "allow": [], + "deny": [], + "ask": [] +} +EOF + run bash -c "bash '$VERIFY' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"allowed_dirs"* ]] + [[ "$output" == *"traversal"* ]] +} + +@test "allowed_dirs: relative path -> error" { + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 2, + "allowed_dirs": ["relative/path"], + "allow": [], + "deny": [], + "ask": [] +} +EOF + run bash -c "bash '$VERIFY' 2>&1" + [ "$status" -eq 1 ] + [[ "$output" == *"allowed_dirs"* ]] + [[ "$output" == *"absolute path"* ]] +} + +@test "allowed_dirs: v1 file with allowed_dirs is still accepted" { + # allowed_dirs is not version-gated, works with v1 too. + cat > "$USER_ROOT/.claude/passthru.json" <<'EOF' +{ + "version": 1, + "allowed_dirs": ["/opt/shared"], + "allow": [{"tool":"Bash","match":{"command":"^ls"}}], + "deny": [] +} +EOF + run_verify + [ "$status" -eq 0 ] + [[ "$output" == *"[OK]"* ]] +} + @test "report format: plain failure includes severity + file + message" { place "$USER_ROOT/.claude/passthru.json" "invalid-regex.json" run bash -c "bash '$VERIFY' 2>&1"