# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.3.3] - 2026-06-19

### Added

- **Evasion-resistant prompt-injection detection:** a shared `deobfuscate` text-normalization pass (zero-width / invisible strip, Unicode confusable skeleton, NFKC, inter-character whitespace collapse, bounded leetspeak fold, and short base64 / hex decode behind a printable gate). Prompt-injection and config-file scanning now run against these normalized variants in addition to the raw input; raw scanning is never replaced. Typoglycemia (character transposition) is out of scope for now.
- **`PromptInjectionObfuscated`** (High): a new rule for an injection seed that matches only after deobfuscation, where the obfuscation itself is the signal. Raw matches still fire `IgnorePreviousInstructions` / `PromptInjectionInOutput`.
- **`OutputDataExfiltration`** (High, MITRE T1041): a new `exfil` rule for markdown / URL beacons carrying a secret-shaped token or canary, secret-shaped URL query values, and "read a sensitive path then send / post / upload" directives (including the "do not tell the user" stealth directive).
- **`injection_seeds_custom`** policy field: add your own prompt-injection seed regexes via `.tirith/policy.yaml`, user, or org policy. A repo-scoped policy may add seeds (it can only tighten). Invalid regexes are reported by `tirith policy validate` and skipped rather than failing the load.
- **`mcp_redact_injection`** policy field (default off): opt-in to downgrade an injection-only MCP tool-output block to a redacted warning, blanking the seed spans and forwarding the rest. Only user / org scope can enable it (a repo-scoped policy cannot weaken this), and it refuses to downgrade when any non-injection finding blocks or when structured content is present.
- **threatdb `ExfilEndpoint` source** (Primary tier): a known-exfiltration-endpoint hostname source, populated from the CI threat feed.

### Fixed

- **`.deb` / `verify-self` byte mismatch (issue #146):** the Debian package now ships the exact canonical release binary instead of a separate rebuild, so an apt-installed tirith passes `tirith verify-self`. A release CI guard asserts the `.deb` binary is byte-identical to the published tarball binary. The `.rpm` is still rebuilt against the target distro's glibc (so it keeps running on RHEL / Rocky 9) and is intentionally not byte-identical.
- **`verify-self` no longer falsely flags source-built installs:** `cargo install`, AUR, and the distribution `.rpm` are compiled from source or against a different libc, so they cannot be byte-compared to the generic release binary. They now report an honest "unverified" (exit 0) rather than a false "modified or replaced" failure, and AUR installs on Arch and its derivatives are detected as such. Homebrew is handled the same way: the homebrew-core formula builds from source (distributed as a bottle), so a mismatch downgrades to "unverified" naming `brew install sheeki03/tap/tirith`, while the prebuilt tap binary still verifies.
- **Scoop autoupdate URL:** the release workflow no longer corrupts the manifest's autoupdate template (a loose version-matching pattern was rewriting the literal `v$version` to `v0.3.2$version`).

## [0.3.2] - 2026-06-16

### Added

- **M5 platform & reach:** `tirith lab` (offline adversarial training corpus); Windows / PowerShell rules (ExecutionPolicy bypass, Defender exclusions, inline IEX download-execute).
- **M5.5 / M6 install & fix:** `tirith fix` (interactive safe-command rewriter); `tirith install` across 12 backends; `tirith doctor --quick`.
- **M7 output & egress firewall:** `tirith view`, `output`, `share`, `redact`, `clipboard`, `logs`; output-direction detection (OSC 52 clipboard writes, fake prompts, hyperlink / title manipulation); `gateway run --filter-output`, `mcp-server --sanitize-tool-output`.
- **M8 operational context:** `tirith context`, `ssh`, `iac`, `sudo`, `devcontainer`, `codespaces`, `prompt-status`; labeled-prod, IaC-apply, sudo-escalation, and container rules.
- **M9 workstation hygiene:** `tirith hygiene`, `persistence`, `aliases`, `env`, `exec`, `path`, `hooks`; credential-hygiene, persistence-foothold, PATH-hijack, and executable-provenance rules.
- **M10 blast radius & isolation:** `tirith preview`, `watch`, `taint`, `intend`, `baseline`, `temp-run`, `fetch --save`; blast-radius, taint, and opt-in anomaly rules.
- **M11 trust ecosystem:** `tirith command-card`, `commands`, `canary`, `secret`, `incident`; signed-attestation and canary-honeytoken rules.
- **M12 browser & visual provenance:** `tirith visual-audit`, `browser` (Chrome native-messaging host), `paste --with-source`; `paste_source_mismatch` rule.
- **M13 onboarding, dashboard & rule DSL:** `tirith onboard`, `dashboard`, `rule` (custom `when:` predicate DSL), `ai`; AI-config drift rules; 7 policy templates.
- **M14 IDE integration:** `tirith lsp` language server with editor analysis profiles.
- **Agent-session guardrails:** `tirith pending` (decision registry), `tirith audit verify` (tamper-evident hash chain), per-rule suppression cooldown, checkpoint restore reporting, agent-memory config scanning, deferred outcome (`tirith check --defer`, exit code 4), and cross-event correlation rules.
- **CLI transparency & safety UX:** `tirith status` (protection health, non-zero on degrade), `tirith policy effective`, `tirith trust from-last-trigger`, global `--quiet` / `TIRITH_QUIET`, `tirith daemon start --detach`; `tirith check --suggest` (renamed from `--suggest-safe-command`).

- **Agent governance — engine wiring, bypass-path origin stamp, and shared YAML helper (M4 item 8 chunk 3)** — chunk 3 of the per-agent governance work. Turns chunk 2's observation-only `agent_rules` schema into enforcement, fixes the engine's bypass-path double-log so a `TIRITH=0`-bypassed BLOCK records exactly one audit entry with `agent_origin` populated, and consolidates the duplicated `yaml_safe_scalar` / `yaml_safe_inline_comment` helpers into a single shared module. **(1) Engine wiring — `apply_agent_rules` runs inside `post_process_verdict`.** A new helper `crate::escalation::apply_agent_rules(&mut Verdict, &Policy)` consults `policy::agent_decision` against `verdict.agent_origin` and applies the **minimal chunk-3 cut**: `Denied` forces `action = Block` and appends a fresh `Finding { rule_id: RuleId::AgentDeniedByPolicy, severity: High, … }` whose description names the matched origin (Debug-escaped) and the policy file path; `Allowed` and `Unspecified` leave the verdict alone (and `agent_origin: None` is treated as `Unspecified` — an engine path that never set an origin has nothing to match against). The splice is after escalation and before warning recording so escalation-driven Blocks stay Block and the Warn-recording path correctly skips when chunk 3 flips to Block. Existing detection findings are preserved — `agent_rules` layers on top, never replaces. **Allow is NOT a bypass** — a verdict the engine already blocked stays blocked even if the caller is on the allow-list; richer "trusted agent" semantics (severity overrides on `allow`, `approval_required: true`, per-origin `fail_mode`) are deferred to a future chunk pending real telemetry from chunk-2's `tirith agent sessions` reads. **(2) New `RuleId::AgentDeniedByPolicy`** wired through every safeguard touchpoint: `verdict.rs` (enum variant), `scoring.rs` (the structural arm of `is_threat_intel_rule` returns `false`), `build.rs` (`EXPECTED_RULES`), `golden_fixtures.rs` (`ALL_RULE_IDS` + `EXTERNALLY_TRIGGERED_RULES` since the rule fires from policy state, not from input characters), `assets/data/rule_explanations.toml` (full operator-facing entry — title, category `policy`, severity rationale, threat-model recap, false-positive guidance pointing at `tirith agent allow`, remediation). The four `golden_fixtures.rs` safeguard tests (`test_all_rule_ids_have_fixture_coverage`, `test_no_url_rules_have_no_url_fixtures`, `test_extractor_ids_cover_rule_triggers`, `test_tier1_does_not_gate_findings`) and `test_rule_id_list_is_complete` all pass — every touchpoint stays in sync. **(3) Bypass-path origin stamp.** Pre-chunk-3, when `TIRITH=0` was honored, `engine::analyze_inner` called `crate::audit::log_verdict` *itself* — inside the engine, before the CLI got a chance to set `agent_origin`. Then the CLI also called `log_verdict`, producing a double audit entry where the first entry was missing origin and the second one had it. Chunk 3 removes the engine's bypass-path audit entirely; the caller is now the **single audit site for the engine-driven analysis paths** (`cli/check.rs`, `cli/paste.rs`, `mcp/tools.rs::call_check_command`) and always stamps `agent_origin` first. `cli/paste.rs` and `mcp/tools.rs` previously SKIPPED audit on `bypass_honored` (trusting `analyze()` to have logged); both now audit unconditionally after stamping origin. `cli/check.rs` was already auditing in this branch; it now stops double-logging because the engine no longer logs. The same review-round pass extended the stamping invariant to the **analysis-then-audit** paths that previously called `audit::log_verdict` without setting `verdict.agent_origin`: `cli/install.rs` (both the package-manager and URL forms) and `cli/ecosystem.rs::scan` now resolve the CLI origin (`resolve_cli_origin(interactive)`) and stamp it on the verdict before the audit write, so `tirith install` and `tirith ecosystem scan` audit lines are attributed instead of landing in `tirith agent sessions`' "unknown" group. The **gateway** path stamps `AgentOrigin::Gateway` on the in-memory raw verdict in chunk 1 and persists the same constant in its local stderr-JSONL `AuditEntry<'a>` via a new always-set `agent_origin` field — closing a pre-chunk-3-follow-up gap where the in-memory verdict carried origin but the serialized line dropped it. The audit-aggregator `tirith agent sessions` documentation in `crates/tirith/src/main.rs` is updated to reflect the closed audit sites; the help text is explicit that origin attribution is best-effort and a future analysis-then-audit path that does not stamp origin will still land its entries in the `"unknown"` group rather than be silently attributed. **(4) Chunk-2 test retired.** `agent_rules_chunk2_loading_changes_no_verdict` (in `crates/tirith-core/src/policy.rs`) was the explicit "chunk 3 must retire this" contract; it's gone, replaced by five chunk-3 tests in `crates/tirith-core/src/escalation.rs::tests`: `agent_rules_deny_forces_block_on_allow_verdict`, `agent_rules_deny_keeps_block_on_already_blocked_verdict`, `agent_rules_allow_does_not_bypass_block`, `agent_rules_unspecified_leaves_verdict_unchanged`, and the critical regression guard `agent_rules_unset_does_not_introduce_finding` (legacy policies with no `agent_rules` block stay byte-for-byte identical to pre-chunk-3 behavior). Plus `apply_agent_rules_returns_true_only_on_denied` (helper signature), `apply_agent_rules_no_origin_is_treated_as_unspecified` (None handling), and `agent_rules_finding_description_escapes_hostile_origin_payload` (Debug-format pinning so a future control-byte-bearing origin can't leak through the audit description). The narrower "engine::analyze itself ignores agent_rules" claim — which IS still true; enforcement lives in `post_process_verdict`, not the raw engine — is pinned by a new `engine_analyze_does_not_consult_agent_rules` test that replaces the retired one. The chunk-2 field-level invariant (`agent_rules_chunk2_observation_only_invariant`) is preserved with an updated docstring noting it now guards against `agent_rules` accidentally bleeding into adjacent mechanisms (allowlist / blocklist / severity overrides). **(5) Shared YAML helper module — `crates/tirith/src/cli/yaml.rs`.** Pre-chunk-3, `yaml_safe_scalar` and `yaml_safe_inline_comment` lived as two byte-identical copies in `cli/mcp.rs` and `cli/agent.rs`. Chunk 3 consolidates both into a single `pub(crate)` module so future changes (and the DEL-escape post-process that previously had to be ported by hand) live in one place. Both call sites now import `safe_scalar as yaml_safe_scalar` (and same for `safe_inline_comment` + `YAML_NEEDS_QUOTING_BYTES`) so the existing call-site code and tests stay readable. The exhaustive round-trip test in `cli/mcp.rs::yaml_safe_scalar_round_trips_through_yaml_parser` (every YAML special character, every C0 control byte, DEL, multi-byte UTF-8, empty string) still passes byte-for-byte; the shared module ships its own smoke-test suite (`safe_scalar_empty_becomes_quoted`, `safe_scalar_plain_identifier_is_bare`, `safe_scalar_quotes_yaml_indicator_byte`, `safe_scalar_quotes_control_bytes`, `safe_scalar_escapes_del_for_yaml_roundtrip`, `safe_inline_comment_passes_safe_strings_unchanged`, `safe_inline_comment_escapes_control_bytes`) so this module is green on its own. **(6) Design doc updated** — `docs/agent-governance-design.md` § 5 flipped from "shipped in chunk 2 (observation-only)" to a full chunk-3 status table mapping `AgentDecision` → verdict effect, and § 6's chunk-2 out-of-scope items "policy enforcement" and "the bypass-path double-log" are marked **Resolved in chunk 3**. A new chunk-3 out-of-scope block documents the deferred richer matcher payloads. **Trust model unchanged from chunks 1 + 2.** Every signal feeding `AgentOrigin` is still **operator-trust**, never adversary-resistant — `TIRITH_INTEGRATION`, MCP `clientInfo`, CI env vars, `is_terminal()` are all settable by any process running as the user. `agent_rules` enforcement layers on top of caller-claimed signals: a policy author who treats a `deny` match as "this came from a hostile caller" is wrong; they're rejecting whatever the caller claimed their identity is. Use this for **operator-trust scoping** (the operator declares "I don't run my MCP server's `tools/call` on commands my CI ran"), not for adversarial security. Like every other engine path, `apply_agent_rules` is **off the tier-1/2/3 detection hot path** and touches no network.

- **Agent governance — CLI surface + `agent_rules` policy schema (M4 item 8 chunk 2)** — chunk 2 of the per-agent governance work, building on chunk 1's `AgentOrigin` scaffolding. Chunk 2 was **observation-only**: it made chunk 1's recorded signal **inspectable** and added the policy schema chunk 3 wires on, but no engine path consulted `agent_rules` to change a verdict in this chunk. **Enforcement landed in chunk 3.** The original regression test `agent_rules_chunk2_loading_changes_no_verdict` (in `crates/tirith-core/src/policy.rs`) was retired in chunk 3 as that chunk's contract required. (1) **New CLI group — `tirith agent`** — four subcommands grouped under a single verb mirroring `tirith mcp`. `tirith agent sessions` reads the local JSONL audit log, groups every `verdict` entry by its recorded `AgentOrigin`, and reports per-group counts, last-seen timestamps, and an Allow/Warn/Block histogram (with `WarnAck` folded into the warn bucket). Hook-telemetry rows are filtered out (those are not verdicts and carry `agent_origin: None` by design). Entries with no `agent_origin` land in an explicit `"unknown"` bucket rather than being silently dropped — honesty over apparent tidiness. `--format json` emits a stable envelope (`schema_version`, `log_path`, `group_count`, `total_entries`, `groups`). A missing audit log is NOT an error: it reports plainly with zero groups and exits 0. `tirith agent explain <query>` drills into one session id (exact match) or command substring (case-insensitive, also against the rendered origin label so an operator can search for `"claude-code"`), surfacing up to 20 matches sorted newest-first with the attribution chain: `AgentOrigin`, redacted command, action, timestamps, rule IDs, bypass state, and policy path. `tirith agent policy init` scaffolds `.tirith/agent-policy.yaml.example` from the audit log's distinct observed origins — every entry **commented out** by design (mirrors `tirith mcp policy init`'s convention — importing a scaffold must never silently widen trust). The scaffold is **deterministic**: origins are sorted by `(kind, payload)`, the header is fixed, and no timestamps are embedded, so two runs against the same log produce a byte-identical file. A missing log writes a header-only template rather than failing — the operator gets a starting point either way. `--force` overwrites an existing example; without it, the example is preserved. `tirith agent allow --kind <kind> [--tool <name>]` validates an `(kind, tool?)` matcher pair and **prints the YAML snippet** an operator pastes under `agent_rules.allow:` in their policy. It deliberately does NOT mutate any policy file — `agent_rules` is observation-only today, and silently appending would suggest enforcement that does not exist yet; the operator integrates it themselves the same way they integrate `tirith mcp policy init`'s example output. Validation: `kind` must be `human` / `agent` / `mcp` / `gateway` / `ci` / `ide`; `--tool` on a payloadless kind (`human`, `gateway`) is rejected up-front because it would match nothing; an empty `--tool ""` is rejected for the same reason. (2) **Policy schema — `agent_rules`** — additive field on `ScanPolicyConfig`'s parent `Policy` struct. `AgentRules { allow: Vec<AgentMatcher>, deny: Vec<AgentMatcher> }`, with `AgentMatcher { kind: AgentOriginKind, tool: Option<String> }` where `AgentOriginKind` is a separate closed enum mirroring `AgentOrigin`'s discriminator (cleaner deserialization, rejects an unknown `kind` at policy-load time rather than silently matching nothing). String matching is **case-sensitive exact** — `claude-code` does not match `Claude Code`; the design doc records (Q2) that normalization is intentionally deferred until chunk 3 has a real telemetry sample set. (3) **Pure decision helper — `policy::agent_decision(&policy, &origin) -> AgentDecision`** — walks `deny` in declaration order (first match → `Denied`), then `allow` (first match → `Allowed`), else `Unspecified`. The helper is **not consulted by the engine in chunk 2**; chunk 3 will wire it into the verdict pipeline. A `tool` filter applied to `Human` or `Gateway` is harmless (those variants carry no caller-claimed payload — the helper matches nothing); the policy validator surfaces it as a warning so the operator notices the typo. (4) **Policy validator updates** — `policy_validate.rs` adds `agent_rules` to the known top-level fields list (so it doesn't trip the "unknown field" warning) and emits Warning-level diagnostics for matchers shaped wrong: `tool` on a payloadless kind, empty `tool: ""`. `threat_intel` was also added to the known list — a pre-existing typo-guard gap that's now closed. (5) **Policy template updates** — the `full`, `individual`, `ci-strict`, and `ai-agent-heavy` templates produced by `tirith policy init` now include documented but commented-out `agent_rules` examples tuned to each scenario (CI gets a `kind: ci, tool: github-actions` allow; ai-agent-heavy gets both allow and deny examples). All templates still validate. (6) **Design doc updated** — `docs/agent-governance-design.md` § 5 flipped from "preview, not built in this chunk" to "shipped in chunk 2", with command-line examples, exit-code semantics, the matcher shape decision (closed `kind` + free-form `tool`, per Q1's recommendation), and the schema-vs-enforcement boundary. Out-of-scope sections clearly delineate chunk-1, chunk-2, and chunk-3 boundaries. (7) **YAML safety** — server / origin payloads passed through `yaml_safe_scalar` are quoted-and-escaped before they reach the example file or the `tirith agent allow` snippet output. A maliciously-crafted tool name containing `:`, `#`, newline, or ANSI escape cannot break the YAML structure or inject control sequences when the operator `cat`s the file. The snippet round-trips cleanly through `serde_yaml` after pasting; a dedicated test covers a hostile-payload scaffold parsing back unchanged. **Trust model unchanged from chunk 1.** Every signal feeding `AgentOrigin` remains **operator-trust**, never adversary-resistant — `TIRITH_INTEGRATION`, MCP `clientInfo`, CI env vars, `is_terminal()` are all settable by any process running as the user. `agent_rules` is for filtering, dashboarding, and observability; if a policy decision must withstand a hostile environment, layer real authentication elsewhere. **No new `RuleId`.** Whether enforcement should produce a distinct `RuleId` or reuse an existing one is a chunk-3 decision. Like every other observability surface, every command is a **local file operation**: it touches no network and is off the tier-1/2/3 detection hot path.
- **Agent governance — design spike + observability scaffolding (M4 item 8 chunk 1)** — the first artifact of the per-agent governance work, deliberately split off from any enforcement code. (1) **Design document** at `docs/agent-governance-design.md` recording the threat model (every signal is operator-trust, not adversary-resistant — `TIRITH_INTEGRATION`, MCP `clientInfo`, `is_terminal()`, every CI provider env var is settable by any process running as the user), the closed enum surface, the population path through `Verdict` → `AuditEntry`, a preview of the planned `tirith agent sessions / explain / policy init / allow` subcommands (chunk 2+), and a section of open questions for the next chunk. The design is explicit that chunk 1 is **observation-only**: no policy gate consumes the new signal, no `RuleId` is added, no `Action` is changed, and the safeguard tests in `golden_fixtures.rs` stay untouched. (2) **`AgentOrigin` enum** at `crates/tirith-core/src/agent_origin.rs` — a closed tagged union with six variants (`Human { interactive }`, `Agent { tool, version? }`, `Mcp { client_name, client_version? }`, `Gateway`, `Ci { provider? }`, `Ide { name }`). Every caller-supplied free-form string (tool name, client name, provider, IDE name, version) passes through `sanitize_caller_label` / `sanitize_caller_version`: trim ASCII whitespace, drop ASCII control bytes + Unicode invisible / format / surrogate codepoints (the same classes the byte-scan rules already flag in command input — re-emitting them through the origin label would defeat the scan), and cap at 256 bytes for labels / 64 bytes for versions with char-boundary truncation so a multibyte UTF-8 sequence is never sliced mid-codepoint. A million-byte hostile `TIRITH_INTEGRATION` cannot crash, cannot inject newlines into an audit log line, cannot inject ANSI escapes into a terminal, cannot bloat the entry — every constructor path is covered by a unit test. (3) **CLI-side environment resolver** — `resolve_cli_origin(interactive)` reads the current process env in fixed priority order: `TIRITH_INTEGRATION` set → `Agent`, named CI provider env set (`GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, `CIRCLECI`, `JENKINS_URL`, `TRAVIS`, `TF_BUILD`, `BITBUCKET_BUILD_NUMBER`, `TEAMCITY_VERSION`, `DRONE`, `CODEBUILD_BUILD_ID`) → `Ci { provider: Some(canonical-tag) }`, generic `CI=true` → `Ci { provider: None }`, else `Human { interactive }`. The provider tag (`github-actions`, etc.) is a fixed string chosen by tirith — never assembled from attacker bytes. `CI=false` is correctly treated as "not CI". (4) **MCP-side per-session origin store** at `crates/tirith-core/src/mcp/origin.rs` — the dispatcher captures `initialize.clientInfo` once and writes it to a process-scoped `RwLock<Option<AgentOrigin>>`; every subsequent `tools/call` reads it back. Absent `clientInfo` records `Mcp { client_name: "unknown-mcp-client" }` rather than falling back to `Human` — "this came from MCP" remains structurally true even when the client is anonymous. `client_info` is no longer `#[allow(dead_code)]`. (5) **Plumbing into the verdict and audit layers** — `Verdict.agent_origin: Option<AgentOrigin>` and `AuditEntry.agent_origin: Option<AgentOrigin>` are both additive, both serde-default-on-parse (old `log.jsonl` lines without the field still load through `audit_aggregator::AuditRecord`), and both `skip_serializing_if = "Option::is_none"` (a verdict that never had its origin set produces no `agent_origin` field on the wire). The CLI `check` / `paste` paths stamp the origin on the raw verdict immediately after `engine::analyze`; the MCP tool handlers (`tirith_check_command`, `tirith_check_url`, `tirith_check_paste`) read from the session store; the gateway request and notification paths both stamp `AgentOrigin::Gateway`. `log_verdict_with_raw` reads `verdict.agent_origin` and copies it to `AuditEntry.agent_origin` — no caller wiring changes; populating the verdict is sufficient. Hook-telemetry and trust-change audit entries leave `agent_origin: None` (those entry types are not verdicts and their existing `integration` field already identifies the hook). **No new `RuleId`, no policy enforcement, no schema bump.** Chunk 1 ends here; chunk 2 wires the `tirith agent` subcommands and the per-origin policy gate.
- **MCP policy governance — `scan.trusted_mcp_servers`, `scan.mcp_allowed_tools`, and `tirith mcp policy init`** — chunk 3 of the Agent & MCP governance work. The policy field `scan.trusted_mcp_servers` already existed in the schema but was not consulted by any rule; chunk 3 wires it through. An MCP server NAME listed there now suppresses every per-server MCP config finding (`mcp_insecure_server`, `mcp_untrusted_server`, `mcp_suspicious_args`, `mcp_overly_permissive`, and `mcp_duplicate_server_name` when the duplicate's name is itself trusted), and filters drift entries with that name out of the `mcp_server_drift` finding — so a server the operator has reviewed and accepted neither raises config-side noise nor counts as drift. The filter is fine-grained: when **every** drift is for a trusted server, no drift finding fires; when **some** drifts are for trusted servers and others are not, only the untrusted ones surface — the trusted entries are dropped, not "trust the whole batch or none". An unparseable lockfile still fires (we cannot know which servers it concerned), exactly as it did before. Trust matching is case-sensitive and exact-string — MCP server names are arbitrary identifiers, not URLs, so locale-insensitive folding would be the wrong default. **New policy field — `scan.mcp_allowed_tools`** — a `HashMap<String, Vec<String>>` keyed by MCP server name, listing the exact tool names that server may expose. Two effects, both surfacing through the existing `mcp_server_drift` rule (no new `RuleId`): (1) **At drift time**, when drift exposes a tool to a server whose name is a key in `mcp_allowed_tools` and that tool is NOT in the allowed set, the drift finding is **upgraded from the default Medium to High severity** — adding a tool outside the allowed set is a stronger signal than ordinary drift. The ladder covers both drift shapes that can introduce new tool exposure: a `Changed` drift (an existing server gains a new tool — checked via `tools_added`), **and** an `Added` drift (a brand-new server appears with a disallowed tool in its declared tool list). Without the `Added` arm, an attacker could smuggle a disallowed tool by introducing a new server rather than mutating an existing one, and the finding would stay at the default Medium; the ladder applies symmetrically to both paths. Drift inside the allowed set keeps Medium; an `mcp_allowed_tools` entry of `[]` for a server forbids ANY tool on that server (every new tool is out-of-set); a `Removed` drift does not feed the ladder because the ladder is about *new* exposure, never lost exposure. (2) **At lockfile load**, when the lockfile *itself* records tools outside the allowed set for a server (the failure mode of "an MCP tool was snuck past `tirith mcp lock`"), a High-severity `mcp_server_drift` finding fires naming the offending tools — alongside any other drift findings, so an operator sees both the immediate violation and the broader inventory state. A server NOT listed in `mcp_allowed_tools` is unconstrained — the gate is opt-in. **New command — `tirith mcp policy init`** — scaffolds a starter MCP policy from the current `.tirith/mcp.lock`. It writes `.tirith/mcp-policy.yaml.example` listing every currently-locked server as a `trusted_mcp_servers` candidate (deduplicated by name, since the same name can legitimately appear in two configs) and every currently-recorded tool list as an `mcp_allowed_tools` entry. Every entry is **commented out** by design — importing the example must NEVER silently widen trust; the operator reviews the scaffold, uncomments what they intend to declare, and merges it into `.tirith/policy.yaml` themselves. A separate `.example` file is cleaner than mutating an existing `policy.yaml` (the operator can `diff` the scaffold against their working policy and integrate the bits they want). The scaffold is **deterministic** — running `mcp policy init` twice against the same lockfile produces a byte-identical example file; the lockfile is already sorted by `(name, source_config)`, and the scaffold sorts further via `BTreeSet` / `BTreeMap`. Server / tool names are **YAML-quoted-and-escaped** before they reach the example — a maliciously-crafted server name containing a `:`, `#`, newline, or ANSI escape cannot break the YAML structure or inject control sequences when the operator `cat`s the file. `--force` overwrites an existing example file; without it, an existing example is preserved (the operator may have edited it to track their working policy). `--format json` emits a structured planned-policy preview (server count, tool count, the scaffold itself) so a CI integration can ingest the proposal without reading the file. A missing lockfile is NOT fatal — a header-only scaffold is still written so the operator has a starting point; an unparseable lockfile IS fatal (exit 1) because we cannot tell what to list. **No new `RuleId`.** Chunk 3 reuses `McpServerDrift` for the lockfile-side disallowed-tool flag and the existing `Mcp*` rules for the config-side suppression — the four `golden_fixtures.rs` safeguard tests, the structural arm in `scoring.rs`, the `EXPECTED_RULES` map in `build.rs`, the `ALL_RULE_IDS` list, and `rule_explanations.toml` need no schema changes. The policy template (`policy.yaml`) gains documented but commented-out `trusted_mcp_servers` / `mcp_allowed_tools` examples in the `full`, `individual`, `ci-strict`, and `ai-agent-heavy` variants, so a fresh `tirith policy init` shows what the fields look like even before the operator runs `mcp policy init`. Policy validation already accepted `scan.mcp_allowed_tools` as a known field name (it was added in the schema work that introduced the field). Like every other `mcp` subcommand, `policy init` is a **local file operation** — no network, off the tier-1/2/3 detection hot path.
- **MCP lockfile drift detection — `tirith mcp verify`, `tirith mcp diff`, and the `mcp_server_drift` scan rule** — chunk 2 of the Agent & MCP governance work. `tirith mcp verify` rebuilds the current inventory and compares it against the committed `.tirith/mcp.lock`: it exits **0** when the two match, **1** when drift is detected (a server added, removed, or altered — transport, env, declared tools, or URL credentials), and **2** on a usage error (no lockfile to verify against, lockfile unreadable, repo root unresolved). The exit-code split lets a CI integration distinguish "the lockfile is stale" (1, the build must fail) from "there is no lockfile to verify" (2, the build must abort with a clear message). `tirith mcp diff` shows the same drift but informationally — it exits 0 whether or not drift is present (drift is reported, not enforced), but a usage error (missing lockfile, unreadable lockfile, unresolvable repo root) still exits 2 so a piped consumer can distinguish "no drift" from "I could not check" — for inspecting a config change before refreshing the lockfile. Both support `--format json` with a stable envelope (`schema_version`, `repo_root`, `lock_path`, `command`, `lockfile_format_version`, `drift_count`, `added_count`, `removed_count`, `changed_count`, `in_sync`, and a sorted `drifts` array; the drift list is byte-stable regardless of inventory order). Drift is **also** detected in the `tirith scan` FileScan path via a new `RuleId::McpServerDrift` rule (Severity Medium, "review me" not "block"): when `tirith scan` walks a repository and reaches `.tirith/mcp.lock`, the rule parses the lockfile, rebuilds the inventory from the repo's MCP configs, and fires when the two differ — so a pre-commit hook or CI scan catches an MCP-surface change the same way it catches an un-pinned action or smuggled instruction. The structured drift core distinguishes every category from the chunk-2 brief: **server added**, **server removed**, transport changes (`KindChanged`, `UrlChanged`, `UserinfoAdded` / `Removed` / `Swapped`, `CommandChanged`, `ArgsChanged`, `EnvChanged`), per-env-variable changes (`Added` / `Removed` / `ValueHashChanged`), and per-tool changes (`Added` / `Removed` / `Set` / `Reordered`). It rides on the chunk-1 inventory hash: when `current_lock.inventory_hash == lockfile.inventory_hash` the drift is provably empty and the per-server diff is skipped entirely; only when the inventory hash differs does the merge walk by `(name, source_config)` produce structured drift entries. Moving an unchanged server between config files is a non-event (chunk 1's `content_hash` deliberately excludes `source_config`) — drift only fires when the server itself changed. **Privacy invariant.** Neither `verify` nor `diff` — nor the new scan rule — ever prints an env value, a URL userinfo string, or even a hash; only the **name** of the variable or credential that changed reaches the human output and the JSON envelope. The lockfile already stripped raw values into salted hashes in chunk 1; drift detection observes that the *hash* changed, never the underlying secret. Drift entries are safe to print to a terminal, serialize as JSON, and commit to an audit log. Like every other `mcp` subcommand, `verify` and `diff` are **local file operations** — no network, off the tier-1/2/3 detection hot path — and discovery is repo-local only.
- **MCP server inventory and lockfile — `tirith mcp lock`** — the first command of the Agent & MCP governance work. `tirith mcp lock` discovers every Model Context Protocol (MCP) server a repository declares — across `.mcp.json` / `mcp.json` / `mcp_settings.json` and the IDE config variants under `.vscode/`, `.cursor/`, `.windsurf/`, `.cline/`, `.amazonq/`, `.continue/`, `.kiro/` — parses each into a structured inventory (per server: its name, transport — a remote `url`, or a local `command` + `args` + the subprocess `env` it injects — and declared `tools`), and writes a deterministic lockfile to `.tirith/mcp.lock` at the repository root. Both known config-shape variants (`mcpServers` and the `servers` alias) are handled; a server object declaring neither a `url` nor a `command` is captured with an explicit "unknown transport" rather than dropped. The lockfile carries a top-level `format_version` (currently **4**), a per-server content hash (over name + transport — including a stdio server's `env` and a URL transport's `userinfo_hash` — + tools, independent of which config file the server lives in; every variable-length component is length-prefixed so the hash is collision-free), the captured config list, and an `inventory_hash` over the whole inventory; the lockfile's servers are sorted by `(name, source_config)` **before** the inventory hash is computed, so the file and its hash are byte-stable regardless of config-discovery order and a `git diff` of `.tirith/mcp.lock` shows exactly what changed in the repo's MCP surface. **Env values are never persisted in the lockfile.** An env value declared in `.mcp.json` is commonly a credential (`API_TOKEN`, `GITHUB_PERSONAL_ACCESS_TOKEN`, `OPENAI_API_KEY`, …) and `.tirith/mcp.lock` is designed to be committed, so each env entry serializes as `{ name, value_hash }` where `value_hash` is the lowercase-hex SHA-256 of `name || ':' || value`. The name acts as a per-entry salt so a low-entropy value (`1`, `true`) is not brute-forceable across servers, and the raw value is consumed and dropped inside the parser before reaching any struct field or output. Drift detection is unchanged in spirit — a value change still flips `value_hash`, which still flips the per-server content hash — but a committed mcp.lock no longer carries plaintext secrets. **URL userinfo (HTTP Basic Auth) is never persisted either.** A URL declared as `https://user:token@host:port/path` is the symmetric leak class: the lockfile is committed, so the raw `user:token` would land in version control. The same salted-hash scheme is applied to the URL transport: the URL is stored with userinfo stripped (`https://host:port/path`), and a `userinfo_hash = sha256(server_name || ':' || userinfo)` — salted by the MCP server's name — is recorded; it is folded into the per-server content hash so a userinfo swap registers as drift. A URL that carried no userinfo serializes with `userinfo_hash` **omitted** (not set to a sentinel), so "no credential" is structurally distinct on the wire from "credential present"; a non-parseable URL is stored verbatim with no hash (we refuse to mangle a string we cannot structurally parse). The `mcp lock` human summary additionally **debug-escapes every env name** before printing (`"{name:?}"`, which renders each control byte as a `\u{NN}` / `\n` / `\r` escape rather than the raw byte), so a maliciously-crafted name containing ANSI escapes, newlines, or other terminal control bytes cannot inject control sequences into the operator's terminal; a redacted URL whose source declared credentials prints with a fixed `(credentials in source URL)` annotation so the redaction is visible without revealing the credential or its hash. This is a **local file operation** — no network, off the tier-1/2/3 detection hot path — and discovery is **repo-local only**: user-level configs such as `~/.claude/` are never inventoried, and the guarantee is enforced — a config path that is a symlink, sits under a symlinked directory, or whose canonicalized path escapes the repo root is rejected rather than followed. A malformed configuration file (not valid JSON, or no MCP-server object) contributes no entries and is never an error or a panic — it is reported as "unparseable", and the lockfile reflects only the configs tirith could read. Finding no MCP configuration at all is likewise not an error: it is reported plainly and an empty-but-valid lockfile is still written as a baseline. `tirith mcp lock` is a **new** subcommand group; the existing `tirith mcp-server` (run tirith as an MCP server) is unchanged. `--format json` reports the captured config / server counts and the full lockfile document. `tirith mcp verify` / `mcp diff` (drift detection against a committed lockfile) are planned follow-up work.
- **Package provenance / maintainer-risk scoring — `tirith package risk` and `tirith package explain` (offline signals)** — tirith can now score a package's supply-chain risk the way `tirith score` scores a URL: a deterministic, fully explainable sum of named factors — **no model, no learned weights, no statistical classifier** — every score reproducible by hand. This is the **offline-signals phase**: every signal is computed **without any network or registry-API call**. `tirith package risk <ecosystem> <name>` prints the score and a one-line-per-signal summary; `tirith package explain <ecosystem> <name>` adds the factor-by-factor derivation (mirroring `tirith score --explain`); both support `--format json`. The offline signals: (1) **Name vs. popular packages** — the dominant factor, sourced from the local threat database's `popular` set. A name that *is* a known-popular package scores 0 (recognized); a name one Levenshtein edit from a known-popular package scores high (the classic typosquat/slopsquat shape, via `check_popular_distance`); an unknown name gets a small baseline only — unknown is not the same as malicious. A new exact-match `ThreatDb::is_popular_package` accessor backs the "is recognized" check (the existing `check_popular_distance` deliberately skips exact matches). (2) **Known malicious typosquat** — additive: the threat DB's `typosquat` index independently lists this exact name as a malicious typosquat (via `check_typosquat`) — a confirmed bad name, stronger than a mere resemblance. (3) **Install / lifecycle-script presence** — additive, and **only** when the package content is locally available: tirith inspects a package directory you already have (auto-discovered under `node_modules` / `site-packages` relative to the cwd, or an explicit `--path`) — it **never downloads** the package. For npm it reads `package.json` for a non-empty `preinstall` / `install` / `postinstall` lifecycle hook; for PyPI it checks for a `setup.py`. (4) **Bundled binary-blob presence** — additive, also local-content-only: a bounded directory walk for native/compiled artifacts (`.so`, `.dll`, `.dylib`, `.node`, `.wasm`, `.jar`, …). The final score is `min(100, sum)`, with the clamp reported as an explicit negative factor so the breakdown always sums exactly to the displayed number — a test asserts that invariant across every signal combination. The score is **advisory and standalone**: `package risk` is not a detection rule, produces no `Verdict`, and changes no `Action`, exit code, or audit log; existing detection and verdicts are untouched. The offline signals are always computed; registry-API-backed provenance signals are an opt-in addition behind `--online` (see the next entry) and never touch the hot path.
- **Package risk — registry-API-backed provenance signals (`tirith package risk --online`)** — `package risk` / `package explain` can now *optionally* consult a package's registry API for provenance / maintainer-risk signals, on top of the always-on offline signals above. This is strictly **opt-in and off the hot path**: API calls run **only** behind an explicit `--online` flag (offline is the default, consistent with tirith's offline-first network policy), `--offline` / `TIRITH_OFFLINE` force offline even with `--online`, and tirith **never** adds network to `tirith check` or any other path — `package risk --online` is the sole entry point. The registry is selected by the `<ecosystem>` argument: the **npm registry API**, the **PyPI JSON API**, and the **crates.io API** are supported (other ecosystems degrade gracefully). Six registry-API signals, each an explicit named factor in the **same deterministic factor-sum model** as the offline phase — no ML, no black-box scoring, every factor reproducible by hand: (1) **package / version age** — a package first published, or a latest version released, very recently (the textbook fresh-typosquat shape; the package-level signal is the larger one and subsumes the smaller version-level one); (2) **abandoned ownership** — the registry lists an established package with zero maintainers / owners (an account-takeover precursor; a single registry document carries the *current* owner set, not its history, so a literal transfer cannot be proven from it — an established package that has lost every listed owner is the shape that can be); (3) **version spike** — the latest version is an abnormal major-version jump from the previous one (a hijacked release often ships an inflated version to capture a semver range); (4) **download counts** — very low downloads over the registry's reported window (near-zero adoption is a weak signal); (5) **repo / source-URL mismatch** — the registry lists no usable source-repository URL (provenance cannot be traced to reviewable source); (6) **yanked / deprecated status** — the registry itself marks the latest version yanked or deprecated. **Network hygiene:** the registry HTTP client uses `reqwest` with an explicit timeout and a response-size cap, exactly as `runner.rs` / `selfupdate.rs` do. **Graceful degradation:** a network failure, timeout, HTTP error, unparseable response, or unsupported ecosystem is **never** a crash, hang, or block — it degrades to the offline score with an honest `api signals: unavailable (reason)`. Successful registry responses are **cached on disk with a TTL** (under the tirith state dir) so repeated `package risk` runs do not hammer the registries. The `package_risk::ApiSignals` seam now carries three states — `not_computed` (offline run, the default), `available` (an `--online` run that reached the registry), and `unavailable` (an `--online` run that degraded) — and `--format json` exposes the gathered provenance under `api_signals`. Offline scoring is **unchanged**: the API signals only *add* factors, every offline-phase test still passes, and the score stays advisory and standalone (no `Verdict`, no exit-code or audit-log change).
- **Install-command coverage — package-manager and infrastructure install patterns** — tirith now detects high-risk patterns in package-manager and infrastructure install commands (apt/dnf/yum/zypper/pacman, brew, kubectl, helm, terraform), focusing on the *dangerous pattern* rather than the tool so a legitimate `apt install foo`, `brew install foo`, `kubectl apply -f ./local.yaml`, `terraform init`, or `helm install ./chart` stays clean. Seven new rules: (1) **`repo_add_from_pipe`** (High) — a network download piped through `tee` into an apt `sources.list` file, or redirected straight into one, so the repo definition is added and its signing key trusted without any review; a *local* file piped into `tee` does not fire. (2) **`unsigned_repo_trust`** (High) — an apt sources entry marked `[trusted=yes]` (tolerant of `[arch=amd64 trusted=yes]`), or `apt-get` run with `--allow-unauthenticated` / `--allow-insecure-repositories` — all disable GPG signature verification. (3) **`gpg_check_disabled`** (High) — `dnf`/`yum`/`zypper` run with `--nogpgcheck`, an inline `gpgcheck=0` repo setting, or pacman `SigLevel = Never`. (4) **`kubectl_apply_remote`** — `kubectl apply`/`create`/`replace -f` given a remote URL (High for a raw-content blob like `raw.githubusercontent.com` or a URL-shortener that hides the source, Medium for any other remote URL); a local file or `-k` kustomize directory does not fire. (5) **`helm_untrusted_repo`** (Medium) — `helm install`/`upgrade`/`repo add`/`pull` pointed at a chart-repository host not in the recognized set; a local chart path does not fire. (6) **`terraform_remote_module`** (Medium) — `terraform init -from-module` (or `tofu`) sourcing a root module from a remote git/http(s)/code-hosting location rather than a local path or the Terraform Registry. (7) **`brew_untrusted_tap`** — `brew install` given a raw URL instead of a formula name (High), or `brew tap` pointed at an explicit non-GitHub git remote (Medium). All rules see through a single leading `sudo` / `doas` wrapper (and its value-taking flags) and tolerate shell quoting. Detection is pure pattern matching — no registry or network calls on the hot path. Each rule has full `tirith explain` documentation, a tier-1 PATTERN_TABLE entry, and golden fixtures covering both the attack shape and the benign baseline.
- **`tirith ecosystem scan` — project dependency-manifest supply-chain scan** — the directory-level companion to `tirith package risk`: `tirith ecosystem scan [path]` walks a project, discovers and parses dependency manifests (npm `package.json` / `package-lock.json`, Python `requirements*.txt` / `pyproject.toml`, Rust `Cargo.toml`, Go `go.mod`, Ruby `Gemfile`), and scores every declared dependency through the `package_risk` engine — offline by default, `--online` adds the registry-API provenance signals. It also folds in **slopsquat detection** — AI-hallucinated package names: a conservative three-layer heuristic (the name is unknown to the threat DB, *and* shaped like an LLM hallucination, *and* sits near a real popular name) flags dependencies an attacker may have registered to catch a hallucinated import. Findings flow through the normal `Verdict` / `Finding` model, are explainable, respect the policy allowlist, and support `--format json`; slopsquat is integrated here, not shipped as a separate crate. Manifest parsers are total — a malformed manifest yields no findings, never a panic.
- **`tirith install` — safe-install transaction** — `tirith install <npm|pip|cargo|url> <args…>` wraps a real package install with pre-execution supply-chain risk analysis: it scores the package(s) with the `package_risk` engine and the install command with the install-command rules *before* anything runs, presents one explainable `Verdict` (a block refuses with bypass-per-policy, a warn requires acknowledgement, an allow proceeds), records the transaction (a working-directory checkpoint plus an audit-log entry), then runs the real `npm` / `pip` / `cargo install` — or, for the `url` form, the downloaded script via the existing safe-download path. `--no-exec` analyzes and records without running the install; `--online` adds registry-API provenance, `--offline` / `TIRITH_OFFLINE` force offline; `--format json` carries an explicit `sandboxed: false`. This is pre-execution install-risk **analysis** plus a recorded transaction — it does **not** sandbox or isolate the install (runtime sandboxing is an explicit tirith non-goal); the real install runs with the user's full privileges.
- **CI / repo supply-chain scanning — `tirith scan` covers your CI and infrastructure files** — `tirith scan` now inspects the files a repository checks in to describe its own build and deploy pipeline, not just AI-config files. It detects the *dangerous pattern*, not the tool, so a hardened workflow, a digest-pinned image, a local Terraform module, and a normal `package.json` all stay clean — every detection ships with benign fixtures proving a legitimate file does not fire. Six new file-content scan rules, run on the `tirith scan` file-walk path (no network, no registry calls): (1) **`workflow_unpinned_action`** (Medium) — a GitHub Actions `uses:` reference in `.github/workflows/*.yml` pinned to a *mutable* ref (a branch like `@main` or a tag like `@v3`) instead of an immutable 40-character commit SHA; a SHA-pinned action and a local `./` action do not fire, and a workflow with many unpinned steps yields one folded finding. (2) **`workflow_dangerous_trigger`** (High) — the `pull_request_target` trigger, which runs with repository secrets and a read/write token in the context of an untrusted fork's PR; the safe `pull_request` trigger does not fire. (3) **`workflow_curl_pipe_shell`** (High) — a `curl … | bash` / `wget … | sh` pipe-to-shell inside a workflow `run:` step (single-line or `|` block scalar); a `curl -o file` download or a benign `echo | grep` does not fire. (4) **`workflow_untrusted_input`** (High) — an attacker-controllable `${{ github.event.* }}` expression (PR title, issue body, branch name, comment, …) interpolated directly into a `run:` shell step — the classic Actions script-injection sink; a trusted context value (`github.sha`) and the same expression in a non-`run:` `with:` block do not fire. (5) **`dockerfile_unpinned_image`** (Medium) — a Dockerfile `FROM` on the mutable `latest` tag or with no tag at all and no `@sha256:` digest pin; a digest-pinned image, a specific version tag, an earlier-build-stage reference, and a build-arg-templated image do not fire. (6) **`package_script_dangerous`** (High) — an npm `package.json` `preinstall`/`install`/`postinstall` lifecycle hook (the hooks that run automatically on `npm install`) whose command is a pipe-to-shell, a base64-decode-and-execute payload, an inline interpreter one-liner that shells out or opens a network connection, or a download-and-run sequence; a benign `node-gyp rebuild` / `tsc` build hook, and the `prepare` hook (commonly a benign `husky install`), do not fire. Terraform `module` blocks with a remote/untrusted `source` in a `*.tf` file and Helm chart dependencies from an untrusted repository in `Chart.yaml` are also detected — these reuse the existing `terraform_remote_module` / `helm_untrusted_repo` rule IDs, since a remote module or untrusted chart repo is the same risk class whether named on a command line or in a checked-in file. Each new rule has full `tirith explain` documentation. `tirith scan` also gains three built-in **`--profile`** values that tune which checks and severities apply: `ci-hardening` (every supply-chain check at full strength, `fail-on high`, for hardening a CI/CD pipeline), `ai-agent-repo` (keeps the injection / dangerous-trigger / dangerous-script findings, suppresses low-value pinning-hygiene noise, for a repo an AI agent operates in), and `oss-maintainer` (emphasises contributor-controllable attack surface — script injection, dangerous triggers, dangerous lifecycle scripts — and downgrades pinning-hygiene findings, for reviewing a contributed change). A built-in profile sets a default `fail_on` and a small explicit per-rule overlay that can suppress or re-grade a finding but never invent one; a policy `scan.profiles.<name>` entry of the same name overrides the built-in. `tirith scan --format json` exposes the new findings unchanged.
- **AI-relevant file hidden-content scanning — `tirith scan` catches content smuggled past a human reviewer** — `tirith scan` now inspects file types an AI coding agent (or a renderer) reads and acts on, looking specifically for *hidden / smuggled* content — content a human reviewing the file would not see, but an agent or a renderer still processes. It extends the existing FileScan architecture (the same module pattern as the CI/repo `cifile` rules) with a new `aifile` rule module; false positives are the explicit design priority, so a normal notebook, an ordinary `CLAUDE.md` with visible instructions, and a plain SVG image all stay clean — every detection ships with benign fixtures proving a legitimate file does not fire. No network, no registry calls. Five new file-content scan rules across three file kinds: (1) **`notebook_hidden_content`** — a Jupyter notebook (`*.ipynb`) cell with content hidden from the rendered view: invisible / bidirectional / zero-width characters in the cell source (High), a long base64-encoded blob embedded in the source (Medium), or a cell hidden via `metadata.jupyter.source_hidden` or a `hide_input` tag (Medium). (2) **`notebook_suspicious_output`** — a stored cell *output* carrying smuggled content: invisible characters in a saved stream / `text/plain` output (High), or an embedded `<script>`, an inline event handler, a `javascript:` URI, or CSS-hidden content in a saved `text/html` output (Medium). A normal computed output — a number, a pandas DataFrame HTML `<table>`, a plain-text result — does not fire. (3) **`agent_instruction_hidden`** (High) — an AI agent-instruction file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, and similar) with a *hidden* directive: an instruction inside an HTML comment (Markdown renders an HTML comment to nothing) or a visually-hidden HTML element (`hidden`, `aria-hidden`, `display:none`). These files *legitimately* contain visible instructions, so ordinary visible instructions — and benign developer comments (`<!-- TODO -->`, `prettier-ignore`) — never fire; only hidden directives with an instruction-shaped phrase do. (4) **`svg_script_embedded`** (High) — an SVG image (`*.svg`) carrying executable content: an embedded `<script>` element, an inline `on*` event-handler attribute, or a `javascript:` URI; a static SVG image (paths, shapes, gradients, text) does not fire. (5) **`svg_external_reference`** (Medium) — an SVG that references content outside itself: a remote `xlink:href` / `href`, or an XXE external-entity declaration (`<!ENTITY … SYSTEM "…">`); an internal `xlink:href="#id"` fragment reference does not fire. `.svg` is no longer treated as a binary/skip extension by the directory walk — an SVG is XML text and is now scanned. Invisible-character detection reuses tirith's existing bidi / zero-width / Unicode-tag classification rather than reimplementing it. Each new rule has full `tirith explain` documentation and golden fixtures covering both the attack shape and the benign baseline; the four `golden_fixtures.rs` tier-1 safeguard tests pass. `tirith scan --format json` exposes the new findings unchanged. DOCX / PPTX / ODT are deliberately out of scope (their ZIP/XML parser complexity is deferred).
- **Verify-self and safe self-update — `tirith verify-self`, `tirith update`, `tirith version --provenance`** — tirith can now verify its own integrity and update itself, conservatively and honestly. (1) **`tirith verify-self`** checks that the running binary is the genuine, unmodified binary from an official release: it re-downloads the release archive for the running version and target, verifies that archive against the release `checksums.txt`, verifies the cosign keyless signature over `checksums.txt` when `cosign` is installed (the release pipeline produces `checksums.txt.sig` + `checksums.txt.pem` — a Sigstore signature with identity `github.com/sheeki03/tirith`), then extracts the binary from the verified archive and confirms it is byte-identical to the running binary. The verdict is one of `verified-signed` (checksum **and** signature verified), `verified-checksum-only` (checksum verified; `cosign` not installed so the signature was not checked), `unverified` (could not verify — a local dev build, offline, an unknown install, an unpublished platform — reported honestly, **never** a false "verified", and not treated as a failure: exit 0), or `failed` (verification ran and the binary did **not** match — exit 1). (2) **`tirith update`** is package-manager-aware. It detects how tirith was installed and, for a package-manager install (Homebrew, cargo, npm, Scoop, AUR, apt/dnf), **never self-modifies** the managed binary — it prints the exact command to run instead (e.g. `brew upgrade tirith`). For a self-managed install (the `install.sh` tarball or a standalone binary) it resolves the latest release, downloads it, verifies it (a checksum mismatch always aborts; `--verify` additionally **requires** a verified cosign signature), then atomically swaps the binary in place — download to a temp file in the binary's own directory, set the executable bit, then a single atomic rename, so tirith is never left half-installed — and keeps the previous binary as a `<name>.tirith-previous` sidecar. `tirith update --rollback` restores that previous binary (self-managed installs only). `--dry-run` shows what would happen and changes nothing. (3) **`tirith version --provenance`** shows the running binary's version, build profile, target triple, detected install method, SHA-256, and an offline verification status. All three commands support `--format json`, and all reach the network **only** when explicitly invoked — there is no new hot-path network. An honest design constraint: tirith has no in-process Sigstore implementation, so cosign signature verification is done by shelling out to the `cosign` binary exactly as `install.sh` does; without `cosign` the integrity check still succeeds via the signed `checksums.txt` but is reported as `verified-checksum-only`, not `verified-signed`.
- **Trust / allowlist UX — narrow by default, expiring by default, and transparent** — `tirith trust` no longer makes it easy to grant a broad, permanent allow by accident. (1) **Narrow-trust-by-default**: `trust add` classifies the pattern's scope — `exact` (a specific URL or path), `substring`, `domain` (a whole host and every subdomain), `wildcard` (`*.host`), or `bare-TLD` (`com`, `dev`) — and a *broad* pattern (domain / wildcard / bare-TLD) is now **refused** unless the caller passes an explicit `--broad`; an exact URL or path is still accepted as-is. The error tells the user to trust the narrowest thing that works, and a bare TLD gets an extra "this allows EVERY host under `.com`" warning. (2) **Default TTLs**: a `trust add` with neither `--ttl` nor the new `--permanent` flag now gets a 30-day TTL, so stale trust expires on its own; `--permanent` is the explicit opt-out and is mutually exclusive with `--ttl`. (3) **Scope visualization**: `trust list` shows each entry's scope class in a new `SCOPE` column, marks a dangerously broad entry (wildcard / bare-TLD) with a `!`, and renders TTLs as a friendly `(in 6d)` / `permanent`; `--format json` carries `scope_kind`, `scope_coverage`, and `broad_warning` per row. (4) **`tirith trust explain <pattern>`** — explains a single entry: its scope and what it covers, the rule it is scoped to (or "global"), when it was added, the free-text reason it was added (a new optional `--reason` flag on `trust add`, recorded in `trust.json`), and when it expires; for a pattern that is *not* trusted it explains what scope it *would* get. (5) **`tirith trust diff`** — shows what changed in the trust set (entries added / removed) since the previous observation; `trust list` and `trust diff` opportunistically record a small, bounded snapshot of the trust set into `~/.local/state/tirith/trust-history.jsonl` so a diff trail accrues over time. (6) **`tirith trust gc --expired`** — the expired-entry garbage collector now takes an explicit `--expired` flag and supports `--format json`. All of this is backward-compatible: the `trust.json` schema only *gains* an optional `reason` field, an existing trust store written by an older tirith loads unchanged, and an entry with no TTL — every pre-existing entry and every `--permanent` entry — is treated as permanent and is never expired or garbage-collected. The engine's hot-path trust loader is untouched and still reads old and new stores alike.
- **Remediation — "what to run instead"** — a tirith finding no longer only says a command is dangerous, it tells you how to make it safe. (1) Every `RuleId` now has a per-rule remediation string — a short, accurate "what to do instead" line — exposed programmatically via a new build-time-generated `rule_explanations::remediation(RuleId)` lookup (single source of truth: `assets/data/rule_explanations.toml`, the same file `tirith explain` already reads). The remediation is surfaced under each finding in the normal `tirith check` output as a `Fix:` line, and as a `remediation` field on each finding in `--format json`. The on-disk `Finding` struct is unchanged — the JSON `remediation` is added through a serialization view, so SARIF, audit logs, and last-trigger output are untouched. (2) `tirith explain --rule <id> --fix` prints just that rule's remediation (human and `--format json`); `--fix` requires `--rule` and is not valid with `--list`. (3) `tirith check --suggest-safe-command` — when a command is blocked or warned, prints a concrete safer rewrite of the *actual* command, but only where a transformation is genuinely safer and mechanically correct: pipe-to-shell (`curl URL | bash`) becomes a download-review-run sequence (`curl -fsSL -o /tmp/tirith-review.sh URL && less … && bash …`); an insecure-TLS flag (`-k` / `--insecure` / `--no-check-certificate`) is dropped; a plain `http://` URL is switched to `https://` with an explicit "verify the host serves HTTPS" caveat. For findings with no safe mechanical rewrite (homograph hostnames, archive-extract targets, threat-DB hits, …) it says so plainly and shows the remediation instead — it never emits a bogus suggestion, and an ambiguous pipeline (extra stages, no clear single URL) yields no rewrite rather than a possibly-wrong one. `--suggest-safe-command` is advisory: it changes neither the verdict nor the exit code, and in `--format json` the rewrites appear under a top-level `safe_suggestions` array. This is incremental — remediation is populated for every rule that has a clear safer path; accuracy is prioritized over coverage.
- **Threat-DB transparency — `tirith threat-db explain | sources | health | diff`** — four additive read-only subcommands so a user can understand and trust the local threat database. They never download, never write the DB, and never change existing `update`/`status` behavior; all support `--format json`. `threat-db explain <indicator>` looks up a domain, package (`name`, `ecosystem:name`, or `name@version`), or IPv4 address and reports whether the DB knows it, the matching classification (malicious package / typosquat / popular-lookalike / malicious hostname / malicious IP), the flagging source, and confidence — and says so plainly when the indicator is absent, with the caveat that absence is not a safety guarantee. `threat-db sources` lists every threat-intelligence feed the DB is built from, grouped into primary (the signed CI database) and supplemental (the optional user-local overlay), with the upstream project URL and live per-source record counts walked from the loaded DB. `threat-db health` reports install state, Ed25519 signature validity, age and staleness against the configured `auto_update_hours` refresh interval, per-category entry counts, supplemental-overlay presence, and any load error, with a one-word `status` (`ok` / `stale` / `not_installed` / `error`). `threat-db diff --since <version-or-date>` summarizes what changed since a DB build sequence or ISO date. The DB binary format retains no per-entry history, so `diff` honestly reports category and per-source **count** deltas between snapshots — not the exact entries added or removed — and states that limitation prominently; the transparency commands opportunistically record a small, bounded snapshot of the current DB (version, build time, counts) into `~/.local/state/tirith/threatdb-history.jsonl` so a usable diff trail accrues over time. The canonical CLI spelling is `threat-db`; `threatdb` continues to work as an alias.
- **Scoring calibration — `tirith score --explain` and `tirith policy tune --from-audit`** — the risk score is now fully explainable, and tirith can read your audit log back to you as conservative policy advice. tirith's scoring stays deterministic and inspectable: there is no model, no learned weights, no statistical classifier — every score is a fixed sum of named factors, reproducible by hand. (1) **`tirith score <url> --explain`** prints the exact factor-by-factor derivation of the score: a *base-severity* factor (the single highest-severity finding sets the floor — `Critical` 90 / `High` 70 / `Medium` 40 / `Low` 15), an *additional-findings* factor (+5 for each finding past the first), and — when it applies — a *threat-intel corroboration* factor (+5 when a local threat-DB rule fired alongside other findings, an unambiguous external confirmation). Each factor shows its point contribution, a running total, and a plain-language reason; the factors sum *exactly* to the displayed score, and a test asserts that invariant for a wide input range. The breakdown is opt-in: without `--explain` the `score` JSON is byte-compatible with before, and with it a `score_breakdown` object is added. The base and additional-findings factors reproduce the historical `score` formula exactly, so no pre-existing score changed; the threat-intel factor is the only new term and is purely additive — it never fires on its own and never changes a verdict, exit code, or audit log (`score` is an inspection command, not an enforcement path). (2) **`tirith policy tune --from-audit`** analyzes the local JSONL audit log and *suggests* — never applies — concrete, conservative policy adjustments. The headline suggestion: a rule that fired often and was allowed or bypassed *every* time, and was *never* blocked, is flagged as a candidate for an allowlist entry or a severity override, with a copy-pasteable (and deliberately incomplete — the user picks the target severity) `severity_overrides` snippet. A rule the user *sometimes* blocks on is never suggested for a downgrade — it is doing its job. It also reports, purely informationally, rules that never fired, but only when that list is short enough to be useful. Every suggestion is a fixed rule over plain counts — an arithmetic fact about the log, not an inference — and when the log is too small to be meaningful (`policy tune` needs a minimum number of records) it says so plainly rather than guessing. `policy tune` never edits the policy: the user reviews each suggestion and applies it by hand. Both commands support `--format json`.

- **`tirith policy init --template <name>`** — three curated starter policies for `tirith policy init`. `individual` is sensible defaults for a single developer (fail-open, paranoia 1, the noisy `shortened_url` rule escalated). `ci-strict` is locked down for automated CI — `fail_mode: closed`, the `TIRITH=0` bypass disabled for both interactive and non-interactive shells, `strict_warn: true`, the common remote-execution rules escalated to CRITICAL, and a `scan.fail_on: high` threshold so `tirith scan` fails the build. `ai-agent-heavy` is tuned for environments where AI agents run many commands — fail-open so an internal error cannot wedge an agent mid-task, but `paranoia: 3`, the non-interactive bypass disabled (an agent must not be able to skip analysis), `approval_rules` requiring human approval for the highest-risk pipe-to-shell rules, and `escalation` rules that block on repeated warnings. Each template writes a well-commented YAML file; every key is verified against the real policy schema, so `tirith policy validate` passes on each. `tirith policy init` with no `--template` is unchanged (still the full default template); `--template` and `--minimal` are mutually exclusive, and an unknown template name fails fast and lists the valid names. The `fintech` and `windows-enterprise` templates are intentionally deferred.
- **Android / Termux install support** — Termux runs on Bionic libc and cannot execute the glibc Linux build, so the `aarch64-unknown-linux-musl` artifact (a statically linked binary) is now documented as the supported Termux build, with step-by-step install instructions in the README. The release workflow gained a CI smoke check for that artifact: after the cross-build it verifies the binary is fully static (no dynamic-linker `INTERP` segment) and, under QEMU aarch64 user-mode emulation, confirms `tirith --version` runs and a known-bad pipe-to-shell command is still blocked. Continuous verification on a real Android device is not yet in CI and is tracked as a follow-up.
- **Visible degraded-protection indicator** — when a shell hook downgrades protection (most commonly bash enter mode falling back to preexec warn-only), the downgrade is now surfaced three ways, deliberately without being naggy. (1) The bash hook emits a single consolidated one-shot message per session — `tirith: protection downgraded to warn-only (does not block) — run 'tirith doctor' for details` — replacing the several differently-worded degrade messages it printed before. (2) The tirith shell hooks expose a new `TIRITH_STATUS` shell variable — one of `blocks` / `warn-only` / `degraded` / `off` — reflecting the live protection level; `degraded` specifically means a *mid-session* downgrade from a stronger level, distinct from a shell that simply started in warn-only. A user who wants a prompt indicator can reference `$TIRITH_STATUS` in their PS1 / `fish_prompt` / PowerShell `prompt`; tirith itself adds **no** per-prompt output. It is a plain **non-exported** shell variable (bash/zsh global, fish `set -g`, PowerShell `$global:`): the prompt runs in the interactive shell and reads it fine, while a non-interactive child — which has no tirith protection — never inherits a misleading status. It is never set in a non-interactive shell at all (conformance invariant g). nushell has no session variable that is both prompt-readable and not child-inherited, so the nushell hook does not set `TIRITH_STATUS`. (3) `tirith doctor` now prints an explicit `protection:` line and, when the status is `degraded`, an unmistakable callout — no longer something the reader must infer from `effective protection: warn-only`. `tirith doctor --compat` gained a matching `protection status:` line. New docs page `docs/prompt-status.md` shows ready-to-paste prompt snippets for bash, zsh, fish, PowerShell, and Starship.
- **`tirith doctor --bundle`** (aliases `--redacted-report`, `--shell-trace`) — writes a single **redacted** diagnostic bundle to a file (path printed; under `~/.local/state/tirith/`) that is safe to attach to a bug report. The bundle contains doctor info, tirith + hook versions, shell / mode / effective protection, hook-chain state, policy discovery, threat-DB status, and a curated slice of the environment. Redaction is layered: the environment section emits only a curated allowlist of tirith-relevant variable names (so unrelated cloud credentials / API keys are never candidates), every emitted value is still scrubbed if it looks like a token/secret, and the literal home-directory path is masked to `~` so absolute paths do not reveal the account username. `--bundle` supports `--format json` (emits `{"bundle_path": "..."}`) and is mutually exclusive with `--fix`, `--simulate-enter`, `--reset-bash-safe-mode`, and `--compat`.
- **`tirith check --offline` / `TIRITH_OFFLINE`** — an opt-in switch that suppresses all network activity on the hot path. `tirith check` normally triggers a periodic background threat-DB refresh before analysis; with `--offline` (or `TIRITH_OFFLINE=1` in the environment) that refresh is a guaranteed no-op, so `check` analyzes purely locally with zero network attempts. `paste`, `score`, `diff`, and `why` are already local-only. This is a mechanism only — the default (online) behavior is unchanged. The env-var form lets shell hooks and the PTY conformance harness pin offline behavior deterministically.
- **`tirith doctor --compat`** — a focused shell/terminal compatibility report (human, plus `--format json`). It surfaces the detected shell, requested-vs-effective bash mode and protection (from the hook-exported `TIRITH_BASH_EFFECTIVE_*` vars), the enter-mode capability verdict, install checks (binary PATH shadowing, shell-profile wiring, materialized-hook staleness, policy discovery, threat-DB status), and best-effort detection of co-installed shell tools that historically interact with hooks (Atuin, Starship, fzf, zoxide, direnv, mise, asdf) via presence on PATH and/or in the shell profile. `--compat` is mutually exclusive with `--fix`, `--simulate-enter`, and `--reset-bash-safe-mode`. It is a static report and does not run the enter-mode self-test — `--simulate-enter` remains the way to (re)measure that.
- **`tirith doctor --simulate-enter`** — runs the bash enter-mode delivery self-test on demand: spawns a disposable bash through a PTY, sources the real hook in enter mode, verifies an allowed command is delivered exactly once and a blocked command is stopped, prints the verdict, and caches it. `tirith doctor` shows the cached verdict on a new `enter capability:` line.

### Fixed

- **#136:** `pipe_to_interpreter` no longer flags a benign `python -c` data pipeline (`json.load(sys.stdin)`) as downloaded-code execution; stdin-as-code and fetch sources still block.
- **#126 / #134:** confusable-text false positives on ordinary CJK text and Cyrillic local paths.
- **#123:** silent rule-panic swallowing during file scans (now surfaced; fails closed under `--ci`).
- **#122:** `resolve_shortened_url` returns `None` past the redirect limit instead of a partial result.
- **#135:** "enter mode failed (bash)" root-caused with working escape hatches (`TIRITH_BASH_MODE=enter`, `tirith doctor --reset-bash-safe-mode`, `--simulate-enter`).
- **#140:** daily threat-DB releases no longer break `@latest` resolution (one rolling prerelease).
- **Security-audit remediation (#137):** repo-scoped policy can only tighten (never weaken); SSRF egress guard on `run` / `fetch --save` / `command-card fetch`; audit-log and fetch hardening.
- **Bash enter mode silently ate the typed command (#111)** — `bind -x` on Enter runs the bound function but, in many bash/readline builds, does not then accept the line, so `PROMPT_COMMAND` never fires and the command tirith deferred into `_TIRITH_PENDING_EVAL` is never delivered. Because whether `bind -x` accepts the line is a capability of the bash build rather than a version number, tirith now **proves** it: `tirith setup` and `tirith doctor` run a disposable-PTY self-test (`cli::bash_capability`) that checks enter-mode delivery *and* blocking, and write a `key=value` capability cache (`<state-dir>/bash-enter-capability`). Cache freshness is keyed on the bash identity — `$BASH_VERSION` and the bash binary path — because `bind -x` line-acceptance is a property of that specific bash/readline build; the cache `schema` number is the cross-tirith-version invalidator (a probe or format change bumps it), and the recorded `tirith_version` is diagnostic only. The bash hook reads that cache at startup — a single small-file read, so `tirith init` stays untouched and fast — and selects enter mode only when delivery is proven for the running bash, otherwise falling back to the safe default, preexec. An explicit `TIRITH_BASH_MODE=enter` still forces enter mode (the startup health gate and runtime self-healing degrade visibly if delivery then fails). New PTY conformance tests cover the capability-gated allowed/blocked contract, and `bash_preexec_enforce.rs` covers the cache reader against `HISTCONTROL` / `HISTIGNORE` / `set +o history` / pre-set `IFS` / pre-enabled `extdebug`.
- **`tirith doctor` reported `policies: (none found)` for a policy created by `tirith policy init` (#112)** — `doctor` now resolves the active policy through the same local discovery the engine uses — `TIRITH_POLICY_ROOT`, walk-up from the cwd to the `.git` boundary, then the user config dir — via a new shared `discover_local_policy_path` resolver. `tirith policy validate` resolves through it too, so it now locates and reports on a present-but-corrupt policy instead of reporting "no policy file found"; and `doctor --fix` gained an existence guard so it never overwrites an existing policy file. Previously `doctor` only checked the user config dir and `TIRITH_POLICY_ROOT`, never walking up from the cwd.
- **Bash enter-mode auto-degrade left `tirith doctor` reporting stale shell state (#111)** — when an interactive bash shell degrades from enter mode to preexec, `_tirith_degrade_to_preexec` now re-exports `TIRITH_BASH_EFFECTIVE_MODE=preexec` and `TIRITH_BASH_EFFECTIVE_PROTECTION=warn-only`, so a child `tirith doctor` reports the real post-degrade state instead of the stale `enter`/`blocks` values exported at shell startup. `tirith doctor` also now warns when a persisted bash safe-mode flag is being overridden by `TIRITH_BASH_MODE=enter`.
- **Bash enter-mode capability probe could mis-classify a working bash (#111 follow-up)** — the `cli::bash_capability` self-test sent a no-terminal-output command (`printf >> marker`; `printf 'true' | bash && touch marker`) and then keyed completion on the PTY going *quiet* before killing the shell. For a command whose hook shells out to `tirith check`, terminal silence is reached **before** the command runs, so the delivery probe could read an empty marker and cache `broken` for a perfectly working bash, and the blocking probe could read a not-yet-run command's absent marker as a successful "block". Both probes now poll the marker *file* with a bounded timeout — the same technique the PTY conformance harness already uses — instead of inferring completion from terminal quiet. The blocking probe additionally gained an **anti-vacuous guard**: it first delivers an allowed command and confirms (by polling its marker) that the probe shell genuinely delivers commands, and only then tests the blocked command; if the allowed command does not run the verdict is `inconclusive`, never a false "blocked".
- **`TIRITH_STATUS` leaked into non-interactive child processes** — every shell hook (bash, zsh, fish, PowerShell, nushell) set `TIRITH_STATUS` as an *exported* environment variable, so a non-interactive child — which has no tirith protection at all — inherited the parent interactive shell's status (e.g. `bash -i` then `bash -c 'echo $TIRITH_STATUS'` printed `warn-only`). `TIRITH_STATUS` exists only for the user's prompt, which runs *in* the interactive shell, so it is now a plain **non-exported** shell variable (bash/zsh global, fish `set -g`, PowerShell `$global:`) — in-shell prompt integration (PS1 / `PROMPT_COMMAND` / `fish_prompt` / a PowerShell `prompt` function) is unaffected, and children no longer inherit a misleading status. The nushell and PowerShell hooks also gained the interactivity guard they were missing. nushell has no session variable that is both prompt-readable and not child-inherited, so the nushell hook no longer sets `TIRITH_STATUS` at all. `docs/prompt-status.md` is updated accordingly, including a caveat that Starship — an external prompt renderer running as a separate process — needs the variable explicitly re-exported.

### Known gaps

- **`agent_rules.deny` is bypassed by env-bypass (`TIRITH=0`) on `tirith check`, the gateway, and the MCP `tools/call_check_command` handler.** All three sites that route through `post_process_verdict` have an `if raw_verdict.bypass_honored { /* skip post-processing */ }` early-return. The bypass branch audits the raw verdict and never reaches `apply_agent_rules`, so an operator who writes `agent_rules: { deny: [...] }` will find that `TIRITH=0` invocations sidestep the deny matcher. Consistent with how `TIRITH=0` overrides every other detection, but worth surfacing because operators may reasonably expect deny to be more authoritative than the user's interactive bypass. Pinned by the regression test `agent_rules_deny_skipped_under_tirith_bypass_today` in `crates/tirith/tests/cli_integration.rs` and documented under "Known limitation" in `docs/agent-governance-design.md` §5. To be revisited in M5 after operator feedback.

## [0.3.1] - 2026-05-08

### Fixed

- **AWS access-key false positive in S3 pre-signed URLs and SigV4 Authorization headers (#101)** — the credential rule no longer flags `AKIA…` matches that sit inside the `X-Amz-Credential` value of a parseable URL whose query also has `X-Amz-Algorithm=AWS4-HMAC-SHA256` and a non-empty `X-Amz-Signature`, or inside the `Credential=` field of an `Authorization: AWS4-HMAC-SHA256 …, Signature=…` header. The carve-out is anchored to absolute byte spans of the actual SigV4 fields — a stray `AKIA…` elsewhere in the same URL/headers/body still fires. Bare access keys, `Authorization: Bearer AKIA…` without SigV4 markers, and URLs missing any SigV4 marker continue to flag. New tests cover each shape and each adversarial bypass we considered.
- **`tirith setup codex` against current Codex CLI versions** — recognises the new `Error: No MCP server named 'X' found.` stderr message instead of treating it as an unexpected failure, and accepts both the legacy top-level `command`/`args` JSON shape and the current nested `transport.command`/`args` shape returned by `codex mcp get --json`. Drift detection still works against either shape.
- **Non-interactive `zsh -lc ...` no longer blocked by stale `.zshenv` guard** — the guard now bakes a stable absolute path to the tirith binary at install time (resolved via PATH lookup with symlink canonicalisation, falling back to `current_exe()` when the PATH entry is a `#!` wrapper script for npm/pnpm). Previously the guard relied on the bare name `tirith` being on PATH, which fails before `.zshrc`/`.zprofile` populate PATH. The path is shell-quoted so spaces and apostrophes round-trip safely.
- **npm shadow false positive on `tirith doctor` / `tirith init` (#105)** — Unix install via npm no longer warns that the `node_modules/tirith/bin/tirith` JS wrapper shadows the native binary. `resolve_effective_tirith_target` now detects the npm wrapper layout (after canonicalising the PATH entry through any symlinks), looks up the matching `@sheeki03/tirith-{platform}-{arch}/bin/tirith` sibling using the same OS+arch mapping the wrapper itself uses, and treats both paths as the same install. Unrelated tirith binaries on PATH (the documented PyPI collision case) still warn.
- **Windows path-shadow false positive on Scoop installs** — `tirith doctor`/`tirith init` no longer warn that the Scoop shim at `~\scoop\shims\tirith.exe` shadows the real binary; the shim is resolved through its `.shim` sidecar to the real path before the equality check.
- **Installer `verify_sha256` portability** — `scripts/install.sh` now probes whether `sha256sum -c` reads from stdin and falls back to `shasum -a 256 -c` when not (some BSDs, busybox). Regression harness added.

### Security

- `rand` bumped to 0.9.3 (RUSTSEC-2026-0097).
- `rustls-webpki` bumped to a version unaffected by upstream advisories.

### Docs

- README: expanded threat intel attribution table; added incident summary; added Nixpkgs install line.

### Internal

- Daily threat-DB manifest direct-pushes to `main` instead of opening auto-merge PRs that silently no-op'd when no required checks were present, accumulating an unmerged backlog.
- Scoop helper code moved under `#[cfg(windows)]` to silence a clippy warning surfaced by recent toolchain versions.
- `_tirith_output` in the bash, fish, and zsh hooks now forwards all arguments instead of only `$1` / `$argv[1]`. No call site passes more than one argument today, but the previous form was a footgun for any future refactor that splits a multi-line message across positional arguments.

## [0.3.0] - 2026-04-21

### Added

- **Bash preexec enforcement (opt-in)** — set `TIRITH_BASH_PREEXEC_ENFORCE=1` to get real blocking in bash preexec mode via `shopt -s extdebug` plus `return 1` from the `DEBUG` trap. Whole-line fail-closed semantics; one block verdict skips the entire typed line. Install-time hostile-history check refuses to engage in shells where `HISTCONTROL` contains `ignorespace`/`ignoredups`/`ignoreboth`, any `HISTIGNORE` is set, or history is disabled. Runtime drift detection with cache-then-degrade downgrades the session to warn-only rather than claim protection it cannot deliver. Idempotent `DEBUG` trap trampoline chains through any pre-existing user `DEBUG` trap. Closes the "tirith says BLOCKED but the command executes" gap in #77.
- **`tirith doctor` live state** — bash hook now exports `TIRITH_BASH_EFFECTIVE_MODE` and `TIRITH_BASH_EFFECTIVE_PROTECTION` (interactive shells only) so `doctor`, a child process, can read the parent shell's live state. Doctor splits requested-vs-effective onto separate lines so mid-session degrades are legible.
- **First-use preexec banner** — on the first command it intercepts, bash preexec prints a one-line reminder that warn-only mode does not block, with a pointer at enter mode.
- **Threat intelligence database** (Phases A/B/C) — `tirith threatdb` subcommand, threat DB compiler binary with CI workflow, signed cache format, detection rules keyed on known-bad hostnames/IPs/packages/typosquats, supplemental feed overlay with Phase B feed parsers and rule mapping, Phase C runtime API enrichment wired into `check` and daemon paths, auto-update and staleness reporting in `doctor`.
- **Per-session warning accumulator** with a new `tirith warnings` CLI command and shell exit summaries across all hooks.
- **Escalation engine** with cooldown and post-process verdicts, integrated into the engine, audit log, MCP gateway, check, and daemon paths.
- **Strict warn mode** with a new `WarnAck` exit code 3.
- **Daemon mode** with network-aware URL checks; Windows parity for network and setup features.
- **`tirith policy init`, `validate`, `test`** subcommands; **`tirith explain --rule`** for rule documentation.
- **`tirith doctor --fix`** for progressive remediation, plus `--reset-bash-safe-mode` flag.
- **`tirith setup`** gains `copilot-cli` (#74) and `kiro` (#75); scanner recognises `.kiro/`, `.amazonq/cli-agents/`, and `.github/hooks/` as config paths.
- **`--include`, `--exclude`, `--profile`** scan filters.
- **GitHub Action, pre-commit hook, and SARIF enrichment** for CI integration.
- **Text confusable detection** (math alphanumerics, same-word mixed-script) plus expanded terminal/config rules.
- **Detection gap analysis** surfaced in `tirith doctor`.
- **Warn-only rendering** for preexec mode (#77) — preexec verdicts now render "DETECTED (shell hook cannot block in preexec mode...)" instead of the misleading "BLOCKED" banner.
- `SKILL.md` for AI agent discovery.
- CLI UX: error suggestions, color module, `confirm` helper, normalised output flags, help examples on every subcommand.
- Tokenizer span tracking (trimmed byte range per segment) to support tighter carveouts without string scanning.
- `aarch64-unknown-linux-musl` target in the release pipeline.

### Fixed

- Restore `TIRITH=0` pipe bypass without weakening paste safety (#78).
- Scp/rsync remote-spec parser replaced so `host:/path` no longer trips URL-host false positives (#26).
- Carve out tirith inspection args so the scanner doesn't match its own prompt text (#29).
- Wrapped commands (`sudo`, `env`, `doas`, `command`, `time`, `nohup` prefixes) now resolve through `resolve_wrapped_command` in the `network_deny` path so prefix chains cannot bypass policy.
- `codefile` byte slicing clamps to UTF-8 char boundaries to avoid a panic on non-ASCII code (#76).
- Approval and warn-ack temp files are cleaned up on all paths to stop `/tmp` leaks (#80).
- Close mid-session `HISTCONTROL` bypass; preexec cache key corrected so drift-triggering pipelines do not leak composite rules.
- Warn-only dedupe scoped to a single typed line so long pipelines no longer suppress later DETECTED banners.
- Windows CI: Finding import, daemon/setup module compilation, XDG audit spool test gated to Unix, Gemini path assertion gated to Unix.
- Platform-specific snapshot tests replaced with cross-platform assertions.
- Early signing-key check in the threat DB workflow.
- Linux bash preexec tests made deterministic; CI caps hung test job runtime.

### Changed

- Stacked CI runs on the same ref are now cancelled; `fuzz/target` and `Cargo.lock` ignored in CI path filters.
- Documentation across README and `docs/troubleshooting.md` updated for the new enforcement matrix, threat-intel features, escalation, hidden findings, `--format` flag canonicalisation, and new MCP client setup guides (Gemini CLI, OpenClaw, Pi CLI).

## [0.2.12] - 2026-04-01

### Fixed

- Always-on pro runtime and shell-hook regression fixes.
- Windows CI test failures.
- Release publish workflow hardened.
- Crates.io re-publish idempotency check + HTTP status-code wait step.
- User-Agent header added to the crates.io API poll.

### Changed

- Docker image uses pre-built release binaries instead of compiling inside the container.

## [0.2.11] - 2026-03-31

### Added

- `Base64DecodeExecute`, `DataExfiltration`, and code-file scan rules for JS/Python files (obfuscated payloads, dynamic code execution, secret exfiltration via `fetch`/`requests.post`).
- HTML and Markdown comment content analysis with severity tiers (High for prompt injection, Medium for destructive commands).

### Fixed

- Send-position-only contract enforced for `SuspiciousCodeExfiltration`.
- Fish block-hides-prompt regression (#31).
- Zsh hook crash when `noclobber` is enabled (#70).
- Postfix `++`/`--` vs division disambiguation in the codefile exfil parser.
- Hardcoded user path removed from the Cursor MCP config template.

## [0.2.10] - 2026-03-25

### Added

- TeamPCP post-compromise behavior detection rules (`/proc/*/mem` scraping, Docker remote privilege escalation, credential-file sweeps) and domain corpus fix.
- Credential leak detection: known-pattern tokens (AWS, GitHub, Stripe, Slack, SendGrid, Anthropic, GCP, npm, private-key blocks) plus entropy-based generic secret detection.

## [0.2.9] - 2026-03-24

### Fixed

- SSRF bypass closed.
- `allowlist_rules` policy field now enforced.
- Webhook env-var hardening.

## [0.2.8] - 2026-03-21

### Added

- SSRF protection on cloaking fetch with DNS resolution checks.

### Fixed

- Cmd caret escapes inside double quotes; env values now redacted in findings.
- Inline `TIRITH=0` paste bypass removed; blocked content previews escape control characters.
- Guarded JSON-RPC notifications are analysed rather than forwarded blindly.
- Inline bypass parsing hardened; self-invocation guard removed.
- URL extraction from env-prefix assignments (`FOO=bar cmd url`); MCP scan file count capped.
- Secrets redacted in JSON output; shell metacharacters quoted in `init` output.
- Windows CI stability; `rustls-webpki` bumped for RUSTSEC-2026-0049 (0.101.x line ignored until upstream patches land).
- Windows `data_dir` uses `APPDATA`.
- `clippy::type_complexity` cleanup via `HostResolver` type alias.

## [0.2.7] - 2026-03-12

### Fixed

- VS Code / Cursor shell-env resolution: skip the `.zshenv` guard when the shell is resolving its env for IDE integration.

### Changed

- README lists `openclaw` under the setup commands.

## [0.2.6] - 2026-03-09

### Added

- Cmd (Windows cmd.exe) shell tokenizer.
- `tirith setup openclaw` command.

## [0.2.5]

### Added

- Pipe-to-shell findings now suggest `vet` (and `tirith run` on Unix for POSIX fetch commands) as safer alternatives when the source is a URL-fetching command.
- Cookbook section for getvet.sh integration (section 7).
- Interpreter detection hardening: canonical INTERPRETERS list (26 entries), `normalize_shell_token()` state machine, `resolve_interpreter_name()` with sudo/env/command/exec/nohup prefix chains.
- Extended interpreter coverage: fish, csh, tcsh, ash, mksh, python2/3, deno, bun, lua, tclsh, elixir, rscript, pwsh.

### Fixed

- `sudo -iu root bash` combined short flags now correctly resolve to `bash`.
- `--` end-of-options marker now stops flag-skipping in interpreter resolution.
- ANSI-C quoting (`$'...'`) no longer applied to Fish shell (Fish doesn't support it).
- Extracted URLs in remediation hints are sanitized to prevent ANSI injection.

## [0.1.5] - 2026-02-04

### Fixed

- Shell hooks now reliably display block/warn messages in all terminal contexts (zsh, bash, fish, PowerShell).
- Blocked commands show with `command>` prefix, blocked pastes show with `paste>` prefix.
- Fish hook now works with vi keybindings (`fish_vi_key_bindings`). Binds Enter in insert, default, and replace modes.
- Fish vi mode: Enter from normal mode now correctly returns to insert mode after execution.

## [0.1.4] - 2026-02-04

### Fixed

- Shell hooks (zsh, bash) now properly display block/warn messages. Previously, messages were silently swallowed in zle/bind-x contexts.

## [0.1.3] - 2026-02-03

### Changed

- Re-licensed under AGPL-3.0-only with a commercial licensing option.

## [0.1.0] - 2026-02-02

### Added

- Tiered analysis engine (Tier 0-3) with <2ms fast path for clean commands
- 30 detection rules across 7 categories: hostname, path, transport, terminal, command, ecosystem, environment
- Shell hooks: zsh, bash (enter + preexec modes), fish, PowerShell
- Self-contained install: hooks embedded in binary, materialized on first `tirith init`
- Policy engine: YAML config, allowlist/blocklist, severity overrides, fail_mode (open/closed)
- JSONL audit log with file locking and event correlation IDs
- Receipt system for script execution tracking with SHA-256 verification
- `doctor` diagnostic command for installation troubleshooting
- Shell completions (zsh, bash, fish, PowerShell) via hidden `completions` subcommand
- Man page via hidden `manpage` subcommand
- `diff` command for comparing URLs against known-good patterns
- `score` command for URL risk scoring
- `why` command to explain the last triggered rule
- `run` command for safe script download and execution (Unix only)
- 235 golden fixture tests across 10 categories
- Criterion performance benchmarks
