#!/usr/bin/env bash # tirith bash hook # Two modes controlled by TIRITH_BASH_MODE: # enter (default outside SSH): bind -x Enter override. Can block + intercept paste. # Startup health gate + pending-not-consumed detection auto-degrade to preexec. # preexec: DEBUG trap warn-only. Cannot block. No paste interception. # Guard against double-loading (session-local only). # If inherited from environment (exported by attacker/parent), ignore it. if [[ -n "$_TIRITH_BASH_LOADED" ]]; then if [[ "$(declare -p _TIRITH_BASH_LOADED 2>/dev/null)" =~ ^declare\ -[a-zA-Z]*x ]]; then unset _TIRITH_BASH_LOADED # Inherited from env — ignore and load fresh else return # Set in this session — genuine double-source guard fi fi _TIRITH_BASH_LOADED=1 # Clear attacker-controllable env vars before any hooks are installed. # _TIRITH_PENDING_EVAL/_PENDING_SOURCE: pre-set value would be eval'd on first prompt. unset _TIRITH_PENDING_EVAL _TIRITH_PENDING_SOURCE # _TIRITH_TEST_*: only clear if inherited from environment (exported by parent). # Session-local values (set without export) are trusted test overrides. [[ "$(declare -p _TIRITH_TEST_SKIP_HEALTH 2>/dev/null)" =~ ^declare\ -[a-zA-Z]*x ]] && unset _TIRITH_TEST_SKIP_HEALTH [[ "$(declare -p _TIRITH_TEST_FAIL_HEALTH 2>/dev/null)" =~ ^declare\ -[a-zA-Z]*x ]] && unset _TIRITH_TEST_FAIL_HEALTH # Session tracking: generate ID per shell session if not inherited if [[ -z "${TIRITH_SESSION_ID:-}" ]]; then TIRITH_SESSION_ID="$(printf '%x-%x' "$$" "$(date +%s)")" export TIRITH_SESSION_ID fi # Output helper: write to stderr by default. # Override via TIRITH_OUTPUT=tty to write to /dev/tty instead. _tirith_output() { if [[ "${TIRITH_OUTPUT:-}" == "tty" ]]; then printf '%s\n' "$@" >/dev/tty else printf '%s\n' "$@" >&2 fi } _tirith_escape_preview() { printf '%q' "$1" } # Parse approval temp file. On success, sets _tirith_ap_* variables. # On failure (missing/unreadable/corrupt), returns 1 with fail-closed defaults. _tirith_parse_approval() { local file="$1" _tirith_ap_required="no" _tirith_ap_timeout=0 _tirith_ap_fallback="block" _tirith_ap_rule="" _tirith_ap_desc="" if [[ ! -r "$file" ]]; then _tirith_output "tirith: warning: approval file missing or unreadable, failing closed" command rm -f "$file" # delete on all paths _tirith_ap_required="yes" _tirith_ap_fallback="block" _tirith_ap_timeout=0 return 1 fi local valid_keys=0 while IFS='=' read -r key value; do case "$key" in TIRITH_REQUIRES_APPROVAL) _tirith_ap_required="$value"; valid_keys=$((valid_keys + 1)) ;; TIRITH_APPROVAL_TIMEOUT) _tirith_ap_timeout="$value" ;; TIRITH_APPROVAL_FALLBACK) _tirith_ap_fallback="$value" ;; TIRITH_APPROVAL_RULE) _tirith_ap_rule="$value" ;; TIRITH_APPROVAL_DESCRIPTION) _tirith_ap_desc="$value" ;; esac done < "$file" # Delete temp file after reading command rm -f "$file" # Corrupt file (no valid keys) → fail closed (reset all fields) if [[ $valid_keys -eq 0 ]]; then _tirith_output "tirith: warning: approval file corrupt, failing closed" _tirith_ap_required="yes" _tirith_ap_fallback="block" _tirith_ap_timeout=0 return 1 fi return 0 } _tirith_parse_warn_ack() { local file="$1" _tirith_wa_findings=0 _tirith_wa_max_severity="" if [[ ! -r "$file" ]]; then command rm -f "$file" return 1 fi while IFS='=' read -r key value; do case "$key" in TIRITH_WARN_ACK_FINDINGS) _tirith_wa_findings="$value" ;; TIRITH_WARN_ACK_MAX_SEVERITY) _tirith_wa_max_severity="$value" ;; esac done < "$file" command rm -f "$file" return 0 } # Trim whitespace to match Rust policy.rs:state_dir() behavior _TIRITH_STATE_DIR="${XDG_STATE_HOME:-}" _TIRITH_STATE_DIR="${_TIRITH_STATE_DIR#"${_TIRITH_STATE_DIR%%[![:space:]]*}"}" _TIRITH_STATE_DIR="${_TIRITH_STATE_DIR%"${_TIRITH_STATE_DIR##*[![:space:]]}"}" _TIRITH_STATE_DIR="${_TIRITH_STATE_DIR:-$HOME/.local/state}/tirith" _TIRITH_SAFE_MODE_FLAG="$_TIRITH_STATE_DIR/bash-safe-mode" _tirith_check_safe_mode() { [[ -f "$_TIRITH_SAFE_MODE_FLAG" ]]; } _tirith_persist_safe_mode() { if ! mkdir -p "$_TIRITH_STATE_DIR" 2>/dev/null || ! printf '1\n' > "$_TIRITH_SAFE_MODE_FLAG" 2>/dev/null; then echo "tirith: warning: could not persist safe-mode flag" >&2 fi } # Read the most recent history entry as "|" on stdout. Returns 1 # with empty stdout when history is unavailable, disabled, or malformed. # HISTTIMEFORMAT is neutralised with a function-local empty value so bash # restores the user's outer setting on return. _tirith_read_history_entry() { local HISTTIMEFORMAT='' local raw raw="$(builtin history 1 2>/dev/null)" || return 1 [[ -z "$raw" ]] && return 1 if [[ "$raw" =~ ^[[:space:]]*([0-9]+)[[:space:]]+(.*)$ ]]; then printf '%s|%s\n' "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" return 0 fi return 1 } # Collapse runs of whitespace and trim spacing around shell operators. # Used to bridge cosmetic spacing differences (`>/dev/null` vs `> /dev/null`) # between BASH_COMMAND and the history line in enforcement mode. _tirith_normalize_spacing() { local s="$1" s="$(printf '%s' "$s" | tr -s '[:space:]' ' ')" s="${s# }" s="${s% }" local op for op in '|' '&' ';' '>' '<'; do while [[ "$s" == *" $op"* ]]; do s="${s//" $op"/$op}"; done while [[ "$s" == *"$op "* ]]; do s="${s//"$op "/$op}"; done done printf '%s' "$s" } # Escape POSIX-ERE metacharacters so the result can be embedded literally into # a bash =~ regex pattern. _tirith_regex_escape() { local s="$1" out="" i c for ((i=0; i<${#s}; i++)); do c="${s:i:1}" case "$c" in '\'|'.'|'*'|'+'|'?'|'|'|'('|')'|'['|']'|'{'|'}'|'^'|'$') out+='\'"$c" ;; *) out+="$c" ;; esac done printf '%s' "$out" } # Return 0 when $1 (BASH_COMMAND) corresponds to one of the simple commands # in $2 (history_line). Uses three steps: # # 1. Literal word-boundary match of BASH_COMMAND in history_line. # 2. Whitespace-normalised retry (bridges `ls -l >/dev/null` vs # `ls -l > /dev/null`). # 3. Command-name fallback: the first token of BASH_COMMAND (the program # name) must appear as a bounded token somewhere in history_line. This # bridges bash's internal rewriting of redirection FDs (`>&2` typed, # `1>&2` in BASH_COMMAND) while still catching alias expansion (the # alias's output command name won't appear in the typed line). _tirith_cmd_is_in_line() { local needle="$1" haystack="$2" [[ -z "$needle" || -z "$haystack" ]] && return 1 [[ "$haystack" == "$needle" ]] && return 0 local esc boundary boundary='(^|[[:space:]|&;<>()])' esc="$(_tirith_regex_escape "$needle")" if [[ "$haystack" =~ ${boundary}${esc}([[:space:]|&\;<>()]|$) ]]; then return 0 fi local n_needle n_haystack n_needle="$(_tirith_normalize_spacing "$needle")" n_haystack="$(_tirith_normalize_spacing "$haystack")" [[ "$n_haystack" == "$n_needle" ]] && return 0 esc="$(_tirith_regex_escape "$n_needle")" if [[ "$n_haystack" =~ ${boundary}${esc}([[:space:]|&\;<>()]|$) ]]; then return 0 fi local first_token="${needle%%[[:space:]]*}" [[ -z "$first_token" ]] && return 1 esc="$(_tirith_regex_escape "$first_token")" if [[ "$haystack" =~ ${boundary}${esc}([[:space:]|&\;<>()]|$) ]]; then return 0 fi return 1 } # Install-time gate for preexec enforcement. Hostile history configurations # cannot provide a trustworthy whole-line view, so the hook stays in # warn-only rather than claim protection it cannot deliver. _tirith_history_is_trustworthy_for_enforcement() { case ":${HISTCONTROL:-}:" in *:ignorespace:*|*:ignoredups:*|*:ignoreboth:*) return 1 ;; esac [[ -n "${HISTIGNORE:-}" ]] && return 1 if ! shopt -oq history 2>/dev/null; then return 1 fi return 0 } # Enable `extdebug` if (and only if) tirith is the one turning it on. Tracks # ownership via _TIRITH_OWNS_EXTDEBUG so we can safely clean up at shell exit; # it is deliberately left on for the rest of the session once enabled, because # disabling it inside the DEBUG trap would break the `return 1` skip semantic # bash relies on. _tirith_enable_extdebug() { if shopt -q extdebug; then return 0 fi shopt -s extdebug _TIRITH_OWNS_EXTDEBUG=1 } # Idempotent DEBUG-trap installer. Chains through any pre-existing user DEBUG # trap via a trampoline so warn-only + enforcement do not clobber the user's # own instrumentation. Second and later calls are no-ops. # # We capture the caller's line number (BASH_LINENO[0] here, since the # trampoline IS the topmost function called from the trap) and pass it # explicitly to _tirith_preexec — otherwise preexec would see only its own # call frame's line, not the user-typed line. _tirith_debug_trampoline() { local _user_line_id="${BASH_LINENO[0]:-0}" if [[ -n "${_TIRITH_PREV_DEBUG_TRAP:-}" ]]; then eval "$_TIRITH_PREV_DEBUG_TRAP" || true fi _tirith_preexec "$_user_line_id" } _tirith_install_debug_trap() { local current current="$(trap -p DEBUG 2>/dev/null)" [[ "$current" == *"_tirith_debug_trampoline"* ]] && return 0 _TIRITH_PREV_DEBUG_TRAP="$(trap -p DEBUG 2>/dev/null | sed "s/^trap -- '//;s/' DEBUG\$//")" trap '_tirith_debug_trampoline' DEBUG } # Cache-then-degrade: flip the session to warn-only mode and re-export the # effective protection string so a subsequent `tirith doctor` sees the truth. # Callers that already know a history index should pin the cache BEFORE # invoking this helper so the current line's remaining DEBUG fires stay # blocked (extdebug stays on for the life of the session). _tirith_session_degrade_to_warn_only() { local reason="$1" _TIRITH_PREEXEC_ENFORCE=0 _TIRITH_WARN_ONLY_USE_BASH_COMMAND=1 _TIRITH_PREEXEC_WARNED=1 # suppress the generic warn-only banner export TIRITH_BASH_EFFECTIVE_PROTECTION="warn-only" if [[ $- == *i* ]]; then _tirith_output "$reason" fi } _tirith_preexec() { [[ "${_TIRITH_BASH_INTERNAL:-0}" == "1" ]] && return 0 # Once-per-shell warn-only banner for interactive preexec users. if [[ -z "${_TIRITH_PREEXEC_WARNED:-}" ]] \ && [[ $- == *i* ]] \ && [[ "${_TIRITH_PREEXEC_ENFORCE:-0}" != "1" ]]; then _TIRITH_PREEXEC_WARNED=1 _tirith_output "tirith: bash is in preexec mode (warn-only, does not block)" _tirith_output " For guaranteed blocking use enter mode (export TIRITH_BASH_MODE=enter)" fi local bash_cmd="$BASH_COMMAND" local entry history_index="" history_line="" if entry="$(_tirith_read_history_entry)"; then history_index="${entry%%|*}" history_line="${entry#*|}" fi # Per-typed-line cache key. The trampoline captures the caller's line # number (BASH_LINENO[0] from its own frame) and passes it as $1; that # value advances on each prompt-boundary even when the user's # HISTCONTROL/HISTIGNORE settings make `history 1` skip entries, so it # identifies "same typed line" reliably even in filtered shells. All # simple commands of one typed line (`a; b`, `a | b`, `a && b`) share # the same value. Fall back to the topmost BASH_LINENO frame for the # rare case preexec is invoked directly without going through the # trampoline. local line_id="${1:-${BASH_LINENO[${#BASH_LINENO[@]}-1]:-0}}" local _tirith_prev_internal="${_TIRITH_BASH_INTERNAL:-0}" local rc if [[ "${_TIRITH_PREEXEC_ENFORCE:-0}" == "1" ]]; then # Helper failed (no history entry available): cannot enforce whole-line # semantics, so block the current DEBUG fire and downgrade the session. if [[ -z "$history_index" ]]; then _tirith_session_degrade_to_warn_only \ "tirith: bash history is unavailable in this shell (history disabled or buffer empty), cannot enforce whole-line semantics; falling back to warn-only. For guaranteed blocking, use enter mode (export TIRITH_BASH_MODE=enter)." return 1 fi # Drift check FIRST. Critical: a stale history index (e.g. when a # filtered command leaves history_index unchanged from a prior allow) # MUST NOT short-circuit to the cache before we re-validate that the # current BASH_COMMAND still belongs to the typed line. Otherwise an # attacker can flip on `HISTCONTROL=ignorespace` mid-session and reuse # an earlier allow verdict for a brand-new blocked command. if ! _tirith_cmd_is_in_line "$bash_cmd" "$history_line"; then _tirith_last_key="$line_id" _tirith_last_rc=1 _tirith_session_degrade_to_warn_only \ "tirith: bash history no longer matches BASH_COMMAND (likely HISTCONTROL/HISTIGNORE filtering, an alias, or a shell transformation outside the whole-line drift check); cannot enforce whole-line semantics; falling back to warn-only. For guaranteed blocking, use enter mode (export TIRITH_BASH_MODE=enter)." return 1 fi # Cache hit on the current typed line (drift just validated). if [[ "${_tirith_last_key:-}" == "$line_id" ]]; then return "${_tirith_last_rc:-0}" fi # Cache miss: fresh whole-line scan. _TIRITH_BASH_INTERNAL=1 command tirith check --shell posix -- "$history_line" rc=$? _TIRITH_BASH_INTERNAL="$_tirith_prev_internal" case "$rc" in 0|2) _tirith_last_key="$line_id" _tirith_last_rc=0 return 0 ;; 1) _tirith_last_key="$line_id" _tirith_last_rc=1 return 1 ;; *) _tirith_last_key="$line_id" _tirith_last_rc=1 _tirith_session_degrade_to_warn_only \ "tirith: preexec enforcement failed unexpectedly (exit $rc), blocking this command and disabling enforcement for this shell" return 1 ;; esac fi # Cross-path pinned-block carryover: a prior degrade may have written # (_tirith_last_key=$line_id, _tirith_last_rc=1) so the rest of the same # typed line continues to be skipped by extdebug. Keying on LINENO means # a later prompt cannot inherit the block — even in shells where history # filtering keeps history_index pinned across prompts. if [[ "${_tirith_last_key:-}" == "$line_id" ]] \ && [[ "${_tirith_last_rc:-}" == "1" ]]; then return 1 fi # When the session has been degraded (install-time hostile-config or # runtime drift), history_line can no longer be trusted to correspond to # the current simple command, so scan BASH_COMMAND instead. Otherwise # prefer history_line so composite rules (pipe-to-interpreter, etc.) fire # on the full typed line. local scan_target if [[ "${_TIRITH_WARN_ONLY_USE_BASH_COMMAND:-0}" == "1" ]]; then scan_target="$bash_cmd" elif [[ -n "$history_line" ]]; then scan_target="$history_line" else scan_target="$bash_cmd" fi # Within-line dedupe: skip when this exact scan target was already sent # to tirith on the SAME typed line (DEBUG can fire multiple times for one # simple command via subshell expansion). Combine the per-line id with # the scan target so identical commands on separate prompts each get a # fresh DETECTED banner — the prompt boundary advances line_id and # naturally invalidates the dedupe. local dedupe_key="${line_id}|${scan_target}" [[ "${_tirith_last_cmd:-}" == "$dedupe_key" ]] && return 0 _tirith_last_cmd="$dedupe_key" _TIRITH_BASH_INTERNAL=1 command tirith check --shell posix --warn-only -- "$scan_target" || true _TIRITH_BASH_INTERNAL="$_tirith_prev_internal" return 0 } _tirith_degrade_to_preexec() { local reason="${1:-unknown}" # Only print warnings in interactive shells if [[ $- == *i* ]]; then _tirith_output "tirith: enter mode failed ($reason) — switching to preexec" _tirith_output " Persistent. Re-enable: TIRITH_BASH_MODE=enter" fi # Safe deterministic degrade: set known-safe defaults for current session. # Custom bindings from .inputrc/.bashrc return on next shell (safe mode persisted, # so tirith won't install bind-x on restart). if [[ "${_TIRITH_BINDS_INSTALLED:-0}" == "1" ]]; then bind '"\C-m": accept-line' 2>/dev/null || true bind '"\C-j": accept-line' 2>/dev/null || true # Restore bracketed paste to readline default if available, otherwise unbind bind '"\e[200~": bracketed-paste-begin' 2>/dev/null || bind -r '"\e[200~"' 2>/dev/null || true _TIRITH_BINDS_INSTALLED=0 fi _tirith_install_debug_trap _TIRITH_BASH_MODE="preexec" _tirith_persist_safe_mode if [[ $- == *i* ]]; then _tirith_output " Restart your shell for full custom keybindings to return." fi } _tirith_prompt_hook() { local pending_eval="${_TIRITH_PENDING_EVAL:-}" local pending_source="${_TIRITH_PENDING_SOURCE:-}" unset _TIRITH_PENDING_EVAL _TIRITH_PENDING_SOURCE if [[ -n "$pending_source" ]]; then source "$pending_source" command rm -f "$pending_source" elif [[ -n "$pending_eval" ]]; then eval -- "$pending_eval" fi } _tirith_is_prompt_hook_attached() { local pc_decl pc_decl="$(declare -p PROMPT_COMMAND 2>/dev/null)" || return 1 if [[ "$pc_decl" == "declare -a"* ]]; then local entry for entry in "${PROMPT_COMMAND[@]}"; do [[ "$entry" == "_tirith_prompt_hook" ]] && return 0 done return 1 else # String form: use regex to match _tirith_prompt_hook as a semicolon-delimited # token with optional surrounding whitespace. [[ "$PROMPT_COMMAND" =~ (^|;)[[:space:]]*_tirith_prompt_hook[[:space:]]*(;|$) ]] && return 0 return 1 fi } _tirith_ensure_prompt_hook() { _tirith_is_prompt_hook_attached && return 0 local pc_decl pc_decl="$(declare -p PROMPT_COMMAND 2>/dev/null)" || pc_decl="" if [[ "$pc_decl" == "declare -a"* ]]; then PROMPT_COMMAND=(_tirith_prompt_hook "${PROMPT_COMMAND[@]}") 2>/dev/null || return 1 elif [[ -n "${PROMPT_COMMAND:-}" ]]; then PROMPT_COMMAND="_tirith_prompt_hook;${PROMPT_COMMAND}" 2>/dev/null || return 1 else PROMPT_COMMAND="_tirith_prompt_hook" 2>/dev/null || return 1 fi return 0 } if [[ -n "${TIRITH_BASH_MODE:-}" ]]; then _TIRITH_BASH_MODE="$TIRITH_BASH_MODE" elif _tirith_check_safe_mode; then _TIRITH_BASH_MODE="preexec" # Only print warning in interactive shells (avoid polluting scripted output) [[ $- == *i* ]] && _tirith_output "tirith: safe mode active (preexec) — previous enter-mode failure detected" [[ $- == *i* ]] && _tirith_output " Re-enable: TIRITH_BASH_MODE=enter or tirith doctor --reset-bash-safe-mode" elif [[ -n "${SSH_CONNECTION:-}" || -n "${SSH_TTY:-}" || -n "${SSH_CLIENT:-}" ]]; then # SSH PTY environments are more reliable with DEBUG-trap preexec mode. _TIRITH_BASH_MODE="preexec" else _TIRITH_BASH_MODE="enter" fi # # Doctor is a child process and cannot read shell-local `_TIRITH_*` variables, # so the hook exports a small public contract: `TIRITH_BASH_EFFECTIVE_MODE` and # `TIRITH_BASH_EFFECTIVE_PROTECTION`. These are re-exported on every state # change (degrade, enforcement flip) so a subsequent `tirith doctor` invocation # in the same shell sees truthful live state. Only exported in interactive # shells where the hook actually installs interception; non-interactive # sourcing is a no-op and must not leak status vars into child processes. if [[ $- == *i* ]]; then export TIRITH_BASH_EFFECTIVE_MODE="$_TIRITH_BASH_MODE" if [[ "$_TIRITH_BASH_MODE" == "enter" ]]; then export TIRITH_BASH_EFFECTIVE_PROTECTION="blocks" else export TIRITH_BASH_EFFECTIVE_PROTECTION="warn-only" fi fi # # Users who set TIRITH_BASH_PREEXEC_ENFORCE to a truthy value get real # blocking in preexec mode via `shopt -s extdebug` + `return 1` from the # DEBUG trap. Enforcement requires a trustworthy whole-line view, so hostile # history configs are rejected at install time: HISTCONTROL containing # ignorespace/ignoredups/ignoreboth, any HISTIGNORE, or `set +o history` # downgrade the session to warn-only with a pointer at enter mode. _TIRITH_PREEXEC_ENFORCE=0 _TIRITH_OWNS_EXTDEBUG=0 _TIRITH_WARN_ONLY_USE_BASH_COMMAND=0 _tirith_env_is_truthy() { case "${1:-}" in 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;; esac return 1 } if [[ "$_TIRITH_BASH_MODE" == "preexec" ]] \ && [[ $- == *i* ]] \ && _tirith_env_is_truthy "${TIRITH_BASH_PREEXEC_ENFORCE:-}"; then if _tirith_history_is_trustworthy_for_enforcement; then _TIRITH_PREEXEC_ENFORCE=1 _tirith_enable_extdebug export TIRITH_BASH_EFFECTIVE_PROTECTION="blocks" else # Same hostile-history check that triggers a runtime drift downgrade — # so the warn-only scan target must also flip to BASH_COMMAND, not the # untrustworthy history_line. Without this the warn-only path would # produce stale DETECTED banners scanned against whatever entry # `history 1` happens to surface. _TIRITH_WARN_ONLY_USE_BASH_COMMAND=1 _tirith_output "tirith: cannot enable preexec enforcement in this shell (HISTCONTROL/HISTIGNORE or disabled history prevent trustworthy whole-line view). Running in warn-only. For guaranteed blocking, use enter mode (export TIRITH_BASH_MODE=enter)." fi fi # Check if a command is unsafe to eval (heredocs, multiline, etc.) _tirith_unsafe_to_eval() { local cmd="$1" # Contains literal newline if [[ "$cmd" == *$'\n'* ]]; then return 0 fi # Ends with backslash (line continuation) if [[ "$cmd" == *'\' ]]; then return 0 fi # Contains heredoc if [[ "$cmd" == *'<<'* ]]; then return 0 fi # Contains compound command keywords that suggest multi-line constructs local keywords='(^|[;&| ])(\{|\}|function |case |select |for |while |until |coproc )' if [[ "$cmd" =~ $keywords ]]; then return 0 fi # Contains '; do' or '; then' patterns (inline loops/conditionals) if [[ "$cmd" == *'; do'* ]] || [[ "$cmd" == *'; then'* ]]; then return 0 fi # Contains command group parentheses if [[ "$cmd" == *'( '* ]] || [[ "$cmd" == *' )'* ]]; then return 0 fi return 1 } _tirith_startup_health_check() { # Test-only override: bypass startup gate to reach runtime failure paths in PTY tests. [[ "${_TIRITH_TEST_SKIP_HEALTH:-}" == "1" ]] && return 0 # Test-only override for CI (avoids needing PTY) [[ "${_TIRITH_TEST_FAIL_HEALTH:-}" == "1" ]] && return 1 # Verify both \C-m and \C-j are bound to _tirith_enter local binds binds="$(bind -X 2>/dev/null)" || return 1 [[ "$binds" =~ \\C-m.*_tirith_enter ]] || return 1 [[ "$binds" =~ \\C-j.*_tirith_enter ]] || return 1 # Verify prompt hook is still attached _tirith_is_prompt_hook_attached || return 1 return 0 } if [[ "$_TIRITH_BASH_MODE" == "enter" ]] && [[ $- == *i* ]]; then # Enter mode: interactive shell only (bind-x requires readline). # Non-interactive sourcing (bash -c, scripts, BASH_ENV) skips this entire block. # Mode variable stays "enter" but nothing is installed — effectively a no-op. # No traps, no bindings, no state writes in non-interactive context. _TIRITH_BINDS_INSTALLED=0 # Attach prompt hook (gates further setup) if ! _tirith_ensure_prompt_hook; then _tirith_degrade_to_preexec "PROMPT_COMMAND is readonly or unattachable" else _tirith_enter() { # Save terminal state — bind -x can corrupt echo in some PTY environments (gcloud ssh, etc.) local _saved_stty _saved_stty=$(stty -g 2>/dev/null) || true # Ensure terminal state is restored on exit trap 'stty "$_saved_stty" 2>/dev/null || true' RETURN # Self-heal: verify prompt hook is still attached if ! _tirith_ensure_prompt_hook; then _tirith_degrade_to_preexec "PROMPT_COMMAND reattachment failed" return # READLINE_LINE stays intact fi # Detect broken delivery: if previous pending was never consumed if [[ -n "${_TIRITH_PENDING_EVAL:-}" || -n "${_TIRITH_PENDING_SOURCE:-}" ]]; then [[ -n "${_TIRITH_PENDING_SOURCE:-}" ]] && command rm -f "${_TIRITH_PENDING_SOURCE}" unset _TIRITH_PENDING_EVAL _TIRITH_PENDING_SOURCE _tirith_degrade_to_preexec "previous command not delivered (check shell history)" return # READLINE_LINE stays intact fi # Empty input: just return (shows new prompt) if [[ -z "$READLINE_LINE" ]]; then READLINE_LINE="" READLINE_POINT=0 return fi # Check for incomplete input (open quotes, unclosed blocks) local syntax_err syntax_err=$(bash -n <<< "$READLINE_LINE" 2>&1) local syntax_rc=$? if [[ $syntax_rc -ne 0 ]] && [[ "$syntax_err" == *"unexpected EOF"* || "$syntax_err" == *"unexpected end of file"* ]]; then # Incomplete input: insert newline for continued editing READLINE_LINE+=$'\n' READLINE_POINT=${#READLINE_LINE} return fi # Run tirith check with approval workflow (stdout=approval file path, stderr=human output) local errfile=$(mktemp) local approval_path local _tirith_prev_internal="${_TIRITH_BASH_INTERNAL:-0}" _TIRITH_BASH_INTERNAL=1 approval_path=$(command tirith check --approval-check --non-interactive --interactive --shell posix -- "$READLINE_LINE" 2>"$errfile") local rc=$? _TIRITH_BASH_INTERNAL="$_tirith_prev_internal" local output=$(<"$errfile") command rm -f "$errfile" # Exit code 3 (WarnAck): stdout has two lines — approval path + warn-ack path. # Split them so approval workflow gets the right file. local warn_ack_path="" if [[ $rc -eq 3 ]]; then local _first_line _rest IFS=$'\n' read -r _first_line <<< "$approval_path" _rest="${approval_path#*$'\n'}" if [[ "$_rest" != "$approval_path" ]]; then warn_ack_path="$_rest" fi approval_path="$_first_line" fi if [[ $rc -eq 0 ]]; then : # Allow: no output elif [[ $rc -eq 2 || $rc -eq 3 ]]; then local escaped_line escaped_line=$(_tirith_escape_preview "$READLINE_LINE") _tirith_output "" _tirith_output "command> $escaped_line" [[ -n "$output" ]] && _tirith_output "$output" elif [[ $rc -eq 1 ]]; then local escaped_line escaped_line=$(_tirith_escape_preview "$READLINE_LINE") _tirith_output "" _tirith_output "command> $escaped_line" [[ -n "$output" ]] && _tirith_output "$output" else # Unexpected exit code: degrade to preexec local escaped_line escaped_line=$(_tirith_escape_preview "$READLINE_LINE") _tirith_output "" _tirith_output "command> $escaped_line" [[ -n "$output" ]] && _tirith_output "$output" [[ -n "$approval_path" ]] && command rm -f "$approval_path" [[ -n "$warn_ack_path" ]] && command rm -f "$warn_ack_path" _tirith_degrade_to_preexec "tirith returned unexpected exit code $rc" return # READLINE_LINE preserved for re-execution via preexec fi # Approval workflow: runs for ALL exit codes (0, 1, 2, 3). # For rc=1 (block), approval gives user a chance to override. if [[ -n "$approval_path" ]]; then _tirith_parse_approval "$approval_path" if [[ "$_tirith_ap_required" == "yes" ]]; then _tirith_output "tirith: approval required for $_tirith_ap_rule" [[ -n "$_tirith_ap_desc" ]] && _tirith_output " $_tirith_ap_desc" local response="" if [[ "$_tirith_ap_timeout" -gt 0 ]]; then read -t "$_tirith_ap_timeout" -p "Approve? (${_tirith_ap_timeout}s timeout) [y/N] " response /dev/null else read -p "Approve? [y/N] " response /dev/null fi if [[ "$response" == [yY]* ]]; then : # Approved: fall through to execute else case "$_tirith_ap_fallback" in allow) _tirith_output "tirith: approval not granted — fallback: allow" ;; warn) _tirith_output "tirith: approval not granted — fallback: warn" ;; *) _tirith_output "tirith: approval not granted — fallback: block" [[ -n "$warn_ack_path" ]] && command rm -f "$warn_ack_path" READLINE_LINE="" READLINE_POINT=0 return ;; esac fi elif [[ $rc -eq 1 ]]; then # Approval not required but command was blocked: honor block [[ -n "$warn_ack_path" ]] && command rm -f "$warn_ack_path" READLINE_LINE="" READLINE_POINT=0 return fi elif [[ $rc -eq 1 ]]; then # No approval file: honor block READLINE_LINE="" READLINE_POINT=0 return fi # Warn-ack workflow (exit code 3): strict_warn requires explicit acknowledgement if [[ $rc -eq 3 && -n "$warn_ack_path" ]]; then _tirith_parse_warn_ack "$warn_ack_path" local response="" read -p "tirith: proceed with ${_tirith_wa_findings} warning(s)? [y/N] " response /dev/null if [[ "$response" == [yY]* ]]; then : # Acknowledged: fall through to execute else _tirith_output "tirith: warnings not acknowledged — command blocked" READLINE_LINE="" READLINE_POINT=0 return fi elif [[ -n "$warn_ack_path" ]]; then command rm -f "$warn_ack_path" fi # Execute the command (approval workflow above handled block cases) local cmd="$READLINE_LINE" READLINE_LINE="" READLINE_POINT=0 # Check if safe to eval if _tirith_unsafe_to_eval "$cmd"; then # Unsafe for eval: fall back to preexec-style warn-only # Add to history and print warning that blocking is limited history -s -- "$cmd" >&2 printf 'tirith: complex command — executing without block capability\n' # Write to a temp file and source it to avoid eval pitfalls local tmpf tmpf=$(mktemp "${TMPDIR:-/tmp}/tirith.XXXXXX") || { # If mktemp fails, defer direct eval — fail-open _TIRITH_PENDING_EVAL="$cmd" return } printf '%s\n' "$cmd" > "$tmpf" _TIRITH_PENDING_SOURCE="$tmpf" return fi history -s -- "$cmd" _TIRITH_PENDING_EVAL="$cmd" } # Bracketed paste interception _tirith_paste() { # Save terminal state — bind -x can corrupt echo in some PTY environments (gcloud ssh, etc.) local _saved_stty _saved_stty=$(stty -g 2>/dev/null) || true trap 'stty "$_saved_stty" 2>/dev/null || true' RETURN # Read pasted content until bracketed paste end sequence (\e[201~) local pasted="" local char while IFS= read -r -n 1 -d '' -t 1 char; do pasted+="$char" # Check for end of bracketed paste if [[ "$pasted" == *$'\e[201~' ]]; then # Strip the end sequence pasted="${pasted%$'\e[201~'}" break fi done # Honor explicit TIRITH=0 bypass: skip paste scanning if [[ "${TIRITH:-}" == "0" ]]; then READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}${pasted}${READLINE_LINE:$READLINE_POINT}" READLINE_POINT=$((READLINE_POINT + ${#pasted})) return fi if [[ -n "$pasted" ]]; then # Check with tirith paste, use temp file to prevent tty leakage local tmpfile=$(mktemp) local _tirith_prev_internal="${_TIRITH_BASH_INTERNAL:-0}" _TIRITH_BASH_INTERNAL=1 printf '%s' "$pasted" | command tirith paste --shell posix --interactive >"$tmpfile" 2>&1 local rc=$? _TIRITH_BASH_INTERNAL="$_tirith_prev_internal" local output=$(<"$tmpfile") command rm -f "$tmpfile" if [[ $rc -eq 0 ]]; then # Allow: fall through to insert : elif [[ $rc -eq 2 ]]; then # Warn: show warning, fall through to insert [[ -n "$output" ]] && { _tirith_output ""; _tirith_output "$output"; } else # Block (rc=1) or unexpected: discard paste (safe — user can re-paste) local escaped_paste escaped_paste=$(_tirith_escape_preview "$pasted") _tirith_output "" _tirith_output "paste> $escaped_paste" [[ -n "$output" ]] && _tirith_output "$output" [[ $rc -ne 1 ]] && _tirith_output "tirith: paste check failed (exit code $rc)" return fi fi # Allow: insert into readline buffer READLINE_LINE="${READLINE_LINE:0:$READLINE_POINT}${pasted}${READLINE_LINE:$READLINE_POINT}" READLINE_POINT=$((READLINE_POINT + ${#pasted})) } # Install key bindings bind -x '"\C-m": _tirith_enter' || true bind -x '"\C-j": _tirith_enter' || true bind -x '"\e[200~": _tirith_paste' || true _TIRITH_BINDS_INSTALLED=1 # Startup health gate: verify bind-x took effect for BOTH keys if ! _tirith_startup_health_check; then _tirith_degrade_to_preexec "startup health check failed (bind-x or PROMPT_COMMAND)" fi fi fi # Exit summary: show session warnings on shell exit _tirith_exit_summary() { [[ -n "${TIRITH_SESSION_ID:-}" ]] || return local _sd="${XDG_STATE_HOME:-$HOME/.local/state}/tirith" [[ -f "$_sd/sessions/$TIRITH_SESSION_ID.json" ]] || return command tirith warnings --summary } _tirith_prev_exit_trap=$(trap -p EXIT 2>/dev/null | sed "s/^trap -- '//;s/' EXIT$//") if [[ -n "$_tirith_prev_exit_trap" ]]; then eval "trap '${_tirith_prev_exit_trap}; _tirith_exit_summary' EXIT" else trap '_tirith_exit_summary' EXIT fi unset _tirith_prev_exit_trap # Install the DEBUG trap as the absolute last step so no more internal hook # code fires it during sourcing. The enter-mode path installs its own bind-x # earlier; the degrade path installs DEBUG on demand inside # `_tirith_degrade_to_preexec`. if [[ "$_TIRITH_BASH_MODE" == "preexec" ]] && [[ $- == *i* ]]; then _tirith_install_debug_trap fi