Skip to content

Commit 0c8bcf3

Browse files
authored
fix(hook): use CLAUDE_PLUGIN_ROOT for self-allow path detection (#1)
1 parent 6078c6f commit 0c8bcf3

3 files changed

Lines changed: 107 additions & 9 deletions

File tree

docs/plans/completed/20260414-claude-passthru-plugin.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ Implementation note: macOS ships BSD grep without `-P`, so the matcher uses perl
243243
- [x] no match: exit 0 with stdout payload `{"continue": true}` (explicit passthrough - avoids any risk that Claude Code's hook parser logs an error on empty stdout; confirmed safe per plan review against `src/utils/hooks.ts:552-574`).
244244
- [x] handle errors (missing stdin, malformed input JSON, rule validation failure): print diagnostic to stderr, emit `{"continue": true}` on stdout, exit 0 (fail open - never block tool use on plugin bugs).
245245
- [x] emergency disable via sentinel file `~/.claude/passthru.disabled` (presence = disabled). Env vars are not reliably inherited by hook subprocesses; a sentinel file is unambiguous and survives across shell invocations.
246-
- [x] **plugin self-allow:** before running user rules, hardcode an allow for the plugin's own scripts. Slash commands (`/passthru:add`, `/passthru:suggest`, `/passthru:verify`) shell out via `bash ${CLAUDE_PLUGIN_ROOT}/scripts/*.sh`, and those Bash tool calls go through the PreToolUse hook. Without this self-allow, every slash-command invocation would hit the native permission dialog. Regex: `^bash /.*/\.claude/plugins/.*/claude-passthru/scripts/[a-z-]+\.sh( |$)` (tool = `Bash`; escaped `\.claude` so the literal dot matches the on-disk path). This is baked into the hook handler, not the user's config, so it works out-of-the-box across all install paths with zero bootstrap step required. Include a bats test covering the self-allow path.
246+
- [x] **plugin self-allow:** before running user rules, hardcode an allow for the plugin's own scripts. Slash commands (`/passthru:add`, `/passthru:suggest`, `/passthru:verify`) shell out via `bash ${CLAUDE_PLUGIN_ROOT}/scripts/*.sh`, and those Bash tool calls go through the PreToolUse hook. Without this self-allow, every slash-command invocation would hit the native permission dialog. Primary check: match the command against `bash ${CLAUDE_PLUGIN_ROOT}/scripts/<known-name>.sh` using the `$CLAUDE_PLUGIN_ROOT` env var Claude Code sets per hook invocation - this is the authoritative install path and works across all install shapes (cache/<marketplace>/<plugin>/<ver>/, local --plugin-dir, etc). Fallback regex (when `$CLAUDE_PLUGIN_ROOT` is unset, e.g. manual pipe-testing): `^bash /.*/\.claude/plugins/.*(claude-passthru|/passthru/).*/scripts/[a-z-]+\.sh( |$)` (tool = `Bash`; escaped `\.claude` so the literal dot matches the on-disk path; accepts both legacy repo-name and real marketplace/plugin-name shapes). This is baked into the hook handler, not the user's config, so it works out-of-the-box across all install paths with zero bootstrap step required. Initial implementation used only a regex with literal `claude-passthru`, which never matched real installs (fixed post-release in v0.1.1). Include a bats test covering the self-allow path.
247247
- [x] write bats tests (all end-to-end via stdin pipe, no "manual pipe-test" step - that test IS a bats test): deny match -> deny JSON, allow match -> allow JSON, no match -> `{"continue": true}`, deny priority over allow, malformed stdin -> `{"continue": true}` + stderr warning, disabled sentinel present -> `{"continue": true}`, plugin self-allow (synthetic `bash .../claude-passthru/scripts/verify.sh` payload -> allow), real-world Bash `gh api /repos/owner/repo/forks` fixture round-trip.
248248
- [x] + **optional audit log (PreToolUse side):** if sentinel `~/.claude/passthru.audit.enabled` exists, append one JSONL line per decision to `~/.claude/passthru-audit.log`. Audit is OFF by default; enabling is a single `touch`. Line schema:
249249
```json

hooks/handlers/pre-tool-use.sh

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -230,18 +230,58 @@ audit_gc_breadcrumbs
230230

231231
# --- 3. Plugin self-allow --------------------------------------------------
232232
# Hardcoded allow for the plugin's own scripts so slash commands do not hit
233-
# the native permission dialog. Matches e.g.:
234-
# bash /Users/foo/.claude/plugins/cache/umputun/claude-passthru/1.0.0/plugins/claude-passthru/scripts/verify.sh
235-
# We only self-allow Bash calls; other tools would not invoke our scripts.
233+
# the native permission dialog. We only self-allow Bash calls; other tools
234+
# would not invoke our scripts.
235+
#
236+
# Primary check: $CLAUDE_PLUGIN_ROOT (set by Claude Code on every hook
237+
# invocation) is the authoritative install path, so it works across all
238+
# install shapes. Real installs live at
239+
# ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/
240+
# not `.../claude-passthru/...` - the hardcoded repo-name regex we used
241+
# previously never matched real installs and forced every slash command
242+
# into the native permission dialog.
243+
#
244+
# Fallback: a broadened regex that accepts `passthru` as any path segment
245+
# under .claude/plugins/, for the rare case where $CLAUDE_PLUGIN_ROOT is
246+
# unset (manual pipe-testing, legacy harnesses).
236247
if [ "$TOOL_NAME" = "Bash" ]; then
237248
SELF_CMD="$(jq -r '.command // ""' <<<"$TOOL_INPUT" 2>/dev/null)"
238249
if [ -n "$SELF_CMD" ]; then
239-
# Plugin install path on disk is ~/.claude/plugins/... so the leading dot
240-
# in .claude must be escaped for regex (literal dot).
241-
SELF_RE='^bash /.*/\.claude/plugins/.*/claude-passthru/scripts/[a-z-]+\.sh( |$)'
242-
if pcre_match "$SELF_CMD" "$SELF_RE"; then
250+
SELF_ALLOWED=0
251+
SELF_PATTERN=""
252+
253+
if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then
254+
SELF_PREFIX="bash ${CLAUDE_PLUGIN_ROOT}/scripts/"
255+
case "$SELF_CMD" in
256+
"$SELF_PREFIX"*)
257+
stripped="${SELF_CMD#"$SELF_PREFIX"}"
258+
script="${stripped%% *}"
259+
case "$script" in
260+
verify.sh|write-rule.sh|bootstrap.sh|log.sh)
261+
SELF_ALLOWED=1
262+
SELF_PATTERN="CLAUDE_PLUGIN_ROOT=${CLAUDE_PLUGIN_ROOT}"
263+
;;
264+
esac
265+
;;
266+
esac
267+
fi
268+
269+
if [ "$SELF_ALLOWED" -eq 0 ]; then
270+
# Fallback regex for environments where $CLAUDE_PLUGIN_ROOT is not set.
271+
# Accepts either `passthru` or `claude-passthru` anywhere after
272+
# .claude/plugins/ and before /scripts/, covering the real install
273+
# shape (cache/<marketplace>/passthru/<ver>/scripts/) and the legacy
274+
# repo-name shape (.../claude-passthru/scripts/).
275+
SELF_RE='^bash /.*/\.claude/plugins/.*(claude-passthru|/passthru/).*/scripts/[a-z-]+\.sh( |$)'
276+
if pcre_match "$SELF_CMD" "$SELF_RE"; then
277+
SELF_ALLOWED=1
278+
SELF_PATTERN="$SELF_RE"
279+
fi
280+
fi
281+
282+
if [ "$SELF_ALLOWED" -eq 1 ]; then
243283
emit_decision "allow" "passthru self-allow: plugin script"
244-
audit_write_line "allow" "$TOOL_NAME" "passthru self-allow" "" "$SELF_RE" "$TOOL_USE_ID"
284+
audit_write_line "allow" "$TOOL_NAME" "passthru self-allow" "" "$SELF_PATTERN" "$TOOL_USE_ID"
245285
exit 0
246286
fi
247287
fi

tests/hook_handler.bats

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,64 @@ EOF
178178
[ "$cont" = "true" ]
179179
}
180180

181+
@test "handler: plugin self-allow via \$CLAUDE_PLUGIN_ROOT (fake path)" {
182+
# Claude Code sets CLAUDE_PLUGIN_ROOT for every hook invocation. Using this
183+
# env var is the authoritative way to locate the plugin install, so the
184+
# self-allow must work even for totally synthetic paths.
185+
fake_root="$TMP/fakeplugin"
186+
mkdir -p "$fake_root/scripts"
187+
cmd="bash $fake_root/scripts/verify.sh"
188+
payload="$(jq -cn --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
189+
run bash -c "CLAUDE_PLUGIN_ROOT='$fake_root' printf '%s' \"\$1\" | CLAUDE_PLUGIN_ROOT='$fake_root' bash '$HANDLER'" _ "$payload"
190+
[ "$status" -eq 0 ]
191+
decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")"
192+
reason="$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<<"$output")"
193+
[ "$decision" = "allow" ]
194+
[[ "$reason" == *"self-allow"* ]]
195+
}
196+
197+
@test "handler: plugin self-allow via \$CLAUDE_PLUGIN_ROOT for realistic cache install path" {
198+
# Real-world install path shape:
199+
# ~/.claude/plugins/cache/<marketplace>/<plugin-name>/<version>/
200+
# The old regex required literal `claude-passthru` in the path, so this
201+
# shape (which is what users actually get) never matched.
202+
real_root="$USER_ROOT/.claude/plugins/cache/passthru/passthru/0.1.0"
203+
mkdir -p "$real_root/scripts"
204+
cmd="bash $real_root/scripts/verify.sh"
205+
payload="$(jq -cn --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
206+
run bash -c "CLAUDE_PLUGIN_ROOT='$real_root' printf '%s' \"\$1\" | CLAUDE_PLUGIN_ROOT='$real_root' bash '$HANDLER'" _ "$payload"
207+
[ "$status" -eq 0 ]
208+
decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")"
209+
[ "$decision" = "allow" ]
210+
}
211+
212+
@test "handler: plugin self-allow via fallback regex for cache/passthru/passthru/<ver>/ path (no env)" {
213+
# Same realistic install path but without CLAUDE_PLUGIN_ROOT set. The
214+
# fallback regex must still recognise `passthru` as a path segment so
215+
# manual pipe-testing and legacy harnesses continue to work.
216+
cmd='bash /Users/alice/.claude/plugins/cache/passthru/passthru/0.1.0/scripts/verify.sh'
217+
payload="$(jq -cn --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
218+
run_handler "$payload"
219+
[ "$status" -eq 0 ]
220+
decision="$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")"
221+
[ "$decision" = "allow" ]
222+
}
223+
224+
@test "handler: self-allow via \$CLAUDE_PLUGIN_ROOT rejects unknown script names" {
225+
# Defense in depth: even if the prefix matches CLAUDE_PLUGIN_ROOT, only
226+
# the plugin's known scripts are self-allowed. An arbitrary foo.sh living
227+
# under the plugin root should not get a free pass.
228+
fake_root="$TMP/fakeplugin"
229+
mkdir -p "$fake_root/scripts"
230+
cmd="bash $fake_root/scripts/evil.sh"
231+
payload="$(jq -cn --arg c "$cmd" '{tool_name:"Bash",tool_input:{command:$c}}')"
232+
run bash -c "CLAUDE_PLUGIN_ROOT='$fake_root' printf '%s' \"\$1\" | CLAUDE_PLUGIN_ROOT='$fake_root' bash '$HANDLER'" _ "$payload"
233+
[ "$status" -eq 0 ]
234+
# Should fall through to passthrough (no rules -> continue:true).
235+
cont="$(jq -r '.continue' <<<"$output")"
236+
[ "$cont" = "true" ]
237+
}
238+
181239
# ---------------------------------------------------------------------------
182240
# Real-world round-trip
183241
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)