@@ -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+ }
0 commit comments