Repository navigation
v0.22.0
Added
- A new action class,
config.persistence, for writes that install something a trusted process runs later. A shell's startup files (~/.zshrc,~/.zshenv,~/.bashrc,~/.bash_aliases,~/.profile, fish'sconfig.fish,conf.dandfunctions,/etc/profileand siblings, PowerShell's profile including$PROFILE), SSH'sauthorized_keys,rcandenvironment, macOSLaunchAgentsandLaunchDaemons, user-level systemd units,environment.d, XDG autostart, the Windows Startup folder, an editor task that runs when the folder is opened ("runOn": "folderOpen"intasks.jsonor a.code-workspace,task.allowAutomaticTasks), and the ways to schedule a job (crontab,schtasks /create,launchctl submit, asystemd-runtimer,reg add …\Run,Register-ScheduledTask). The default policy asks in any session (ask-persistence) and denies in a session that has read something suspect (deny-persistence-when-tainted). Nineteen action classes are now named in the policy. A read of the same files is ordinary: only a redirect into the file, a verb that puts text in it (tee,patch,sed -i,perl -pi,cp/mv/installonto it,dd of=,defaults write,PlistBuddy,git clone,git … --output,tar -C,unzip -d), a download written to it, or an inline interpreter whose code writes counts;chmod,touchandrmdo not. A relative name is joined to the process's working directory and to an earliercd(acdinside parentheses is undone when the subshell closes), and~/.{a,b}is expanded.~/.ssh/configcounts only when what is written names a directive that runs a program (ProxyCommand,LocalCommand,KnownHostsCommand,Match … exec). - Writes through a symlink are judged by where they land. A project file that is a link to
~/.ssh/authorized_keys(Wiz GhostApproval) was classified by its name. The path ofWrite,Edit,Readand of an MCP tool's path arguments (read or written, up to 64 per call; past that the call is asked about) is now resolved on disk, through a link at the end, a link in any directory above, a dangling chain, and a directory that does not exist yet under a linked one, and the real target is classified as well, with the signalvia-symlink. - The text of a write is read, not only its path, and each string a write carries is read on its own. Git configuration text in a file named like one (a gitdir's
config, a.gitconfigor anything named like it,.cfg/.conf/.ini/.inc), in two tiers: a key git runs on every operation (core.fsmonitor,core.hooksPath,diff.external, afilterdriver other than Git LFS's own, amergedriver) isconfig.git_exec, which closes thegitdir:alternate-config shape of GitPwned, also whengit show --outputwrites it; a key that names a program for one feature (gpg.program, atextconv,include.path, a shell alias or pager, a credential helper starting with!) isconfig.persistence, asked rather than refused, because a user's own~/.gitconfighas them. An MCP server list that registers a shell or an inline interpreter as a server (sh,bash -c,env bash,npx -c,python3.12 -c,node --eval,deno eval, or a wrapper that sources anything but the user's shell setup before a package runner; a server's own options after its script are left alone) and an agent definition, skill or command whose frontmatter hashooks:areconfig.instructions_payload; an SSH client configuration that runs a program isconfig.persistence. A text past 4 MiB in one of those files is asked about, not passed. The same texts are read when a Bash command writes the file (a heredoc, or a quoted string given toechoorprintf). - A script a command runs is judged by what it contains.
bash helper.sh,source x.sh,./clean.sh,./cleanupwith a shell#!line,bash < x.sh,$PWD/x.sh, a script run after acd,& .\clean.ps1,pwsh -File,cmd /c wipe.bat: the file is read when the command runs, and what it does that is destructive, encoded or persistent is added to the command. Variables are resolved in order, and a line is also read with every other value its variables take in the file, so neither a reassignment after the use nor a function defined before the assignment hidesrm -rf "$HOME". Two 2026 reports are exactly this (claude-code #88462, #87360). Only shell-family scripts are read, one level deep;eval "$(tool init)"is not counted, because it is hownvm.shand~/.zshrcset themselves up (of 128 scripts on one machine, 14 raised it), whileeval "$(curl …)"is; the text of a heredoc that only prints, or that writes a file the script never runs (a Dockerfile, a README), is skipped; and a script past 1 MiB or 20,000 lines, or a command that runs more than eight scripts, isshell.unparsedinstead of passing silently. Only files that are shell scripts count toward that limit, so agit add \list or a heredoc of paths is not asked about. trapbodies andcmd /c "…"/powershell -Command "…"strings are read as commands, includingtrap -- '…', a double-quoted trap with\"and\$inside, and acmdbody that ends in a backslash before its closing quote.- A command sent over
sshis read as the command it carries, in the quotes it was given in, behind combined options (-tp 22), quoted option values,--andsshpass:ssh prod docker rmi <image>(claude-code #94579),docker volume rm,docker compose down -v, a recursivermoutside/tmp(..collapsed, an earliercdfollowed), and a decode-and-run pipeline are asked about;docker ps,docker stop,rm -fandrm -rf /tmp/…, quoted or not, are not. A command left unread for want of room isshell.unparsed. - Deletes on hosting, storage and clusters are
shell.destructive, with global options allowed before the verb (kubectl -n prod delete,aws --profile p s3 rm,gcloud --project=p … delete):firebase hosting:disableand the otherfirebasedeletes,gcloud … delete,gsutil rm|rb,aws s3 rm|rb|sync --delete,aws <service> delete-|terminate-|deregister-|remove-…,az … delete,kubectl deleteof namespaces, volumes, deployments, nodes or--all,heroku,netlify,vercel,helm,fly,docker volume rm|pruneanddocker system prune --volumes,gh api -X DELETE,npm unpublish,gh release delete,lftp … --delete,rsync --del/--delete*to a remote host, andgit filter-branch|filter-repo|reflog expire|gc --prune=now. stroq attackreplays 35 scenarios, up from 21: 26 documented incidents and 9 synthetic cells; 16 blocked, 19 asked, 0 passed through. The fourteen new ones are GitPwned, the git-worktree~/.zshenvescape (CVE-2026-55607), GhostApproval, thetrapthat removes$HOME, the agent-writtengit clean -xdffscript, the Antigravity.vscodetime bomb, the Kiro MCP server (CVE-2026-10591), the memtry onboarding harvest, Clinejection, thesshimage removal, the Firebase hosting disable, thelftp mirror --delete, thermdirthat collapsed to a drive root and the reset and force-push on a branch with an open PR. A scenario can now carrylinks(symlinks created in the throwaway directories) and script fixtures.--fuzzcrosses the 19 that carry untrusted text with all 25 mutations: 475 variants, 0 escapes. The report headers now say what was run: documented incidents and synthetic cells are counted apart, and the fuzz header multiplies only the scenarios it mutated.- A new rule,
STROQ-2026-00013, for an instruction to sweep home and agent-config directories and submit what is found. It names two agent or credential directories under the home directory (~/.claude,~/.ssh,~/.aws, …) between a collecting verb and a sending one, with gaps that cross line breaks. It closes the one--fuzzescape that was left: the memtry-cli onboarding text (OSV MAL-2026-10736) split across lines, which no other rule read. A backup guide that names~/Documentsand~/Desktopdoes not trigger it. The bench false-positive rate is unchanged at 18/121 (14.9%).
Fixed
- A pipeline of many commands took time quadratic in its length. For each stage the classifier copied and searched everything after it for a shell: 256 KiB of
x | x | …took 1.3 s, and a hook has no length cap on what it is handed. It is one pass now (0.2 s). prisma db push --accept-data-loss=falsewas a destructive command. The flag that declines data loss matched the one that accepts it.- A command word named like an object property (
toString,__proto__) threw in the classifier. The table of network subcommands is read by its own keys only.
Changed
.mcp.json,.cursor/mcp.json,.vscode/mcp.json,.roo/mcp.json,.kiro/settings/mcp.jsonand.gemini/settings.jsonare instruction files. A write is asked about in a tainted session, or in any session when the text registers a shell or an inline interpreter as the server (Kiro, CVE-2026-10591). Adding an ordinarynpxserver is still ordinary work. The agent definitions under.github/agents,.github/prompts,.github/chatmodes,.kiro/steering,.kiro/hooksand.agent//.agents/(rules,workflows,skills) join the list.- The new detectors read a command cut where the shell cuts it. The quote-blind cut at every
|,;and&&turnedgrep -E "a|crontab|b"into acrontabcommand and took the file fromperl -pi -e 's/a|b/'~/.zshrc; persistence, scripts and the text of Bash writes now read the command split outside quotes, with heredoc bodies cut by line as before.
Measured
Against 84,434 real tool calls from this machine's Claude Code and Codex transcripts, compared with 0.21.2, 42 decisions changed. Outside Stroq's own development, 6 did, all from allow to ask: two git filter-branch runs, three recursive deletes over ssh, and a heredoc that writes a script with a delete in it; none to deny. The 4 new denies are all probes written while building this release: two write git configuration that sets core.fsmonitor, one is a Python edit whose text names .git/config, and one writes test fixtures that name Stroq's own settings.
Not covered (stated, not implied)
A Python or Node script (only shell-family scripts are read), what a script computes at run time, cat x.sh | bash, a script that does not exist yet or changes after it was read, a file copied into place by a later command, a symlink made or followed inside a Bash command, an npm lifecycle script run by npm install, a virtualenv interpreter swapped for another, docker run --privileged and socket escapes, sudo systemsetup -setremotelogin on, an ssh remote command written with $'…' or split across words, a git configuration written to a file not named like one and pulled in by an include.path set some other way, and a tool call forged inside a model response.