Skip to content

Commit 3060e2b

Browse files
authored
fix(bootstrap): relax Read/Edit/Write path validation to match Claude Code (#9)
1 parent 88fa364 commit 3060e2b

6 files changed

Lines changed: 265 additions & 9 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ bash .../scripts/bootstrap.sh --write
117117

118118
Regex metacharacters in the original path/prefix/name are escaped so the converted pattern matches literally. Anything that does not match one of the shapes above is skipped with a `[WARN]` line on stderr (for example, custom MCP tool patterns that do not start with `mcp__`, or a `WebFetch(...)` with a non-`domain:` argument).
119119

120+
For `Read`, `Edit`, and `Write`, path acceptance mirrors Claude Code's own rules (`src/utils/permissions/pathValidation.ts`): redundant slash runs (`//foo`, `///foo/bar`) are collapsed to a single slash, `~/...` expands to `$HOME/...`, and paths with spaces or deep nesting are accepted. Only the shapes Claude Code itself rejects are skipped with a `[WARN]`:
121+
122+
* shell / env expansion: `$VAR`, `${VAR}`, `$(cmd)`, `%VAR%`
123+
* zsh equals expansion: leading `=` (e.g. `=cmd`)
124+
* tilde variants other than `~/`: `~user`, `~+`, `~-`, `~N`
125+
* UNC paths: leading `\\server\share`
126+
120127
Bootstrap writes to dedicated imported files so hand-curated rules in `passthru.json` stay separate:
121128

122129
* `~/.claude/passthru.imported.json` (user scope)

scripts/bootstrap.sh

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
# Unknown shapes (rules with spaces past the prefix, unrecognized forms) are
2727
# skipped with a warning printed to stderr.
2828
#
29+
# Read/Edit/Write paths that use shell expansion (`$VAR`, `${VAR}`, `$(cmd)`,
30+
# `%VAR%`), zsh equals expansion (`=cmd`), tilde variants other than `~/`
31+
# (`~user`, `~+`, `~-`), or UNC (`\\server\share`) are skipped. This mirrors
32+
# Claude Code's own path validation rules.
33+
#
2934
# Flags:
3035
# --write actually write; default is a dry-run that prints JSON to stdout.
3136
# --user-only only scan/import user scope.
@@ -250,27 +255,90 @@ convert_rule() {
250255
# inner path for a trailing glob suffix (`/**` or `/*`). A glob suffix
251256
# means "anything under this directory"; without it the path is an
252257
# exact-file match.
258+
#
259+
# Acceptance mirrors Claude Code's actual path validation
260+
# (src/utils/permissions/pathValidation.ts). Only the shapes that Claude
261+
# Code itself rejects are skipped here:
262+
# * Unix shell expansion: `$VAR`, `${VAR}`, `$(cmd)`
263+
# * Windows env expansion: `%VAR%`
264+
# * Zsh equals expansion: leading `=`
265+
# * Tilde variants other than `~/`: `~user`, `~+`, `~-`, bare `~`
266+
# * UNC paths: leading `\\server\share`
267+
# Everything else (including leading `//`, spaces, deeply nested paths)
268+
# is accepted. Redundant `/` runs are normalized to a single `/` to
269+
# mirror Node's `path.resolve()` output, which is what Claude Code
270+
# ultimately compares against in the hook payload.
253271
local tool_name="${raw%%(*}"
254272
local inner="${raw#${tool_name}(}"
255273
inner="${inner%)}"
256274
if [ -z "$inner" ]; then
257275
printf '[WARN] skipping %s rule with empty path: %s\n' "$tool_name" "$raw" >&2
258276
return 0
259277
fi
278+
279+
# Shell / env expansion: `$`, `${...}`, `$(...)` anywhere in the path,
280+
# or any `%...%` windows-style reference. Claude Code does not resolve
281+
# these, so neither do we.
282+
if [[ "$inner" == *'$'* ]] || [[ "$inner" == *'%'* ]]; then
283+
printf '[WARN] skipping %s(%s): shell expansion syntax not supported\n' \
284+
"$tool_name" "$inner" >&2
285+
return 0
286+
fi
287+
288+
# Zsh equals expansion: leading `=cmd` resolves against PATH, not
289+
# handled by Claude Code.
290+
if [[ "$inner" == =* ]]; then
291+
printf '[WARN] skipping %s(%s): shell expansion syntax not supported\n' \
292+
"$tool_name" "$inner" >&2
293+
return 0
294+
fi
295+
296+
# UNC paths. `containsVulnerableUncPath` in Claude Code rejects these.
297+
if [[ "$inner" == '\\'* ]]; then
298+
printf '[WARN] skipping %s(%s): UNC path not supported\n' \
299+
"$tool_name" "$inner" >&2
300+
return 0
301+
fi
302+
303+
# Tilde variants. `~/...` and bare `~` are the only accepted forms -
304+
# other variants (`~user`, `~+`, `~-`, `~N`) are left as literals by
305+
# Claude Code and never match a resolved file_path in a hook payload.
306+
# Convert `~/` and bare `~` to the current `$HOME` so the regex has a
307+
# chance to match the already-resolved path the hook sees.
308+
# NOTE: the `'~/'*` pattern below is a literal prefix match on the rule
309+
# string (left-hand side), not a path we want tilde-expanded. ShellCheck's
310+
# SC2088 flags any quoted `~` as "use $HOME", but here the tilde is
311+
# intentional because we match the rule as the user wrote it.
312+
if [[ "$inner" == '~' ]]; then
313+
inner="$HOME"
314+
elif [[ "${inner:0:2}" == "~/" ]]; then
315+
inner="$HOME/${inner:2}"
316+
elif [[ "${inner:0:1}" == "~" ]]; then
317+
printf '[WARN] skipping %s(%s): tilde variant not supported\n' \
318+
"$tool_name" "$inner" >&2
319+
return 0
320+
fi
321+
322+
# Collapse runs of `/` to a single `/`, matching Node's `path.resolve()`.
323+
# Used on both the prefix form (before the trailing `/**`|`/*` is
324+
# stripped) and the exact form.
325+
local normalized
326+
normalized="$(printf '%s' "$inner" | perl -pe 's|/{2,}|/|g')"
327+
260328
local path_re
261-
if [[ "$inner" == */\*\* ]]; then
262-
local prefix="${inner%/\*\*}"
329+
if [[ "$normalized" == */\*\* ]]; then
330+
local prefix="${normalized%/\*\*}"
263331
local escaped
264332
escaped="$(regex_escape "$prefix")"
265333
path_re="^${escaped}(/|\$)"
266-
elif [[ "$inner" == */\* ]]; then
267-
local prefix="${inner%/\*}"
334+
elif [[ "$normalized" == */\* ]]; then
335+
local prefix="${normalized%/\*}"
268336
local escaped
269337
escaped="$(regex_escape "$prefix")"
270338
path_re="^${escaped}(/|\$)"
271339
else
272340
local escaped
273-
escaped="$(regex_escape "$inner")"
341+
escaped="$(regex_escape "$normalized")"
274342
path_re="^${escaped}\$"
275343
fi
276344
json="$(jq -cn \

tests/bootstrap.bats

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,8 @@ run_boot() {
123123
[ "$status" -eq 0 ]
124124
[ -f "$(proj_imported)" ]
125125
run jq -r '.allow | length' "$(proj_imported)"
126-
# fixture has 18 entries, 1 skipped (ExactStrangeFormat) -> 17 kept
127-
[ "$output" = "17" ]
126+
# fixture has 19 entries, 1 skipped (ExactStrangeFormat) -> 18 kept
127+
[ "$output" = "18" ]
128128
}
129129

130130
@test "bootstrap: dry-run output matches --write file content (schema-wise)" {
@@ -624,3 +624,183 @@ EOF
624624
[[ "$output" == *'^Skill$'* ]]
625625
[[ "$output" == *'^mcp__context7__query\-docs$'* ]]
626626
}
627+
628+
# ---------------------------------------------------------------------------
629+
# Read/Edit/Write path normalization: leading `//` collapses to a single `/`,
630+
# matching Node's `path.resolve()` behaviour in Claude Code. Previously the
631+
# converter treated `Read(//...)` as "unusual" and skipped it. The hook
632+
# payload always carries a resolved, single-slash path, so a rule generated
633+
# from a `//...` entry would never match unless we normalize at import time.
634+
# ---------------------------------------------------------------------------
635+
636+
@test "bootstrap: Read(//private/tmp/foo/**) normalizes to single leading slash" {
637+
printf '{"permissions":{"allow":["Read(//private/tmp/foo/**)"]}}\n' \
638+
> "$USER_ROOT/.claude/settings.json"
639+
run_boot --user-only --write
640+
[ "$status" -eq 0 ]
641+
run jq -r '.allow | length' "$(user_imported)"
642+
[ "$output" = "1" ]
643+
pat="$(jq -r '.allow[0].match.file_path' "$(user_imported)")"
644+
# The resulting regex must NOT carry two leading slashes (`regex_escape`
645+
# would emit `\/\/` for the unnormalized form).
646+
[[ "$pat" != *'\/\/'* ]]
647+
# Must match the single-slash form Claude Code passes to the hook after
648+
# Node's `path.resolve()` normalization, and everything under it.
649+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' '/private/tmp/foo' "$pat"
650+
[ "$status" -eq 0 ]
651+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' '/private/tmp/foo/bar' "$pat"
652+
[ "$status" -eq 0 ]
653+
# Must NOT match a sibling that only shares the prefix literally.
654+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' '/private/tmp/foobar' "$pat"
655+
[ "$status" -ne 0 ]
656+
}
657+
658+
@test "bootstrap: Read(///deeply/nested/**) collapses all redundant slashes" {
659+
printf '{"permissions":{"allow":["Read(///deeply/nested/**)"]}}\n' \
660+
> "$USER_ROOT/.claude/settings.json"
661+
run_boot --user-only --write
662+
[ "$status" -eq 0 ]
663+
pat="$(jq -r '.allow[0].match.file_path' "$(user_imported)")"
664+
# No repeated escaped slashes should remain - normalization happens before
665+
# the path gets escaped.
666+
[[ "$pat" != *'\/\/'* ]]
667+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' '/deeply/nested/x' "$pat"
668+
[ "$status" -eq 0 ]
669+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' '/deeply/nested' "$pat"
670+
[ "$status" -eq 0 ]
671+
}
672+
673+
@test "bootstrap: Read path with embedded double slash is normalized" {
674+
printf '{"permissions":{"allow":["Read(/a//b/c)"]}}\n' \
675+
> "$USER_ROOT/.claude/settings.json"
676+
run_boot --user-only --write
677+
[ "$status" -eq 0 ]
678+
pat="$(jq -r '.allow[0].match.file_path' "$(user_imported)")"
679+
# Embedded `//` collapses to single `/` before regex escaping.
680+
[[ "$pat" != *'\/\/'* ]]
681+
# The resulting (exact-form) regex matches the single-slash literal path.
682+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' '/a/b/c' "$pat"
683+
[ "$status" -eq 0 ]
684+
}
685+
686+
# ---------------------------------------------------------------------------
687+
# Read/Edit/Write skip cases that mirror Claude Code's own rejection rules.
688+
# ---------------------------------------------------------------------------
689+
690+
@test "bootstrap: Read(~user/.ssh) skipped (tilde variant not supported)" {
691+
printf '{"permissions":{"allow":["Read(~user/.ssh)"]}}\n' \
692+
> "$USER_ROOT/.claude/settings.json"
693+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
694+
[[ "$output" == *"tilde variant not supported"* ]]
695+
[[ "$output" == *"~user/.ssh"* ]]
696+
# --write should produce an empty allow list (rule was skipped).
697+
run_boot --user-only --write
698+
[ "$status" -eq 0 ]
699+
run jq -r '.allow | length' "$(user_imported)"
700+
[ "$output" = "0" ]
701+
}
702+
703+
@test "bootstrap: Read(~+) and Read(~-) skipped (tilde variants)" {
704+
printf '{"permissions":{"allow":["Read(~+)","Read(~-)"]}}\n' \
705+
> "$USER_ROOT/.claude/settings.json"
706+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
707+
[[ "$output" == *"tilde variant not supported"* ]]
708+
run_boot --user-only --write
709+
[ "$status" -eq 0 ]
710+
run jq -r '.allow | length' "$(user_imported)"
711+
[ "$output" = "0" ]
712+
}
713+
714+
@test "bootstrap: Read(~/foo) expands to \$HOME/foo (bare ~/ is accepted)" {
715+
printf '{"permissions":{"allow":["Read(~/foo/**)"]}}\n' \
716+
> "$USER_ROOT/.claude/settings.json"
717+
run_boot --user-only --write
718+
[ "$status" -eq 0 ]
719+
run jq -r '.allow | length' "$(user_imported)"
720+
[ "$output" = "1" ]
721+
pat="$(jq -r '.allow[0].match.file_path' "$(user_imported)")"
722+
# The resolved path should start with the current HOME and end with the
723+
# prefix-form suffix.
724+
[[ "$pat" == "^"*"/foo(/|\$)" ]]
725+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' "$HOME/foo" "$pat"
726+
[ "$status" -eq 0 ]
727+
}
728+
729+
@test "bootstrap: Read(\$HOME/x) skipped (shell expansion syntax)" {
730+
printf '{"permissions":{"allow":["Read($HOME/x)"]}}\n' \
731+
> "$USER_ROOT/.claude/settings.json"
732+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
733+
[[ "$output" == *"shell expansion syntax not supported"* ]]
734+
run_boot --user-only --write
735+
[ "$status" -eq 0 ]
736+
run jq -r '.allow | length' "$(user_imported)"
737+
[ "$output" = "0" ]
738+
}
739+
740+
@test "bootstrap: Read(\${HOME}/x) and Read(\$(pwd)/x) skipped (shell expansion)" {
741+
cat > "$USER_ROOT/.claude/settings.json" <<'EOF'
742+
{"permissions":{"allow":["Read(${HOME}/x)","Read($(pwd)/x)"]}}
743+
EOF
744+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
745+
[[ "$output" == *"shell expansion syntax not supported"* ]]
746+
run_boot --user-only --write
747+
[ "$status" -eq 0 ]
748+
run jq -r '.allow | length' "$(user_imported)"
749+
[ "$output" = "0" ]
750+
}
751+
752+
@test "bootstrap: Read(%APPDATA%) skipped (windows env expansion)" {
753+
printf '{"permissions":{"allow":["Read(%%APPDATA%%/foo)"]}}\n' \
754+
> "$USER_ROOT/.claude/settings.json"
755+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
756+
[[ "$output" == *"shell expansion syntax not supported"* ]]
757+
}
758+
759+
@test "bootstrap: Read(=cmd) skipped (zsh equals expansion)" {
760+
printf '{"permissions":{"allow":["Read(=cmd)"]}}\n' \
761+
> "$USER_ROOT/.claude/settings.json"
762+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
763+
[[ "$output" == *"shell expansion syntax not supported"* ]]
764+
}
765+
766+
@test "bootstrap: Read(\\\\server\\share) skipped (UNC path)" {
767+
# Feed a literal two-backslash `\\server\share` via a file (bash printf is
768+
# too fiddly for doubled backslashes in JSON).
769+
cat > "$USER_ROOT/.claude/settings.json" <<'EOF'
770+
{"permissions":{"allow":["Read(\\\\server\\share)"]}}
771+
EOF
772+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
773+
[[ "$output" == *"UNC path not supported"* ]]
774+
run_boot --user-only --write
775+
[ "$status" -eq 0 ]
776+
run jq -r '.allow | length' "$(user_imported)"
777+
[ "$output" = "0" ]
778+
}
779+
780+
@test "bootstrap: Edit and Write honor the same skip rules as Read" {
781+
cat > "$USER_ROOT/.claude/settings.json" <<'EOF'
782+
{"permissions":{"allow":["Edit($HOME/x)","Write(=foo)"]}}
783+
EOF
784+
run bash -c "bash '$BOOTSTRAP' --user-only 2>&1 >/dev/null"
785+
[[ "$output" == *"Edit("* ]]
786+
[[ "$output" == *"Write("* ]]
787+
[[ "$output" == *"shell expansion syntax not supported"* ]]
788+
run_boot --user-only --write
789+
[ "$status" -eq 0 ]
790+
run jq -r '.allow | length' "$(user_imported)"
791+
[ "$output" = "0" ]
792+
}
793+
794+
@test "bootstrap: Read(/path/with spaces/**) accepted (spaces are not a reject reason)" {
795+
# Claude Code accepts paths with spaces; only shell-expansion, tilde
796+
# variants, and UNC are rejected. Verify the converter keeps this entry.
797+
printf '{"permissions":{"allow":["Read(/path/with spaces/**)"]}}\n' \
798+
> "$USER_ROOT/.claude/settings.json"
799+
run_boot --user-only --write
800+
[ "$status" -eq 0 ]
801+
run jq -r '.allow | length' "$(user_imported)"
802+
[ "$output" = "1" ]
803+
pat="$(jq -r '.allow[0].match.file_path' "$(user_imported)")"
804+
run perl -e 'exit(1) unless $ARGV[0] =~ /$ARGV[1]/' '/path/with spaces/file' "$pat"
805+
[ "$status" -eq 0 ]
806+
}

tests/fixtures/settings-with-allow.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"Bash(npm run test:unit)",
1515
"WebSearch",
1616
"Read(/private/tmp/ios-webkit-debug-proxy/**)",
17+
"Read(//private/tmp/double-slash-normalized/**)",
1718
"Read(/etc/hosts)",
1819
"Edit(/var/log/app.log)",
1920
"Write(/tmp/out/**)",

0 commit comments

Comments
 (0)