Security
- **safelint no longer follows symlinked source files out of the linted tree, in any mode.** `os.walk(followlinks=False)` blocks descent into symlinked *directories*, but a symlinked *file* was still admitted by the `is_file()` guard (which follows the link). A cloned repo could commit `evil.py -> /etc/passwd` (or `key.py -> ~/.ssh/id_rsa`) and have that out-of-tree target read and even echoed a line at a time into the violation gutter. Symlinks are now handled **follow-if-resolved-within-tree**: a symlinked file (or a symlinked top-level directory target - `os.walk` follows the top argument regardless of `followlinks=False`) is followed **only when its real path stays inside the repo root** (the resolved cwd). So a monorepo's in-tree symlink (`app/config.py -> ../shared/config.py`, `shared/` under the repo) is still linted, while one escaping the tree is skipped with a warning. The check runs in **both** directory discovery **and** the central pre-read guard, so the pre-commit hook and single-file/explicit-path runs (which reach `check_file` directly, bypassing discovery) enforce the same boundary. An unresolvable symlink (loop or broken link, including Python 3.13+ where `resolve()` returns a still-symlink path for a loop rather than raising) fails closed.
- **Terminal output now visualises control characters in attacker-controlled strings.** The pretty renderer echoed the offending source line (plus the file path and rule message) verbatim to the TTY; a crafted line containing raw ANSI / OSC escapes could clear or redraw the terminal, spoof "All checks passed." output, set the window title, or drive OSC 52 clipboard writes when its violation was rendered. C0 (except tab), DEL, and C1 bytes are now shown as visible `\xNN` escapes, matching how ruff / ripgrep / git sanitise terminal output, and Unicode bidi-override / zero-width code points (Trojan Source, CVE-2021-42574) become visible `\uNNNN` escapes so they cannot visually reorder or hide the flagged text. One shared sanitiser (in `_diagnostics`) now covers **every** output surface: the stdout violation gutter, the **stderr diagnostic warnings** (e.g. a symlink-skip warning echoing a crafted filename), and the **JSON / SARIF** string fields (`message`, `filepath`, suggestion `description` / `replacement`) - `json.dumps(ensure_ascii=False)` escapes C0 but passed C1 and bidi code points through verbatim, so those formatters are now sanitised too (SARIF URIs stay percent-encoded).
- **`test_dirs` containment now also rejects symlinked relative entries.** The v2.7.1 (H3) lexical `..` containment stopped string-based escapes, but a committed `tests -> /etc` symlink passed the lexical check and let the coverage-rule `rglob` follow it out of the tree (a weak existence oracle, no content read). Relative entries whose real path escapes the project root are now dropped (and a symlink loop or an unreadable ancestor during that check is treated as "cannot prove containment" and dropped, rather than crashing discovery - including on Python 3.13+, where `resolve()` no longer raises on a loop and a still-symlink result is rejected instead; the downstream `rglob` is additionally guarded so an ancestor-level loop cannot crash discovery on any version); absolute entries remain honoured as the documented opt-in.
- **Third-party pre-commit hooks are pinned to full commit SHAs** (`pre-commit-hooks`, `ruff-pre-commit`), matching the H9 workflow-pinning convention, so a moved upstream tag cannot alter what runs on a contributor's machine.