# 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 ` 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 [--tool ]` 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, deny: Vec }`, with `AgentMatcher { kind: AgentOriginKind, tool: Option }` 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>`; 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` and `AuditEntry.agent_origin: Option` 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>` 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 ` prints the score and a one-line-per-signal summary; `tirith package explain ` 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 `` 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 ` 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.` 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 `