Skip to content

Commit 88fa364

Browse files
authored
feat(bootstrap): add converters for WebSearch, Read/Edit/Write, and Skill (#8)
1 parent fead4df commit 88fa364

7 files changed

Lines changed: 247 additions & 7 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.2.3",
3+
"version": "0.3.0",
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.2.3",
3+
"version": "0.3.0",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ hooks/
3333
marker. Prints to stderr only when the user has no passthru files yet
3434
AND has importable entries in ~/.claude/settings.json.
3535
scripts/
36-
bootstrap.sh one-time importer from native permissions.allow into passthru.imported.json
36+
bootstrap.sh one-time importer from native permissions.allow into passthru.imported.json.
37+
Supported shapes: Bash(prefix:*) | Bash(exact) | mcp__* | WebFetch(domain:X)
38+
| WebSearch | Read/Edit/Write(path[/**]) | Skill(name). Others -> [WARN] skip.
3739
write-rule.sh atomic write wrapper: backup + append + verify + rollback
3840
verify.sh rule verifier CLI (also invoked by write-rule.sh and /passthru:verify)
3941
log.sh audit-log viewer CLI + sentinel toggle

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ bash .../scripts/bootstrap.sh --write
103103

104104
`--write` mode also runs `scripts/verify.sh --quiet` after writing. If the verifier finds errors, the script restores the pre-write backup and exits non-zero.
105105

106+
**What bootstrap converts.** Six native rule shapes are recognized:
107+
108+
| Native rule | Converted to |
109+
| --- | --- |
110+
| `Bash(<prefix>:*)` | `{"tool": "Bash", "match": {"command": "^<prefix>(\\s|$)"}}` |
111+
| `Bash(<exact command>)` | `{"tool": "Bash", "match": {"command": "^<exact>$"}}` |
112+
| `mcp__server__tool` | `{"tool": "^mcp__server__tool$"}` |
113+
| `WebFetch(domain:x.com)` | `{"tool": "WebFetch", "match": {"url": "^https?://([^/.]+\\.)*x\\.com([/:?#]\|$)"}}` |
114+
| `WebSearch` | `{"tool": "^WebSearch$"}` |
115+
| `Read(<path>)`, `Edit(<path>)`, `Write(<path>)` | `{"tool": "^Read$", "match": {"file_path": "^<path>$"}}` (exact) or `"^<path>(/\|$)"` when the native rule ends in `/**` or `/*` |
116+
| `Skill(<name>)` | `{"tool": "^Skill$", "match": {"skill": "^<name>$"}}` |
117+
118+
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).
119+
106120
Bootstrap writes to dedicated imported files so hand-curated rules in `passthru.json` stay separate:
107121

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

scripts/bootstrap.sh

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
# mcp__server__tool -> {tool:"^mcp__server__tool$"}
1717
# WebFetch(domain:x.com) -> {tool:"WebFetch",
1818
# match:{url:"^https?://([^/.]+\\.)*x\\.com([/:?#]|$)"}}
19+
# WebSearch -> {tool:"^WebSearch$"}
20+
# Read(<path>/**) -> {tool:"^Read$", match:{file_path:"^<escaped>(/|$)"}}
21+
# Read(<path>) -> {tool:"^Read$", match:{file_path:"^<escaped>$"}}
22+
# Edit(<path>[/**]) -> same shape as Read
23+
# Write(<path>[/**]) -> same shape as Read
24+
# Skill(<name>) -> {tool:"^Skill$", match:{skill:"^<escaped>$"}}
1925
#
2026
# Unknown shapes (rules with spaces past the prefix, unrecognized forms) are
2127
# skipped with a warning printed to stderr.
@@ -234,6 +240,60 @@ convert_rule() {
234240
--arg tool "^${escaped}\$" \
235241
'{tool:$tool, reason:"imported from settings"}')"
236242

243+
elif [[ "$raw" == "WebSearch" ]]; then
244+
# Bare WebSearch native rule allows any query. No match block.
245+
json="$(jq -cn \
246+
'{tool:"^WebSearch$", reason:"imported from settings"}')"
247+
248+
elif [[ "$raw" == Read\(*\) ]] || [[ "$raw" == Edit\(*\) ]] || [[ "$raw" == Write\(*\) ]]; then
249+
# File-path-based native rules. Detect the tool name, then inspect the
250+
# inner path for a trailing glob suffix (`/**` or `/*`). A glob suffix
251+
# means "anything under this directory"; without it the path is an
252+
# exact-file match.
253+
local tool_name="${raw%%(*}"
254+
local inner="${raw#${tool_name}(}"
255+
inner="${inner%)}"
256+
if [ -z "$inner" ]; then
257+
printf '[WARN] skipping %s rule with empty path: %s\n' "$tool_name" "$raw" >&2
258+
return 0
259+
fi
260+
local path_re
261+
if [[ "$inner" == */\*\* ]]; then
262+
local prefix="${inner%/\*\*}"
263+
local escaped
264+
escaped="$(regex_escape "$prefix")"
265+
path_re="^${escaped}(/|\$)"
266+
elif [[ "$inner" == */\* ]]; then
267+
local prefix="${inner%/\*}"
268+
local escaped
269+
escaped="$(regex_escape "$prefix")"
270+
path_re="^${escaped}(/|\$)"
271+
else
272+
local escaped
273+
escaped="$(regex_escape "$inner")"
274+
path_re="^${escaped}\$"
275+
fi
276+
json="$(jq -cn \
277+
--arg tool "^${tool_name}\$" \
278+
--arg fp "$path_re" \
279+
'{tool:$tool, match:{file_path:$fp}, reason:"imported from settings"}')"
280+
281+
elif [[ "$raw" == Skill\(*\) ]]; then
282+
# Skill(<name>) -> exact-match the skill identifier.
283+
local name="${raw#Skill(}"
284+
name="${name%)}"
285+
name="${name#"${name%%[![:space:]]*}"}"
286+
name="${name%"${name##*[![:space:]]}"}"
287+
if [ -z "$name" ]; then
288+
printf '[WARN] skipping Skill rule with empty name: %s\n' "$raw" >&2
289+
return 0
290+
fi
291+
local escaped
292+
escaped="$(regex_escape "$name")"
293+
json="$(jq -cn \
294+
--arg skill "^${escaped}\$" \
295+
'{tool:"^Skill$", match:{skill:$skill}, reason:"imported from settings"}')"
296+
237297
else
238298
printf '[WARN] skipping unknown rule format: %s\n' "$raw" >&2
239299
return 0

tests/bootstrap.bats

Lines changed: 162 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

tests/fixtures/settings-with-allow.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
"WebFetch(domain:docs.anthropic.com)",
1313
"WebFetch(domain:x.com)",
1414
"Bash(npm run test:unit)",
15-
"Read(/tmp/foo)",
15+
"WebSearch",
16+
"Read(/private/tmp/ios-webkit-debug-proxy/**)",
17+
"Read(/etc/hosts)",
18+
"Edit(/var/log/app.log)",
19+
"Write(/tmp/out/**)",
20+
"Skill(revdiff)",
1621
"ExactStrangeFormat"
1722
]
1823
}

0 commit comments

Comments
 (0)