#!/bin/bash # # Derived from ShadowTerm's shadowterm-hook.sh (hooks v2026.09.08). Same # payload and wire format; only branding and file names changed: # shadowterm-hook.sh -> shadowntfy-hook.sh # shadowterm-notify.sh -> shadowntfy-notify.sh # shadowterm.conf -> shadowntfy.conf # shadowterm-hook.log -> shadowntfy-hook.log # The SHADOWTERM_* variable names inside the conf are unchanged on purpose. # # ShadowNtfy Claude Code Hook - Permission Requests, AskUserQuestion, # PermissionDenied retry, MCP Elicitation # Version: 2026.09.08 # Source of truth; installed by shadowntfyapp.pages.dev/install.sh # # Claude Code events handled: # PermissionRequest → phone allow/deny (decision.behavior), optionally # with updatedPermissions (always-allow / session mode) # PreToolUse/AskUserQuestion → phone/mirror/console modes # (permissionDecision + updatedInput.answers) # PermissionDenied → phone "Retry" (hookSpecificOutput.retry) # Elicitation → phone/mirror/console modes # (hookSpecificOutput.action + content) # # Wire compatibility: every ntfy payload keeps a legacy `event_type` the # app has always understood and adds `event_kind` with the precise kind. # Older app builds ignore the extra keys; older hook scripts ignore the # extra response keys. See docs/superpowers/specs/2026-09-07-claude-code- # hooks-phase-b-design.md in the ShadowTerm repo. # set -e # Load configuration if [ -f "$HOME/.claude/hooks/shadowntfy.conf" ]; then # shellcheck source=/dev/null source "$HOME/.claude/hooks/shadowntfy.conf" fi NTFY_URL="${SHADOWTERM_NTFY_URL:-http://localhost:8080}" TOPIC="${SHADOWTERM_TOPIC:-claude-code}" RESPONSE_TOPIC="${SHADOWTERM_RESPONSE_TOPIC:-${TOPIC}-responses}" TIMEOUT="${SHADOWTERM_TIMEOUT:-300}" DENIED_TIMEOUT="${SHADOWTERM_DENIED_TIMEOUT:-45}" AUTH_TOKEN="${SHADOWTERM_AUTH_TOKEN:-}" # Never publish to the public ntfy.sh service. ShadowNtfy only talks to a # self-hosted server; the installer refuses ntfy.sh and this covers a # hand-edited conf. case "$(printf '%s' "$NTFY_URL" | tr '[:upper:]' '[:lower:]' | sed -E 's#^[[:space:]]+##; s#[[:space:]]+$##; s#^[a-z][a-z0-9+.-]*://##; s#^[^@/]*@##; s#[/:?\#].*$##; s#\.$##')" in ntfy.sh|www.ntfy.sh|*%*) exit 0 ;; esac HOOKS_VERSION="${SHADOWTERM_HOOKS_VERSION:-2026.09.08}" # Per-event toggles. Defaults match the original behavior so an old # conf without these keys still works: permissions on, questions on # (mirror), everything else off. ASKUQ_MODE controls how # AskUserQuestion and MCP elicitations are handled: console / mirror / # phone. NOTIFY_PERMISSIONS="${SHADOWTERM_NOTIFY_PERMISSIONS:-1}" NOTIFY_QUESTIONS="${SHADOWTERM_NOTIFY_QUESTIONS:-1}" NOTIFY_PERMISSION_DENIED="${SHADOWTERM_NOTIFY_PERMISSION_DENIED:-1}" ASKUQ_MODE="${SHADOWTERM_ASKUQ_MODE:-mirror}" # `answer` is the legacy name (pre-mode-split); treat as `phone`. [ "$ASKUQ_MODE" = "answer" ] && ASKUQ_MODE="phone" # Bearer auth for every ntfy call. The response-topic polls need it just # as much as the POST does: on a token-protected ntfy server an # unauthenticated GET returns 403 and the hook waits out the full # TIMEOUT without ever seeing the phone's reply. POLL_HEADERS=() if [ -n "$AUTH_TOKEN" ]; then POLL_HEADERS+=(-H "Authorization: Bearer $AUTH_TOKEN") fi # Where human-facing status lines go (the "Waiting for response..." # block and the mirror banner). Claude Code spawns hooks without a # controlling terminal, so /dev/tty exists and passes `-w` (that only # checks permission bits) yet fails to open with ENXIO; under `set -e` # that redirect used to kill the hook with exit 1. Probe by actually # opening it, and otherwise write straight to fd 2 rather than # reopening /dev/stderr (a reopen can fail on some fd types, a plain # `>&2` never does). SHADOWTERM_USER_OUT=/dev/null in the conf # silences the status lines entirely. USER_OUT="${SHADOWTERM_USER_OUT:-${USER_OUT:-}}" if [ -z "$USER_OUT" ]; then if { : > /dev/tty; } 2>/dev/null; then USER_OUT="/dev/tty"; else USER_OUT="fd2"; fi fi user_out() { if [ "$USER_OUT" = "fd2" ]; then printf '%s\n' "$*" >&2 || true else printf '%s\n' "$*" > "$USER_OUT" 2>/dev/null || true fi } # Generate unique request ID REQUEST_ID=$(uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo "req-$(date +%s)-$$") # Derive a per-session label that we prefix to every notification # title so the user can tell which Claude Code session fired the # event when several are running. Uses the cwd's basename (the # project name in 99% of cases) and the short hostname. Falls back # gracefully when either is missing. HOOK_PROJECT_NAME="$(basename "${PWD:-$HOME}" 2>/dev/null)" [ -z "$HOOK_PROJECT_NAME" ] || [ "$HOOK_PROJECT_NAME" = "/" ] && HOOK_PROJECT_NAME="Claude Code" HOOK_HOST_NAME="$(hostname -s 2>/dev/null || echo unknown)" HOOK_LABEL="${HOOK_PROJECT_NAME} on ${HOOK_HOST_NAME}" # Persistent log file so the user can tail -f to see what the hook is # doing in real time. Claude Code discards hook stderr on success, and # the tty output is one-shot, so this file is the only place a full # trace lives. HOOK_LOG="$HOME/.claude/hooks/shadowntfy-hook.log" mkdir -p "$(dirname "$HOOK_LOG")" 2>/dev/null log_to_file() { echo "[$(date '+%Y-%m-%dT%H:%M:%S%z')] [$REQUEST_ID] $*" >> "$HOOK_LOG" 2>/dev/null } log_to_file "hook start" # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- # Emit a warning Claude Code shows to the user (no decision attached, so # the native prompt still appears). Valid on every event. emit_system_message() { jq -n --arg msg "$1" '{ systemMessage: $msg }' } # FIT_NTFY_PAYLOAD_START # ntfy stores any POST body over its message-size-limit (4096 on the # ShadowNtfy server) as an attachment and shows "You received a file" # as the message, which hides request_id from the phone. Keep every # body at or under NTFY_PAYLOAD_MAX bytes (UTF-8, counted with wc -c) # and never drop request_id. Prints the JSON to post. NTFY_PAYLOAD_MAX=4000 fit_ntfy_payload() { local payload="$1" out orig final orig=$(printf '%s' "$payload" | wc -c | tr -d ' ') if [ "$orig" -le "$NTFY_PAYLOAD_MAX" ]; then printf '%s' "$payload" return 0 fi # Stage 1: clip the long free-text fields, drop suggestions. out=$(printf '%s' "$payload" | jq -c ' def clip($n): if type == "string" and length > $n then .[0:$n] + "..." else . end; (if has("command") then .command |= clip(500) else . end) | (if has("details") then .details |= clip(300) else . end) | (if has("reason") then .reason |= clip(200) else . end) | (if (.questions | type) == "array" then .questions |= map(if type == "object" and has("question") then .question |= clip(200) else . end) else . end) | del(.permission_suggestions) | .truncated = true' 2>/dev/null) || out="" # Stage 2: drop approval variants, clip harder. if [ -z "$out" ] || [ "$(printf '%s' "$out" | wc -c | tr -d ' ')" -gt "$NTFY_PAYLOAD_MAX" ]; then out=$(printf '%s' "${out:-$payload}" | jq -c ' def clip($n): if type == "string" and length > $n then .[0:$n] + "..." else . end; del(.permission_options) | (if has("command") then .command |= clip(80) else . end) | (if has("details") then .details |= clip(120) else . end) | .truncated = true' 2>/dev/null) || out="" fi # Last resort: minimal envelope. request_id always survives. if [ -z "$out" ] || [ "$(printf '%s' "$out" | wc -c | tr -d ' ')" -gt "$NTFY_PAYLOAD_MAX" ]; then out=$(printf '%s' "$payload" | jq -c --arg rid "$REQUEST_ID" ' def clip($n): if type == "string" and length > $n then .[0:$n] + "..." else . end; { request_id: $rid, event_type, event_kind, tool_name, permission_type, project: (.project | clip(100)), host: (.host | clip(100)), cwd: (.cwd | clip(200)), session_id: (.session_id | clip(100)), truncated: true, details: "Payload too long; command omitted." } | with_entries(select(.value != null))' 2>/dev/null) || out="" if [ -z "$out" ] || [ "$(printf '%s' "$out" | wc -c | tr -d ' ')" -gt "$NTFY_PAYLOAD_MAX" ]; then out=$(jq -nc --arg rid "$REQUEST_ID" \ '{ request_id: $rid, event_type: "info", truncated: true, details: "Payload too long; command omitted." }' 2>/dev/null) \ || out="{\"request_id\":\"$REQUEST_ID\",\"truncated\":true}" fi fi final=$(printf '%s' "$out" | wc -c | tr -d ' ') log_to_file "ntfy payload fitted: original=${orig}B final=${final}B" printf '%s' "$out" } # FIT_NTFY_PAYLOAD_END # POST a payload to the notification topic. Args: title, tags, priority, # payload JSON. Logs the HTTP status. post_notification() { local title="$1" tags="$2" priority="$3" payload="$4" status payload=$(fit_ntfy_payload "$payload") local headers=(-H "Title: $title" -H "Tags: $tags" -H "Priority: $priority") if [ -n "$AUTH_TOKEN" ]; then headers+=(-H "Authorization: Bearer $AUTH_TOKEN") fi log_to_file "POST ${NTFY_URL}/${TOPIC} (topic=${TOPIC}, response_topic=${RESPONSE_TOPIC})" status=$(curl -s -o /dev/null -w "%{http_code}" -X POST "${NTFY_URL}/${TOPIC}" \ "${headers[@]}" -d "$payload") || status="curl_failed" log_to_file "ntfy POST status=$status" } # Poll the response topic for a message whose requestId matches ours. # Args: timeout seconds, log prefix. On success sets MATCHED_MSG to the # inner message JSON and returns 0. On timeout returns 1. Poll cadence # is 1s; the `since` window is 60s so a reply posted while we slept is # still visible. MATCHED_MSG="" poll_for_response() { local timeout="$1" prefix="$2" local start now elapsed count=0 body_file http body bytes line msg req start=$(date +%s) MATCHED_MSG="" while true; do now=$(date +%s) elapsed=$((now - start)) if [ "$elapsed" -ge "$timeout" ]; then log_to_file "$prefix: TIMEOUT after ${elapsed}s ($count polls). No response received." return 1 fi count=$((count + 1)) body_file=$(mktemp 2>/dev/null || echo "/tmp/shadowntfy-poll-$$") http=$(curl -s -o "$body_file" -w "%{http_code}" "${POLL_HEADERS[@]}" \ "${NTFY_URL}/${RESPONSE_TOPIC}/json?poll=1&since=60s" 2>/dev/null || echo "curl_err") body=$(cat "$body_file" 2>/dev/null || echo "") rm -f "$body_file" bytes=${#body} # Log the 1st poll, every 30th, and any non-200 / non-empty poll. if [ "$count" = "1" ] || [ "$http" != "200" ] || [ "$bytes" -gt 0 ] || [ $((count % 30)) = 0 ]; then log_to_file "$prefix: poll #$count http=$http bytes=$bytes (${elapsed}s elapsed)" fi if [ -n "$body" ]; then while IFS= read -r line; do [ -z "$line" ] && continue echo "$line" | grep -q '"event":"open"\|"event":"keepalive"' && continue msg=$(echo "$line" | jq -r '.message // empty' 2>/dev/null) if [ -z "$msg" ]; then log_to_file "$prefix: non-message line: $(echo "$line" | head -c 120)" continue fi req=$(echo "$msg" | jq -r '.requestId // .request_id // empty' 2>/dev/null) if [ "$req" = "$REQUEST_ID" ]; then MATCHED_MSG="$msg" log_to_file "$prefix: matched response after $count polls (${elapsed}s) preview=$(echo "$msg" | head -c 160)" return 0 else log_to_file "$prefix: msg seen: requestId=${req:-} (ours=$REQUEST_ID) preview=$(echo "$msg" | head -c 120)" fi done <<< "$body" fi sleep 1 done } # --------------------------------------------------------------------------- # Read hook input # --------------------------------------------------------------------------- INPUT=$(cat) HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // "unknown"') TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // "unknown"') TOOL_INPUT=$(echo "$INPUT" | jq -c '.tool_input // {}') SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // "unknown"') CWD=$(echo "$INPUT" | jq -r '.cwd // "unknown"') PERMISSION_MODE=$(echo "$INPUT" | jq -r '.permission_mode // "default"') # Guard: AskUserQuestion must be handled on PreToolUse so we can return # `updatedInput` (PermissionRequest's output shape can't carry the # answers Claude needs). If we got here from PermissionRequest for an # AskUserQuestion call, exit cleanly and let the PreToolUse hook drive # the question/answer flow. if [ "$HOOK_EVENT" = "PermissionRequest" ] && [ "$TOOL_NAME" = "AskUserQuestion" ]; then echo "AskUserQuestion fired via PermissionRequest; deferring to PreToolUse handler" >&2 exit 0 fi # --------------------------------------------------------------------------- # PermissionDenied: auto mode denied a tool. Ping the phone; if it taps # Retry within DENIED_TIMEOUT, tell Claude it may retry. # --------------------------------------------------------------------------- if [ "$HOOK_EVENT" = "PermissionDenied" ]; then if [ "$NOTIFY_PERMISSION_DENIED" != "1" ]; then log_to_file "PermissionDenied ignored (SHADOWTERM_NOTIFY_PERMISSION_DENIED=$NOTIFY_PERMISSION_DENIED)" exit 0 fi DENY_REASON=$(echo "$INPUT" | jq -r '.reason // "no reason given"') DENIED_COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // empty') DENIED_FILE=$(echo "$TOOL_INPUT" | jq -r '.file_path // empty') DETAILS="Auto mode denied $TOOL_NAME in $HOOK_PROJECT_NAME: $DENY_REASON" PAYLOAD=$(jq -n --arg request_id "$REQUEST_ID" \ --arg session_id "$SESSION_ID" \ --arg cwd "$CWD" \ --arg project "$HOOK_PROJECT_NAME" \ --arg host "$HOOK_HOST_NAME" \ --arg hooks_version "$HOOKS_VERSION" \ --arg details "$DETAILS" \ --arg tool_name "$TOOL_NAME" \ --arg reason "$DENY_REASON" \ --arg command "$DENIED_COMMAND" \ --arg file_path "$DENIED_FILE" \ '{ event_type: "error", event_kind: "permission_denied", request_id: $request_id, details: $details, tool_name: $tool_name, reason: $reason, command: (if $command == "" then null else $command end), file_path: (if $file_path == "" then null else $file_path end), session_id: $session_id, cwd: $cwd, project: $project, host: $host, hooks_version: $hooks_version }') log_to_file "PermissionDenied tool=$TOOL_NAME reason=$DENY_REASON" post_notification "${HOOK_LABEL} · Auto mode denied $TOOL_NAME" "denied,automode" "high" "$PAYLOAD" if poll_for_response "$DENIED_TIMEOUT" "denied"; then RESPONSE=$(echo "$MATCHED_MSG" | jq -r '.response // empty') if [ "$RESPONSE" = "retry" ]; then log_to_file "PermissionDenied: phone asked for a retry" jq -n '{ hookSpecificOutput: { hookEventName: "PermissionDenied", retry: true } }' else log_to_file "PermissionDenied: response=$RESPONSE, denial stands" fi fi exit 0 fi # --------------------------------------------------------------------------- # Elicitation: an MCP server asks the user for input. Same modes as # AskUserQuestion. Form schemas become a `questions` array so an older # app can answer with the UI it already has; the phone's answers are # mapped back onto the schema keys (and coerced by type) here. # --------------------------------------------------------------------------- if [ "$HOOK_EVENT" = "Elicitation" ]; then if [ "$NOTIFY_QUESTIONS" != "1" ] || [ "$ASKUQ_MODE" = "console" ]; then log_to_file "Elicitation ignored (SHADOWTERM_NOTIFY_QUESTIONS=$NOTIFY_QUESTIONS mode=$ASKUQ_MODE)" exit 0 fi ELICIT_SERVER=$(echo "$INPUT" | jq -r '.mcp_server_name // "MCP server"') ELICIT_MESSAGE=$(echo "$INPUT" | jq -r '.message // "The MCP server needs your input."') ELICIT_MODE=$(echo "$INPUT" | jq -r '.mode // "form"') ELICIT_URL=$(echo "$INPUT" | jq -r '.url // empty') ELICIT_ID=$(echo "$INPUT" | jq -r '.elicitation_id // empty') ELICIT_SCHEMA=$(echo "$INPUT" | jq -c '.requested_schema // null') ELICIT_JSON=$(jq -n \ --arg server "$ELICIT_SERVER" --arg message "$ELICIT_MESSAGE" --arg mode "$ELICIT_MODE" \ --arg url "$ELICIT_URL" --arg id "$ELICIT_ID" --argjson schema "$ELICIT_SCHEMA" \ '{ mcp_server_name: $server, message: $message, mode: $mode, url: (if $url == "" then null else $url end), elicitation_id: (if $id == "" then null else $id end), requested_schema: $schema }') if [ "$ELICIT_MODE" = "url" ]; then URL_QUESTION="$ELICIT_SERVER asks you to open $ELICIT_URL and finish there. Continue when done." QMAP='[]' QUESTIONS_JSON=$(jq -n --arg q "$URL_QUESTION" \ '[{ question: $q, header: "Open link", multiSelect: false, options: [{label: "Done", description: "I finished in the browser"}, {label: "Decline", description: "Do not continue"}] }]') else # One question per schema property. QMAP remembers question text → # schema key + type so the answers can be mapped back. QMAP=$(jq -c --argjson s "$ELICIT_SCHEMA" -n ' ($s.properties // {}) as $p | ($s.required // []) as $req | [ $p | to_entries[] | .key as $k | .value as $v | { key: $k, type: ($v.type // "string"), q: (($v.title // $k) + (if ($req | index($k)) != null then " (required)" else "" end)), enum: ($v.enum // null) } ]') QUESTIONS_JSON=$(echo "$QMAP" | jq -c ' map({ question: .q, header: .key, multiSelect: false, options: (if .enum != null then [ .enum[] | { label: (.|tostring), description: "" } ] elif .type == "boolean" then [ { label: "Yes", description: "" }, { label: "No", description: "" } ] else [] end) })') fi Q_COUNT=$(echo "$QUESTIONS_JSON" | jq 'length') DETAILS="$ELICIT_SERVER: $ELICIT_MESSAGE" PAYLOAD=$(jq -n --arg request_id "$REQUEST_ID" \ --arg session_id "$SESSION_ID" \ --arg cwd "$CWD" \ --arg project "$HOOK_PROJECT_NAME" \ --arg host "$HOOK_HOST_NAME" \ --arg hooks_version "$HOOKS_VERSION" \ --arg details "$DETAILS" \ --arg tool_name "mcp__${ELICIT_SERVER}" \ --argjson questions "$QUESTIONS_JSON" \ --argjson elicitation "$ELICIT_JSON" \ '{ event_type: "question_request", event_kind: "elicitation_request", request_id: $request_id, details: $details, tool_name: $tool_name, session_id: $session_id, cwd: $cwd, project: $project, host: $host, hooks_version: $hooks_version, questions: $questions, elicitation: $elicitation }') log_to_file "Elicitation server=$ELICIT_SERVER mode=$ELICIT_MODE questions=$Q_COUNT askuqMode=$ASKUQ_MODE" post_notification "${HOOK_LABEL} · $ELICIT_SERVER needs input" "question,elicitation" "high" "$PAYLOAD" if [ "$ASKUQ_MODE" != "phone" ]; then user_out "" user_out "╭ Mirrored to ShadowNtfy (read-only on phone) ────────" user_out "│ $ELICIT_SERVER: $ELICIT_MESSAGE" user_out "│ Answer in this console (Claude Code dialog follows)." user_out "╰─────────────────────────────────────────────────────" log_to_file "elicitation $ASKUQ_MODE: posted + banner, exiting" exit 0 fi if ! poll_for_response "$TIMEOUT" "elicitation phone"; then exit 0 # native dialog takes over fi RESPONSE=$(echo "$MATCHED_MSG" | jq -r '.response // empty') if [ "$RESPONSE" = "declined" ] || [ "$RESPONSE" = "denied" ]; then log_to_file "elicitation phone: declined" jq -n '{ hookSpecificOutput: { hookEventName: "Elicitation", action: "decline" } }' exit 0 fi ANSWERS=$(echo "$MATCHED_MSG" | jq -c '.answers // {}') if [ "$ELICIT_MODE" = "url" ]; then URL_ANSWER=$(echo "$ANSWERS" | jq -r --arg q "$URL_QUESTION" '.[$q] // empty') if [ "$URL_ANSWER" = "Decline" ]; then log_to_file "elicitation phone: url declined" jq -n '{ hookSpecificOutput: { hookEventName: "Elicitation", action: "decline" } }' else log_to_file "elicitation phone: url accepted" jq -n '{ hookSpecificOutput: { hookEventName: "Elicitation", action: "accept", content: {} } }' fi exit 0 fi CONTENT=$(jq -n --argjson qmap "$QMAP" --argjson answers "$ANSWERS" ' reduce $qmap[] as $m ({}; ($answers[$m.q]) as $v | if $v == null or $v == "" then . else .[$m.key] = ( if $m.type == "number" or $m.type == "integer" then (($v | tonumber?) // $v) elif $m.type == "boolean" then (($v | ascii_downcase) as $l | if $l == "yes" or $l == "true" then true elif $l == "no" or $l == "false" then false else $v end) else $v end) end)') log_to_file "elicitation phone: accept content_keys=$(echo "$CONTENT" | jq -c 'keys')" jq -n --argjson content "$CONTENT" \ '{ hookSpecificOutput: { hookEventName: "Elicitation", action: "accept", content: $content } }' exit 0 fi # --------------------------------------------------------------------------- # PermissionRequest / AskUserQuestion # --------------------------------------------------------------------------- # Guard: AskUserQuestion mirroring is disabled in conf, or the mode is # console (question stays in Claude Code, no ntfy POST). if [ "$TOOL_NAME" = "AskUserQuestion" ]; then if [ "$NOTIFY_QUESTIONS" != "1" ] || [ "$ASKUQ_MODE" = "console" ]; then log_to_file "AskUserQuestion ignored (SHADOWTERM_NOTIFY_QUESTIONS=$NOTIFY_QUESTIONS mode=$ASKUQ_MODE)" exit 0 fi elif [ "$NOTIFY_PERMISSIONS" != "1" ]; then log_to_file "PermissionRequest ignored (SHADOWTERM_NOTIFY_PERMISSIONS=$NOTIFY_PERMISSIONS)" exit 0 fi # Build permission details based on tool type FILE_PATH="" COMMAND="" case "$TOOL_NAME" in "Bash") COMMAND=$(echo "$TOOL_INPUT" | jq -r '.command // "unknown command"') DETAILS="Command: $COMMAND" PERMISSION_TYPE="command_execution" ;; "Write") FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // "unknown file"') DETAILS="File: $FILE_PATH" PERMISSION_TYPE="file_write" ;; "Edit") FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // "unknown file"') DETAILS="File: $FILE_PATH" PERMISSION_TYPE="file_write" ;; "Read") FILE_PATH=$(echo "$TOOL_INPUT" | jq -r '.file_path // "unknown file"') DETAILS="File: $FILE_PATH" PERMISSION_TYPE="file_read" ;; "AskUserQuestion") # Claude is asking the user one or more clarifying questions. # The tool_input has shape { questions: [{ question, header, # options:[{label,description}], multiSelect }] }. We surface # the first question's text in the notification body for the # banner/preview, and lift the full questions array to the top # level of the payload so ShadowNtfy can render the answer UI. IS_ASK_USER_QUESTION=1 QUESTIONS_JSON=$(echo "$TOOL_INPUT" | jq -c '.questions // []') FIRST_Q=$(echo "$TOOL_INPUT" | jq -r '.questions[0].question // "Claude has a question."') Q_COUNT=$(echo "$TOOL_INPUT" | jq '.questions | length // 0') if [ "$Q_COUNT" -gt 1 ]; then DETAILS="$Q_COUNT questions ... $FIRST_Q" else DETAILS="$FIRST_Q" fi PERMISSION_TYPE="ask_user_question" ;; mcp__*) # mcp____ (plugin-scoped names keep full server segment) MCP_SERVER=$(echo "$TOOL_NAME" | awk -F'__' '{print $2}') MCP_TOOL=$(echo "$TOOL_NAME" | awk -F'__' '{print $NF}') DETAILS="MCP: ${MCP_SERVER:-?} / ${MCP_TOOL:-$TOOL_NAME}" PERMISSION_TYPE="mcp_tool" ;; *) DETAILS="Tool: $TOOL_NAME" PERMISSION_TYPE="other" ;; esac # Build notification payload. AskUserQuestion gets a different event_type # (question_request) plus the questions array so ShadowNtfy can render # the answer UI; everything else stays on the legacy permission_request # shape so the existing approval flow keeps working. Both shapes carry # `project` and `host` so the iOS app and ntfy preview can render the # per-session label. if [ "${IS_ASK_USER_QUESTION:-0}" = "1" ]; then PAYLOAD=$(jq -n --arg request_id "$REQUEST_ID" \ --arg session_id "$SESSION_ID" \ --arg cwd "$CWD" \ --arg project "$HOOK_PROJECT_NAME" \ --arg host "$HOOK_HOST_NAME" \ --arg hooks_version "$HOOKS_VERSION" \ --arg details "$DETAILS" \ --arg tool_name "$TOOL_NAME" \ --argjson questions "$QUESTIONS_JSON" \ '{ event_type: "question_request", event_kind: "question_request", request_id: $request_id, details: $details, tool_name: $tool_name, session_id: $session_id, cwd: $cwd, project: $project, host: $host, hooks_version: $hooks_version, questions: $questions }') NOTIFY_TITLE="${HOOK_LABEL} · Claude has a question" NOTIFY_TAGS="question,askuserquestion" else # Approval variants the phone may offer. One per Claude Code # permission_suggestion (the same "always allow" options the terminal # dialog builds from), plus "accept edits this session" unless the # session already runs in a mode that would make it a no-op. The # chosen entry comes back as `permissionUpdate` and is echoed as # `updatedPermissions`. Older apps ignore this array. PERM_SUGGESTIONS=$(echo "$INPUT" | jq -c '.permission_suggestions // []') PERM_OPTIONS=$(jq -n -c --argjson s "$PERM_SUGGESTIONS" --arg mode "$PERMISSION_MODE" ' def dest_label: if .destination == "session" then "this session" elif .destination == "localSettings" then "this project (local)" elif .destination == "projectSettings" then "this project" elif .destination == "userSettings" then "all projects" else (.destination // "this session") end; def rule_label: (.rules // []) | map(.toolName + (if .ruleContent then "(" + .ruleContent + ")" else "" end)) | join(", "); [ $s | to_entries[] | .key as $i | .value as $e | select($e.type == "addRules" or $e.type == "setMode") | select($e.type != "setMode" or $e.mode != "bypassPermissions") | { id: ("suggestion-" + ($i | tostring)), label: (if $e.type == "addRules" then ((if ($e.behavior // "allow") == "allow" then "Always allow " else "Always " + $e.behavior + " " end) + ($e | rule_label) + " for " + ($e | dest_label)) else ("Switch to " + $e.mode + " for " + ($e | dest_label)) end), update: $e } ] + (if ($mode == "acceptEdits" or $mode == "auto" or $mode == "dontAsk" or $mode == "bypassPermissions") then [] else [ { id: "session-acceptEdits", label: "Allow, and accept edits this session", update: { type: "setMode", mode: "acceptEdits", destination: "session" } } ] end)') PAYLOAD=$(jq -n --arg request_id "$REQUEST_ID" \ --arg session_id "$SESSION_ID" \ --arg cwd "$CWD" \ --arg project "$HOOK_PROJECT_NAME" \ --arg host "$HOOK_HOST_NAME" \ --arg hooks_version "$HOOKS_VERSION" \ --arg permission_type "$PERMISSION_TYPE" \ --arg details "$DETAILS" \ --arg tool_name "$TOOL_NAME" \ --arg file_path "$FILE_PATH" \ --arg command "$COMMAND" \ --arg permission_mode "$PERMISSION_MODE" \ --argjson permission_options "$PERM_OPTIONS" \ --argjson permission_suggestions "$PERM_SUGGESTIONS" \ '{ event_type: "permission_request", event_kind: "permission_request", permission_type: $permission_type, request_id: $request_id, details: $details, tool_name: $tool_name, file_path: (if $file_path == "" then null else $file_path end), command: (if $command == "" then null else $command end), session_id: $session_id, cwd: $cwd, project: $project, host: $host, hooks_version: $hooks_version, permission_mode: $permission_mode, permission_options: $permission_options, permission_suggestions: $permission_suggestions }') NOTIFY_TITLE="${HOOK_LABEL} · Permission Request" NOTIFY_TAGS="permission" fi log_to_file "tool=$TOOL_NAME event=$HOOK_EVENT askUserQ=${IS_ASK_USER_QUESTION:-0} mode=$PERMISSION_MODE timeout=${TIMEOUT}s" post_notification "$NOTIFY_TITLE" "$NOTIFY_TAGS" "high" "$PAYLOAD" # AskUserQuestion: behavior depends on ASKUQ_MODE. # mirror|banner → console banner + ntfy POST + exit. Claude Code's # native prompt fires; you answer in the console. # phone → ntfy POST + poll for phone answer up to TIMEOUT. # If phone answers, emit `permissionDecision:"allow"` # with `updatedInput.answers` so Claude Code uses # them. On timeout, exit 0 ... Claude Code's native # prompt takes over and you answer in the console. if [ "${IS_ASK_USER_QUESTION:-0}" = "1" ]; then if [ "$ASKUQ_MODE" != "phone" ]; then user_out "" user_out "╭ Mirrored to ShadowNtfy (read-only on phone) ────────" user_out "$(echo "$TOOL_INPUT" | jq -r ' .questions | to_entries | map("│ Q\(.key + 1)" + (if .value.header and .value.header != "" then " [\(.value.header)]" else "" end) + ": \(.value.question)") | join("\n") ')" user_out "│ Answer in this console (Claude Code prompt follows)." user_out "╰─────────────────────────────────────────────────────" log_to_file "askuserquestion $ASKUQ_MODE: posted to ntfy + console banner, exiting" exit 0 fi log_to_file "askuserquestion phone: polling ntfy for phone answer (timeout=${TIMEOUT}s)" if ! poll_for_response "$TIMEOUT" "askuserquestion phone"; then exit 0 # native prompt takes over fi ASKUQ_ANSWERS=$(echo "$MATCHED_MSG" | jq -c '.answers // empty' 2>/dev/null) if [ -z "$ASKUQ_ANSWERS" ] || [ "$ASKUQ_ANSWERS" = "null" ]; then log_to_file "askuserquestion phone: no answers parsed, exiting (will fall back to native prompt)" exit 0 fi log_to_file "askuserquestion phone: emitting updatedInput.answers to Claude Code" jq -n \ --argjson questions "$QUESTIONS_JSON" \ --argjson answers "$ASKUQ_ANSWERS" \ '{ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput: { questions: $questions, answers: $answers } } }' exit 0 fi # From here down only permission_request flows reach us. user_out "" user_out "Waiting for response from ShadowNtfy..." user_out "Request ID: $REQUEST_ID" user_out "Tool: $TOOL_NAME" user_out "Details: $DETAILS" if ! poll_for_response "$TIMEOUT" "permission"; then # Make the timeout visible: Claude Code shows systemMessage, then its # own prompt (no decision attached). user_out "" user_out "⚠ ShadowNtfy hook timed out after ${TIMEOUT}s." user_out " No response was received for request $REQUEST_ID." user_out " Claude Code will fall back to its built-in prompt." user_out " Log: $HOOK_LOG" emit_system_message "ShadowNtfy: no phone response after ${TIMEOUT}s, answer here." exit 0 fi RESPONSE=$(echo "$MATCHED_MSG" | jq -r '.response // empty' 2>/dev/null) # Optional permission update chosen on the phone. Only the fields Claude # Code documents are passed through, and only the two entry types we # offer; anything else degrades to a plain allow. PERM_UPDATE=$(echo "$MATCHED_MSG" | jq -c ' (.permissionUpdate // .permission_update // null) as $u | if ($u | type) != "object" then empty elif ($u.type == "setMode" and ($u.mode | type) == "string") or ($u.type == "addRules" and ($u.rules | type) == "array") then ($u | { type, rules, behavior, mode, destination } | with_entries(select(.value != null)) | .destination = (.destination // "session")) else empty end' 2>/dev/null || echo "") if [ "$RESPONSE" = "approved" ]; then if [ -n "$PERM_UPDATE" ]; then echo "Permission APPROVED by ShadowNtfy with update: $PERM_UPDATE" >&2 log_to_file "permission: approved with updatedPermissions=$PERM_UPDATE" jq -n --argjson update "$PERM_UPDATE" '{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow", updatedPermissions: [ $update ] } } }' else echo "Permission APPROVED by ShadowNtfy" >&2 log_to_file "permission: approved" jq -n '{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } }' fi elif [ "$RESPONSE" = "denied" ]; then echo "Permission DENIED by ShadowNtfy" >&2 log_to_file "permission: denied" jq -n --arg reason "Permission denied by user via ShadowNtfy" '{ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: $reason } } }' else echo "Unknown response: $RESPONSE" >&2 log_to_file "permission: unknown response=$RESPONSE, falling back to native prompt" emit_system_message "ShadowNtfy sent an unrecognized response ($RESPONSE); answer here." exit 0 fi