# tirith fish hook # Binds Enter to check commands before execution. # Guard against double-loading (session-local only). # If inherited from environment (exported by attacker/parent), ignore it. if set -q _TIRITH_FISH_LOADED if set -q -x _TIRITH_FISH_LOADED set -e _TIRITH_FISH_LOADED # Inherited from env — ignore and load fresh else return # Set in this session — genuine double-source guard end end set -g _TIRITH_FISH_LOADED 1 # Session tracking: generate ID per shell session if not inherited if not set -q TIRITH_SESSION_ID set -gx TIRITH_SESSION_ID (printf '%x-%x' %self (date +%s)) end # Output helper: write to stderr by default. # Override via TIRITH_OUTPUT=tty to write to /dev/tty instead. function _tirith_output if test "$TIRITH_OUTPUT" = "tty" printf '%s\n' $argv >/dev/tty else printf '%s\n' $argv >&2 end end function _tirith_escape_preview string escape -- $argv[1] end function _tirith_parse_approval set -g _tirith_ap_required "no" set -g _tirith_ap_timeout 0 set -g _tirith_ap_fallback "block" set -g _tirith_ap_rule "" set -g _tirith_ap_desc "" if not test -r "$argv[1]" _tirith_output "tirith: warning: approval file missing or unreadable, failing closed" command rm -f "$argv[1]" # delete on all paths set -g _tirith_ap_required "yes" set -g _tirith_ap_fallback "block" return 1 end set -l valid_keys 0 while read -l line set -l parts (string split -m1 = "$line") if test (count $parts) -ge 2 switch $parts[1] case TIRITH_REQUIRES_APPROVAL set -g _tirith_ap_required $parts[2] set valid_keys (math $valid_keys + 1) case TIRITH_APPROVAL_TIMEOUT set -g _tirith_ap_timeout $parts[2] case TIRITH_APPROVAL_FALLBACK set -g _tirith_ap_fallback $parts[2] case TIRITH_APPROVAL_RULE set -g _tirith_ap_rule $parts[2] case TIRITH_APPROVAL_DESCRIPTION set -g _tirith_ap_desc $parts[2] end end end < "$argv[1]" command rm -f "$argv[1]" if test $valid_keys -eq 0 _tirith_output "tirith: warning: approval file corrupt, failing closed" set -g _tirith_ap_required "yes" set -g _tirith_ap_fallback "block" return 1 end return 0 end function _tirith_parse_warn_ack set -g _tirith_wa_findings 0 set -g _tirith_wa_max_severity "" if not test -r "$argv[1]" command rm -f "$argv[1]" return 1 end while read -l line set -l parts (string split -m1 = "$line") if test (count $parts) -ge 2 switch $parts[1] case TIRITH_WARN_ACK_FINDINGS set -g _tirith_wa_findings $parts[2] case TIRITH_WARN_ACK_MAX_SEVERITY set -g _tirith_wa_max_severity $parts[2] end end end < "$argv[1]" command rm -f "$argv[1]" return 0 end # Save original key bindings function BEFORE defining our new one if functions -q fish_user_key_bindings; and not functions -q _tirith_original_fish_user_key_bindings functions -c fish_user_key_bindings _tirith_original_fish_user_key_bindings end # Wrap fish_clipboard_paste to intercept clipboard paste operations if functions -q fish_clipboard_paste; and not functions -q _tirith_original_fish_clipboard_paste functions -c fish_clipboard_paste _tirith_original_fish_clipboard_paste function fish_clipboard_paste # Honor TIRITH=0 bypass: skip paste scanning if test "$TIRITH" = "0" _tirith_original_fish_clipboard_paste return end set -l content (_tirith_original_fish_clipboard_paste | string collect) if test -z "$content" return end set -l tmpfile (mktemp) echo -n "$content" | command tirith paste --shell fish --interactive >$tmpfile 2>&1 set -l rc $status set -l output (string collect < $tmpfile) command rm -f $tmpfile if test $rc -eq 0 # Allow: fall through to echo else if test $rc -eq 2 if test -n "$output" _tirith_output "" _tirith_output "$output" commandline -f repaint end # Warn: fall through to echo else # Block or unexpected: discard set -l escaped_content (_tirith_escape_preview "$content") _tirith_output "" _tirith_output "paste> $escaped_content" if test -n "$output" _tirith_output "$output" end if test $rc -ne 1 _tirith_output "tirith: unexpected exit code $rc — paste blocked for safety" end commandline -f repaint return end echo -n "$content" end end function _tirith_check_command set -l cmd (commandline) # Empty input: execute normally if test -z "$cmd" commandline -f execute return end # Run tirith check with approval workflow (stdout=approval file path, stderr=human output). # Redirect both stdout and stderr to temp files instead of using command substitution — # fish 4.0+ changed terminal mode handling for external commands in key bindings, # and command substitution (set -l x (cmd)) can hang in that context. set -l outfile (mktemp) set -l errfile (mktemp) command tirith check --approval-check --non-interactive --interactive --shell fish -- "$cmd" >$outfile 2>$errfile set -l rc $status # Read stdout lines: line 1 = approval path, line 2 = warn-ack path (exit code 3 only) set -l approval_path "" set -l warn_ack_path "" if test -s $outfile set -l _stdout_lines (string split \n < $outfile) if test (count $_stdout_lines) -ge 1 set approval_path $_stdout_lines[1] end if test (count $_stdout_lines) -ge 2 set warn_ack_path $_stdout_lines[2] end end set -l output "" if test -s $errfile set output (string collect < $errfile) end command rm -f $outfile $errfile if test $rc -eq 0 # Allow: no output else if test $rc -eq 2; or test $rc -eq 3 set -l escaped_cmd (_tirith_escape_preview "$cmd") _tirith_output "" _tirith_output "command> $escaped_cmd" if test -n "$output" _tirith_output "$output" end else if test $rc -eq 1 set -l escaped_cmd (_tirith_escape_preview "$cmd") _tirith_output "" _tirith_output "command> $escaped_cmd" if test -n "$output" _tirith_output "$output" end else # Unexpected rc: warn + execute (fail-open to avoid terminal breakage) _tirith_output "" if test -n "$output" _tirith_output "$output" end _tirith_output "tirith: unexpected exit code $rc — running unprotected" test -n "$approval_path"; and command rm -f "$approval_path" test -n "$warn_ack_path"; and command rm -f "$warn_ack_path" commandline -f execute return end # Approval workflow: runs for ALL exit codes (0, 1, 2, 3). # For rc=1 (block), approval gives user a chance to override. if test -n "$approval_path" _tirith_parse_approval "$approval_path" if test "$_tirith_ap_required" = "yes" _tirith_output "tirith: approval required for $_tirith_ap_rule" if test -n "$_tirith_ap_desc" _tirith_output " $_tirith_ap_desc" end set -l response "" if test "$_tirith_ap_timeout" -gt 0 # Fish read has no timeout flag; delegate to bash read -t set -l timeout_s $_tirith_ap_timeout if command -q bash set response (bash -c 'read -t '"$timeout_s"' -p "Approve? ('"$timeout_s"'s timeout) [y/N] " r /dev/null && echo "$r" || echo ""') else # Fallback: blocking read (no timeout support without bash) read -P "Approve? [y/N] " response end else read -P "Approve? [y/N] " response end if string match -qi 'y*' -- "$response" # Approved: fall through to execute else switch $_tirith_ap_fallback case allow _tirith_output "tirith: approval not granted — fallback: allow" case warn _tirith_output "tirith: approval not granted — fallback: warn" case '*' _tirith_output "tirith: approval not granted — fallback: block" test -n "$warn_ack_path"; and command rm -f "$warn_ack_path" commandline -r "" commandline -f repaint return 1 end end else if test $rc -eq 1 # Approval not required but command was blocked: honor block test -n "$warn_ack_path"; and command rm -f "$warn_ack_path" commandline -r "" commandline -f repaint return 1 end else if test $rc -eq 1 # No approval file: honor block commandline -r "" commandline -f repaint return 1 end # Warn-ack workflow (exit code 3): strict_warn requires explicit acknowledgement if test $rc -eq 3; and test -n "$warn_ack_path" _tirith_parse_warn_ack "$warn_ack_path" set -l response "" read -P "tirith: proceed with $_tirith_wa_findings warning(s)? [y/N] " response if string match -qi 'y*' -- "$response" # Acknowledged: fall through to execute else _tirith_output "tirith: warnings not acknowledged — command blocked" commandline -r "" commandline -f repaint return 1 end else if test -n "$warn_ack_path" command rm -f "$warn_ack_path" end commandline -f execute end function _tirith_bind_enter # Default/emacs mode — bind both the legacy escape codes (\r/\n) and # the symbolic 'enter' name so the hook fires regardless of whether # the terminal uses the kitty keyboard protocol (fish 4.0+). bind \r _tirith_check_command bind \n _tirith_check_command bind enter _tirith_check_command 2>/dev/null # fish 4.0+ symbolic name # Vi insert mode bind -M insert \r _tirith_check_command 2>/dev/null bind -M insert \n _tirith_check_command 2>/dev/null bind -M insert enter _tirith_check_command 2>/dev/null # Vi default/normal mode (no -m insert to avoid Ghostty input freeze) bind -M default \r _tirith_check_command 2>/dev/null bind -M default \n _tirith_check_command 2>/dev/null bind -M default enter _tirith_check_command 2>/dev/null # Vi replace mode (no -m insert to avoid Ghostty input freeze) bind -M replace \r _tirith_check_command 2>/dev/null bind -M replace \n _tirith_check_command 2>/dev/null bind -M replace enter _tirith_check_command 2>/dev/null end # Bind immediately _tirith_bind_enter # Hook into fish_user_key_bindings for any future rebinds function fish_user_key_bindings if functions -q _tirith_original_fish_user_key_bindings _tirith_original_fish_user_key_bindings end _tirith_bind_enter end