@@ -114,7 +114,7 @@ run_boot() {
114114 cp " $FIXTURES /settings-with-allow.json" " $PROJ_ROOT /.claude/settings.local.json"
115115 run bash -c " bash '$BOOTSTRAP ' 2>&1 >/dev/null"
116116 [[ " $output " == * " [WARN]" * ]]
117- [[ " $output " == * " Read(/tmp/foo) " * ]] || [[ " $output " == * " ExactStrangeFormat" * ]]
117+ [[ " $output " == * " ExactStrangeFormat" * ]]
118118}
119119
120120@test " bootstrap: --write persists converted rules to project .imported.json" {
@@ -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 13 entries, 2 skipped (Read, ExactStrangeFormat) -> 11 kept
127- [ " $output " = " 11 " ]
126+ # fixture has 18 entries, 1 skipped (ExactStrangeFormat) -> 17 kept
127+ [ " $output " = " 17 " ]
128128}
129129
130130@test " bootstrap: dry-run output matches --write file content (schema-wise)" {
@@ -465,3 +465,162 @@ EOF
465465 run jq -r ' .allow[0].match.command' " $( user_imported) "
466466 [ " $output " = " ^ls(\\ s|\$ )" ]
467467}
468+
469+ # ---------------------------------------------------------------------------
470+ # WebSearch converter
471+ # ---------------------------------------------------------------------------
472+
473+ @test " bootstrap: WebSearch converts to ^WebSearch$ tool rule with no match block" {
474+ printf ' {"permissions":{"allow":["WebSearch"]}}\n' > " $USER_ROOT /.claude/settings.json"
475+ run_boot --user-only --write
476+ [ " $status " -eq 0 ]
477+ run jq -r ' .allow | length' " $( user_imported) "
478+ [ " $output " = " 1" ]
479+ run jq -r ' .allow[0].tool' " $( user_imported) "
480+ [ " $output " = " ^WebSearch\$ " ]
481+ # No match block should be present.
482+ run jq -r ' .allow[0] | has("match")' " $( user_imported) "
483+ [ " $output " = " false" ]
484+ }
485+
486+ # ---------------------------------------------------------------------------
487+ # Read/Edit/Write file_path converters
488+ # ---------------------------------------------------------------------------
489+
490+ @test " bootstrap: Read(/tmp/foo/**) converts to Read tool with prefix regex" {
491+ printf ' {"permissions":{"allow":["Read(/tmp/foo/**)"]}}\n' > " $USER_ROOT /.claude/settings.json"
492+ run_boot --user-only --write
493+ [ " $status " -eq 0 ]
494+ run jq -r ' .allow[0].tool' " $( user_imported) "
495+ [ " $output " = " ^Read\$ " ]
496+ pat=" $( jq -r ' .allow[0].match.file_path' " $( user_imported) " ) "
497+ # Must match /tmp/foo and everything under it.
498+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /tmp/foo' " $pat "
499+ [ " $status " -eq 0 ]
500+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /tmp/foo/bar' " $pat "
501+ [ " $status " -eq 0 ]
502+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /tmp/foo/bar/baz' " $pat "
503+ [ " $status " -eq 0 ]
504+ # Must NOT match /tmp/foobar (sibling with same prefix).
505+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /tmp/foobar' " $pat "
506+ [ " $status " -ne 0 ]
507+ }
508+
509+ @test " bootstrap: Read(/etc/hosts) converts to exact file_path match" {
510+ printf ' {"permissions":{"allow":["Read(/etc/hosts)"]}}\n' > " $USER_ROOT /.claude/settings.json"
511+ run_boot --user-only --write
512+ [ " $status " -eq 0 ]
513+ run jq -r ' .allow[0].tool' " $( user_imported) "
514+ [ " $output " = " ^Read\$ " ]
515+ pat=" $( jq -r ' .allow[0].match.file_path' " $( user_imported) " ) "
516+ # Must match exactly.
517+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /etc/hosts' " $pat "
518+ [ " $status " -eq 0 ]
519+ # Must NOT match /etc/hosts.bak or subpaths.
520+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /etc/hosts.bak' " $pat "
521+ [ " $status " -ne 0 ]
522+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /etc/hosts/sub' " $pat "
523+ [ " $status " -ne 0 ]
524+ }
525+
526+ @test " bootstrap: Edit(/path) converts with same file_path shape as Read" {
527+ printf ' {"permissions":{"allow":["Edit(/var/log/app.log)"]}}\n' > " $USER_ROOT /.claude/settings.json"
528+ run_boot --user-only --write
529+ [ " $status " -eq 0 ]
530+ run jq -r ' .allow[0].tool' " $( user_imported) "
531+ [ " $output " = " ^Edit\$ " ]
532+ pat=" $( jq -r ' .allow[0].match.file_path' " $( user_imported) " ) "
533+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /var/log/app.log' " $pat "
534+ [ " $status " -eq 0 ]
535+ }
536+
537+ @test " bootstrap: Write(/path/**) converts to Write prefix regex" {
538+ printf ' {"permissions":{"allow":["Write(/tmp/out/**)"]}}\n' > " $USER_ROOT /.claude/settings.json"
539+ run_boot --user-only --write
540+ [ " $status " -eq 0 ]
541+ run jq -r ' .allow[0].tool' " $( user_imported) "
542+ [ " $output " = " ^Write\$ " ]
543+ pat=" $( jq -r ' .allow[0].match.file_path' " $( user_imported) " ) "
544+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /tmp/out/file.txt' " $pat "
545+ [ " $status " -eq 0 ]
546+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /tmp/output' " $pat "
547+ [ " $status " -ne 0 ]
548+ }
549+
550+ @test " bootstrap: Read path with regex metachars (dots, asterisks) is escaped" {
551+ # A literal path with regex metacharacters (dot, asterisk) must be escaped
552+ # so the resulting pattern matches only the literal path and does not
553+ # admit anything a greedy reader would allow.
554+ printf ' {"permissions":{"allow":["Read(/a.b/c*/**)"]}}\n' > " $USER_ROOT /.claude/settings.json"
555+ run_boot --user-only --write
556+ [ " $status " -eq 0 ]
557+ pat=" $( jq -r ' .allow[0].match.file_path' " $( user_imported) " ) "
558+ # Must match the literal path.
559+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /a.b/c*' " $pat "
560+ [ " $status " -eq 0 ]
561+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /a.b/c*/file' " $pat "
562+ [ " $status " -eq 0 ]
563+ # Must NOT match paths that only pass because dots and asterisks were
564+ # treated as regex operators.
565+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /aXb/cZZ' " $pat "
566+ [ " $status " -ne 0 ]
567+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' /a/b/c/file' " $pat "
568+ [ " $status " -ne 0 ]
569+ }
570+
571+ # ---------------------------------------------------------------------------
572+ # Skill converter
573+ # ---------------------------------------------------------------------------
574+
575+ @test " bootstrap: Skill(revdiff) converts to ^Skill$ with {skill: ^revdiff$}" {
576+ printf ' {"permissions":{"allow":["Skill(revdiff)"]}}\n' > " $USER_ROOT /.claude/settings.json"
577+ run_boot --user-only --write
578+ [ " $status " -eq 0 ]
579+ run jq -r ' .allow[0].tool' " $( user_imported) "
580+ [ " $output " = " ^Skill\$ " ]
581+ run jq -r ' .allow[0].match.skill' " $( user_imported) "
582+ [ " $output " = " ^revdiff\$ " ]
583+ }
584+
585+ @test " bootstrap: Skill name with metachars is escaped" {
586+ printf ' {"permissions":{"allow":["Skill(my.skill*)"]}}\n' > " $USER_ROOT /.claude/settings.json"
587+ run_boot --user-only --write
588+ [ " $status " -eq 0 ]
589+ pat=" $( jq -r ' .allow[0].match.skill' " $( user_imported) " ) "
590+ # Must match literal name only.
591+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' my.skill*' " $pat "
592+ [ " $status " -eq 0 ]
593+ # Must NOT match a name that only passes because . or * were unescaped.
594+ run perl -e ' exit(1) unless $ARGV[0] =~ /$ARGV[1]/' ' myXskill' " $pat "
595+ [ " $status " -ne 0 ]
596+ }
597+
598+ # ---------------------------------------------------------------------------
599+ # Regressions: Bash, MCP, WebFetch still convert with the new converters added.
600+ # ---------------------------------------------------------------------------
601+
602+ @test " bootstrap: Bash/MCP/WebFetch conversions still work alongside new converters" {
603+ cat > " $USER_ROOT /.claude/settings.json" << 'EOF '
604+ {"permissions":{"allow":[
605+ "Bash(git status:*)",
606+ "Bash(echo hello)",
607+ "mcp__context7__query-docs",
608+ "WebFetch(domain:docs.anthropic.com)",
609+ "WebSearch",
610+ "Read(/tmp/foo/**)",
611+ "Skill(revdiff)"
612+ ]}}
613+ EOF
614+ run_boot --user-only --write
615+ [ " $status " -eq 0 ]
616+ run jq -r ' .allow | length' " $( user_imported) "
617+ [ " $output " = " 7" ]
618+ # Spot-check each shape.
619+ run jq -r ' [.allow[].tool] | sort | join(",")' " $( user_imported) "
620+ [[ " $output " == * ' Bash' * ]]
621+ [[ " $output " == * ' WebFetch' * ]]
622+ [[ " $output " == * ' ^WebSearch$' * ]]
623+ [[ " $output " == * ' ^Read$' * ]]
624+ [[ " $output " == * ' ^Skill$' * ]]
625+ [[ " $output " == * ' ^mcp__context7__query\-docs$' * ]]
626+ }
0 commit comments