diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 6518035..9751b37 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,6 +1,6 @@ { "name": "passthru", - "version": "0.5.2", + "version": "0.5.3", "description": "Regex-based permission rules for Claude Code via hooks", "owner": { "name": "nnemirovsky" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index dd9aca7..9f5f7b5 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "passthru", - "version": "0.5.2", + "version": "0.5.3", "description": "Regex-based permission rules for Claude Code via hooks", "license": "MIT" } diff --git a/hooks/handlers/pre-tool-use.sh b/hooks/handlers/pre-tool-use.sh index b0dabc4..49dde37 100755 --- a/hooks/handlers/pre-tool-use.sh +++ b/hooks/handlers/pre-tool-use.sh @@ -635,6 +635,7 @@ RULE_JSON_LINE="" if [ -s "$OVERLAY_RESULT" ]; then VERDICT="$(head -n 1 "$OVERLAY_RESULT" 2>/dev/null || true)" RULE_JSON_LINE="$(sed -n '2p' "$OVERLAY_RESULT" 2>/dev/null || true)" + RULE_SCOPE_LINE="$(sed -n '3p' "$OVERLAY_RESULT" 2>/dev/null || true)" fi # Best-effort cleanup of the result file; harmless if the file is already # missing. @@ -654,13 +655,15 @@ case "$VERDICT" in exit 0 ;; yes_always|no_always) - # Persist the proposed rule via write-rule.sh. Scope is always user: - # overlay-dialog.sh does not expose a scope picker today, and the - # proposer never writes one. If a future overlay UX adds per-rule scope - # selection, add scope extraction here then. + # Persist the proposed rule via write-rule.sh. Scope comes from overlay + # dialog line 3 (project or user, defaults to project). target_list="allow" [ "$VERDICT" = "no_always" ] && target_list="deny" - target_scope="user" + target_scope="project" + case "$RULE_SCOPE_LINE" in + user) target_scope="user" ;; + project) target_scope="project" ;; + esac if [ -n "$RULE_JSON_LINE" ] && jq -e '.' >/dev/null 2>&1 <<<"$RULE_JSON_LINE"; then _rule_to_write="$RULE_JSON_LINE" diff --git a/scripts/overlay-dialog.sh b/scripts/overlay-dialog.sh index ed92b5d..e3de5ee 100755 --- a/scripts/overlay-dialog.sh +++ b/scripts/overlay-dialog.sh @@ -74,10 +74,12 @@ write_verdict_once() { write_verdict_always() { # $1 = yes_always | no_always - # $2 = proposed rule JSON (one line, compact) + # $2 = rule JSON (one line, compact) + # $3 = scope (project|user, default project) { printf '%s\n' "$1" printf '%s\n' "$2" + printf '%s\n' "${3:-project}" } > "$RESULT_FILE" 2>/dev/null || true } @@ -154,11 +156,11 @@ if [ -n "$TEST_ANSWER" ]; then ;; yes_always) proposed="$(propose_rule)" - write_verdict_always "yes_always" "$proposed" + write_verdict_always "yes_always" "$proposed" "project" ;; no_always) proposed="$(propose_rule)" - write_verdict_always "no_always" "$proposed" + write_verdict_always "no_always" "$proposed" "project" ;; cancel) : @@ -413,6 +415,9 @@ prop_tool="$(jq -r '.tool // ""' <<<"$proposed" 2>/dev/null)" prop_match_key="$(jq -r '.match // empty | keys[0] // empty' <<<"$proposed" 2>/dev/null)" prop_match_val="$(jq -r '.match // empty | to_entries[0].value // empty' <<<"$proposed" 2>/dev/null)" +# Scope defaults to project. Tab toggles between project/user. +rule_scope="project" + render_rule_editor() { printf '\033[H\033[2J' _render_header "$_display_cwd" @@ -432,6 +437,12 @@ render_rule_editor() { if [ -n "$prop_match_key" ]; then printf " Match %-6s ${GREEN}%s${RESET}\n" "${prop_match_key}:" "$prop_match_val" fi + # Scope toggle: highlight the active scope. + if [ "$rule_scope" = "project" ]; then + printf " Scope: ${REVERSE} project ${RESET} \033[2muser\033[0m \033[2m(Tab to toggle)\033[0m\n" + else + printf " Scope: \033[2mproject\033[0m ${REVERSE} user ${RESET} \033[2m(Tab to toggle)\033[0m\n" + fi printf '\n' } @@ -514,6 +525,15 @@ while true; do prop_tool="$edited_tool" render_confirm_screen ;; + $'\t') + # Tab toggles scope. + if [ "$rule_scope" = "project" ]; then + rule_scope="user" + else + rule_scope="project" + fi + render_confirm_screen + ;; esc|timeout) exec bash "$0" ;; @@ -529,6 +549,6 @@ if [ -n "$prop_match_key" ] && [ -n "$prop_match_val" ]; then else final_rule="$(jq -cn --arg t "$prop_tool" '{tool: $t}')" fi -write_verdict_always "$answer" "$final_rule" +write_verdict_always "$answer" "$final_rule" "$rule_scope" exit 0 diff --git a/tests/hook_handler.bats b/tests/hook_handler.bats index 2c23c8d..49b5f5b 100644 --- a/tests/hook_handler.bats +++ b/tests/hook_handler.bats @@ -847,10 +847,10 @@ STUB # Write verdict + optional rule JSON, then exit 0. The hook reads # \$PASSTHRU_OVERLAY_RESULT_FILE afterwards. local rule_literal="" + local scope_literal="" if [ -n "$rule_json" ]; then - # Pass the rule JSON through printf as a literal string (escape \$ so - # the stub itself does not try to expand it). rule_literal="printf '%s\\n' '$rule_json' >> \"\$PASSTHRU_OVERLAY_RESULT_FILE\"" + scope_literal="printf '%s\\n' 'project' >> \"\$PASSTHRU_OVERLAY_RESULT_FILE\"" fi cat > "$stub_overlay" </dev/null || true printf '%s\\n' '${verdict}' > "\$PASSTHRU_OVERLAY_RESULT_FILE" ${rule_literal} +${scope_literal} # Touch a log file so tests can assert the stub ran. if [ -n "\${PASSTHRU_OVERLAY_STUB_LOG:-}" ]; then { @@ -1183,7 +1184,7 @@ EOF [[ "$reason" == *"overlay"* ]] } -@test "overlay: yes_always verdict writes rule to user/allow AND emits allow" { +@test "overlay: yes_always verdict writes rule to project/allow AND emits allow" { rule_json='{"tool":"Bash","match":{"command":"^gh "},"reason":"overlay yes_always"}' setup_overlay_stub "yes_always" 0 "$rule_json" export TMUX="mock/0" @@ -1204,15 +1205,15 @@ EOF [ -n "$json_line" ] decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" [ "$decision" = "allow" ] - # Rule landed in user-scope passthru.json under allow[]. - [ -f "$USER_ROOT/.claude/passthru.json" ] - match_cmd="$(jq -r '.allow[-1].match.command' "$USER_ROOT/.claude/passthru.json")" + # Rule landed in project-scope passthru.json under allow[] (default scope). + [ -f "$PROJ_ROOT/.claude/passthru.json" ] + match_cmd="$(jq -r '.allow[-1].match.command' "$PROJ_ROOT/.claude/passthru.json")" [ "$match_cmd" = "^gh " ] - tool_val="$(jq -r '.allow[-1].tool' "$USER_ROOT/.claude/passthru.json")" + tool_val="$(jq -r '.allow[-1].tool' "$PROJ_ROOT/.claude/passthru.json")" [ "$tool_val" = "Bash" ] } -@test "overlay: no_always verdict writes rule to user/deny AND emits deny" { +@test "overlay: no_always verdict writes rule to project/deny AND emits deny" { rule_json='{"tool":"Bash","match":{"command":"^curl "},"reason":"overlay no_always"}' setup_overlay_stub "no_always" 0 "$rule_json" export TMUX="mock/0" @@ -1233,8 +1234,8 @@ EOF [ -n "$json_line" ] decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$json_line")" [ "$decision" = "deny" ] - [ -f "$USER_ROOT/.claude/passthru.json" ] - match_cmd="$(jq -r '.deny[-1].match.command' "$USER_ROOT/.claude/passthru.json")" + [ -f "$PROJ_ROOT/.claude/passthru.json" ] + match_cmd="$(jq -r '.deny[-1].match.command' "$PROJ_ROOT/.claude/passthru.json")" [ "$match_cmd" = "^curl " ] }