Skip to content

Commit 5176e72

Browse files
authored
feat(security): bash command security + auto-allow hardening (#22)
* feat(security): bash command security + auto-allow hardening Compound command splitting: add split_bash_command() in Perl that tokenizes Bash commands respecting quotes, subshells, and backticks, then splits by unquoted |, &&, ||, ;, & operators. Redirections are stripped from each segment. For deny rules, ANY segment match denies the whole command. For allow rules, ALL segments must be covered. Read-only auto-allow: mirror CC's readonly command list and safety regex pattern. Simple commands (cat, head, tail, wc, stat, etc.) and custom regex commands (echo, ls, find, cd, jq, etc.) are auto-allowed when all path arguments resolve inside cwd or allowed directories. Internal tool auto-allow: Agent, Skill, and Glob now get explicit allow decisions (permissionDecision: allow) instead of passthrough, preventing CC's native confirmation dialogs. Overlay proposal anchoring: Bash proposals changed from ^<cmd>\s to ^<cmd>(\s[safe-chars]*)?$ using CC's safe character class to block compound operator injection in proposed rules. Additional allowed directories: new optional allowed_dirs field in passthru.json v2. Bootstrap imports additionalAllowedWorkingDirs from CC settings. Path validation for Read/Edit/Write/Grep auto-allow and readonly Bash commands checks cwd plus all allowed dirs. * docs(plan): bash command security + auto-allow hardening * feat(overlay): desktop notification via OSC 777 before overlay prompt * feat(overlay): WebSearch auto-allow + overlay queue lock for concurrent prompts
1 parent eaddf65 commit 5176e72

16 files changed

Lines changed: 3268 additions & 198 deletions

CLAUDE.md

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ hooks/
2323
SessionStart (timeout 5s, no matcher) handlers
2424
common.sh shared library. Functions:
2525
* load_rules / validate_rules (merge + schema-check)
26+
* load_allowed_dirs (read + deduplicate allowed_dirs from all rule files)
2627
* pcre_match / match_rule / find_first_match (rule matching)
28+
* split_bash_command (compound command splitter via perl tokenizer)
29+
* match_all_segments (per-segment matching for compound Bash commands)
30+
* is_readonly_command / readonly_paths_allowed (readonly auto-allow)
31+
* _pm_path_inside_any_allowed (path validation against cwd + allowed dirs)
32+
* build_ordered_allow_ask (document-order allow/ask interleaving)
33+
* permission_mode_auto_allows (CC mode replication with allowed dirs)
2734
* passthru_user_home, passthru_tmpdir, passthru_iso_ts,
2835
passthru_sha256, sanitize_tool_use_id (env + path helpers)
2936
* audit_enabled, audit_log_path, emit_passthrough
@@ -36,7 +43,9 @@ hooks/
3643
Sourced by hook handlers AND by scripts/log.sh,
3744
scripts/verify.sh, scripts/write-rule.sh.
3845
handlers/
39-
pre-tool-use.sh main hook: loads rules, matches, emits allow/deny/passthrough
46+
pre-tool-use.sh main hook: splits compound Bash commands, checks deny per segment,
47+
readonly auto-allow, allow/ask document-order matching (per-segment
48+
for compound commands), mode auto-allow with allowed dirs, overlay
4049
post-tool-use.sh classifies successful native-dialog outcomes into asked_* events.
4150
Delegates to classify_passthrough_outcome in common.sh.
4251
post-tool-use-failure.sh
@@ -71,8 +80,9 @@ scripts/
7180
PASSTHRU_OVERLAY_TIMEOUT bounds the wait (default 60s).
7281
overlay-propose-rule.sh
7382
regex proposer. Takes tool_name + tool_input JSON, emits a rule JSON
74-
targeting one of four categories (Bash prefix, Read/Edit/Write path,
75-
WebFetch URL host, MCP namespace). Unknown tool -> bare ^<Name>$ rule.
83+
targeting one of four categories (Bash fully-anchored with safe char class,
84+
Read/Edit/Write path prefix, WebFetch URL host, MCP namespace).
85+
Unknown tool -> bare ^<Name>$ rule.
7686
overlay-config.sh overlay sentinel toggle + multiplexer detection reporter. Backs
7787
/passthru:overlay.
7888
tests/
@@ -84,6 +94,8 @@ tests/
8494
post_tool_use_failure_hook.bats
8595
PostToolUseFailure handler coverage (permission errors, generic
8696
errored events, timeouts, interrupts, missing breadcrumb).
97+
command_splitting.bats split_bash_command + match_all_segments coverage (compound
98+
command splitting, redirection stripping, quote/subshell handling).
8799
*.bats test suites (one per script or component).
88100
docs/
89101
rule-format.md schema reference
@@ -244,6 +256,90 @@ Lower the PreToolUse timeout only after also lowering
244256
`PASSTHRU_OVERLAY_TIMEOUT` (and only after profiling on target hardware).
245257
Raising it is always safe since the handler fails open on timeout.
246258

259+
## Compound command splitting
260+
261+
For Bash tool calls, the hook splits compound commands into segments before
262+
matching. The splitter (`split_bash_command` in `hooks/common.sh`) uses
263+
inline perl to tokenize the command respecting single quotes, double quotes,
264+
`$()` subshells, backticks, and backslash escaping. It splits on unquoted
265+
`|`, `&&`, `||`, `;`, and `&`, and strips redirections (`>`, `>>`, `<`,
266+
`2>&1`, `N>file`) from each segment.
267+
268+
Matching after splitting follows these rules:
269+
270+
* **Deny**: each segment is checked against deny rules. ANY segment matching
271+
a deny rule causes the whole command to be denied.
272+
* **Allow**: ALL segments must match allow rules (each segment may match a
273+
different rule). If any segment has no match, the command falls through.
274+
* **Ask**: if any segment's first match is an ask rule (and no segment was
275+
denied), the whole command triggers ask.
276+
277+
Fail-safe: parse errors (unterminated quotes, etc.) return the original
278+
command as a single segment, preserving the pre-split behavior.
279+
280+
The splitter always runs for Bash commands. Single commands (no operators)
281+
produce one segment and are matched identically to the previous behavior.
282+
283+
## Readonly Bash command auto-allow
284+
285+
After deny checking (deny always wins) and before allow/ask matching, the
286+
hook checks whether ALL segments of a Bash command are read-only. If so,
287+
the command is auto-allowed without needing explicit allow rules.
288+
289+
The readonly command list mirrors Claude Code's `readOnlyValidation.ts`:
290+
291+
* **Simple commands** (generic safety regex `^<cmd>(?:\s|$)[^<>()$\x60|{}&;\n\r]*$`):
292+
`cal`, `uptime`, `cat`, `head`, `tail`, `wc`, `stat`, `strings`, `hexdump`,
293+
`od`, `nl`, `id`, `uname`, `free`, `df`, `du`, `locale`, `groups`, `nproc`,
294+
`basename`, `dirname`, `realpath`, `cut`, `paste`, `tr`, `column`, `tac`,
295+
`rev`, `fold`, `expand`, `unexpand`, `fmt`, `comm`, `cmp`, `numfmt`,
296+
`readlink`, `diff`, `true`, `false`, `sleep`, `which`, `type`, `expr`,
297+
`test`, `getconf`, `seq`, `tsort`, `pr`
298+
* **Two-word commands** (same safety regex): `docker ps`, `docker images`
299+
* **Custom regex commands**: `echo` (no `$`/backticks), `pwd`, `whoami`,
300+
`ls`, `find` (no `-exec`/`-delete`), `cd`, `jq` (no `-f`/`--from-file`),
301+
`uniq`, `history`, `alias`, `arch`, `node -v`, `python --version`,
302+
`python3 --version`
303+
304+
**Path validation**: after a segment matches a readonly regex, all absolute
305+
path arguments are checked against cwd and allowed dirs via
306+
`_pm_path_inside_any_allowed`. Relative paths are assumed to resolve inside
307+
cwd. This prevents `cat /etc/passwd` from being auto-allowed while allowing
308+
`cat src/main.rs`.
309+
310+
Auto-allowed commands are logged with source `passthru-readonly` and reason
311+
`readonly:<first-word>`.
312+
313+
## Allowed directories
314+
315+
The `allowed_dirs` field in passthru.json extends the trusted directory set
316+
for path-based auto-allow. It affects:
317+
318+
* **Mode auto-allow** (`permission_mode_auto_allows`): Read/Edit/Write/Grep/
319+
Glob/LS tools with paths in any allowed dir are treated the same as files
320+
inside cwd.
321+
* **Readonly auto-allow** (`readonly_paths_allowed`): absolute path arguments
322+
in read-only Bash commands are checked against cwd AND each allowed dir.
323+
324+
`load_allowed_dirs` in `hooks/common.sh` reads `allowed_dirs` from all four
325+
rule files, concatenates, and deduplicates. It is separate from `load_rules`
326+
to preserve the `{version, allow, deny, ask}` contract. Bootstrap imports
327+
Claude Code's `additionalAllowedWorkingDirs` from settings and writes them
328+
to `allowed_dirs` in `passthru.imported.json`.
329+
330+
See `docs/rule-format.md` for the schema and `CONTRIBUTING.md` for guidance
331+
on extending `allowed_dirs` support.
332+
333+
## Internal tool auto-allow
334+
335+
Agent, Skill, and Glob are always auto-allowed with an explicit `allow`
336+
decision (not passthrough). This runs before rule loading (step 3b in
337+
`pre-tool-use.sh`) so it is fast and cannot be affected by broken rule files.
338+
These tools are logged with source `passthru-internal`.
339+
340+
ToolSearch, TaskCreate, and other CC-internal tools remain in the step 7
341+
passthrough list and emit `{"continue": true}`.
342+
247343
## Releases
248344

249345
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:
291387
* 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.
292388
* 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[]`.
293389
* 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`).
390+
* 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".
391+
* 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`.
392+
* 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`.

CONTRIBUTING.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,55 @@ Non-breaking additions (new optional fields, new optional top-level keys) do not
8383
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.
8484
3. Add bats tests in `tests/verifier.bats` covering the success and failure case. Fixtures go in `tests/fixtures/`.
8585

86+
## Extending the readonly command list
87+
88+
The readonly auto-allow list lives in `hooks/common.sh` across three arrays:
89+
90+
* `PASSTHRU_READONLY_COMMANDS` - simple commands using the generic safety regex (`^<cmd>(?:\s|$)[^<>()$\x60|{}&;\n\r]*$`). Add commands here when the generic regex is sufficient (no special flags or subcommands to worry about).
91+
* `PASSTHRU_READONLY_TWO_WORD_COMMANDS` - two-word commands like `docker ps` that use the same generic safety regex with the full two-word prefix.
92+
* `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`).
93+
94+
To add a new readonly command:
95+
96+
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.
97+
2. Add the entry to the appropriate array in `hooks/common.sh`.
98+
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).
99+
4. Run the full test suite: `bats tests/*.bats`.
100+
101+
The list mirrors Claude Code's `readOnlyValidation.ts`. Check CC source when adding commands to keep the two lists in sync.
102+
103+
## Extending the compound command splitter
104+
105+
The compound command splitter (`split_bash_command` in `hooks/common.sh`) uses inline perl to tokenize Bash commands. It handles:
106+
107+
* Single/double quotes, `$()` subshells (nested), backticks, backslash escaping
108+
* Splitting on unquoted `|`, `&&`, `||`, `;`, `&`
109+
* Stripping redirections (`>`, `>>`, `<`, `2>&1`, `N>file`)
110+
111+
The per-segment matching algorithm (`match_all_segments` in `hooks/common.sh`) implements:
112+
113+
* Deny: ANY segment matching a deny rule blocks the whole command
114+
* Allow: ALL segments must match. Different segments may match different rules
115+
* Ask: ANY segment matching ask (with no deny) triggers ask
116+
117+
Tests live in `tests/command_splitting.bats` (splitter unit tests) and `tests/hook_handler.bats` (integration tests for compound matching in the hook).
118+
119+
When modifying the splitter:
120+
121+
1. Add tests in `tests/command_splitting.bats` first.
122+
2. The fail-safe behavior (parse errors return original command as one segment) must be preserved.
123+
3. The perl tokenizer handles all splitting and redirection stripping in a single process for performance.
124+
125+
## Working with `allowed_dirs`
126+
127+
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:
128+
129+
* `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.
130+
* `_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`.
131+
* `permission_mode_auto_allows` accepts an optional 5th parameter (`allowed_dirs_json`). Callers that do not pass it get the old behavior (cwd only).
132+
* `validate_rules` tolerates the `allowed_dirs` key and validates entries: must be an array of non-empty strings, rejects path traversal (`/../`).
133+
* Bootstrap imports `additionalAllowedWorkingDirs` from CC's `settings.json` via `extract_allowed_dirs` and writes them to `allowed_dirs` in `passthru.imported.json`.
134+
86135
## Branch policy
87136

88137
`main` is protected on GitHub. All changes must go through pull requests. Direct pushes to `main` are blocked.

0 commit comments

Comments
 (0)