Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
13 changes: 8 additions & 5 deletions hooks/handlers/pre-tool-use.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"

Expand Down
28 changes: 24 additions & 4 deletions scripts/overlay-dialog.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
:
Expand Down Expand Up @@ -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"
Expand All @@ -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'
}

Expand Down Expand Up @@ -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"
;;
Expand All @@ -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
21 changes: 11 additions & 10 deletions tests/hook_handler.bats
Original file line number Diff line number Diff line change
Expand Up @@ -847,17 +847,18 @@ 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" <<STUB
#!/usr/bin/env bash
: "\${PASSTHRU_OVERLAY_RESULT_FILE:?}"
mkdir -p "\$(dirname "\$PASSTHRU_OVERLAY_RESULT_FILE")" 2>/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
{
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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 " ]
}

Expand Down
Loading