Skip to content

v0.22.0

Choose a tag to compare

@AGGIB AGGIB released this 01 Oct 08:52
· 221 commits to main since this release
7f2dbd7

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's config.fish, conf.d and functions, /etc/profile and siblings, PowerShell's profile including $PROFILE), SSH's authorized_keys, rc and environment, macOS LaunchAgents and LaunchDaemons, user-level systemd units, environment.d, XDG autostart, the Windows Startup folder, an editor task that runs when the folder is opened ("runOn": "folderOpen" in tasks.json or a .code-workspace, task.allowAutomaticTasks), and the ways to schedule a job (crontab, schtasks /create, launchctl submit, a systemd-run timer, 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/install onto 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, touch and rm do not. A relative name is joined to the process's working directory and to an earlier cd (a cd inside parentheses is undone when the subshell closes), and ~/.{a,b} is expanded. ~/.ssh/config counts 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 of Write, Edit, Read and 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 signal via-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 .gitconfig or anything named like it, .cfg/.conf/.ini/.inc), in two tiers: a key git runs on every operation (core.fsmonitor, core.hooksPath, diff.external, a filter driver other than Git LFS's own, a merge driver) is config.git_exec, which closes the gitdir: alternate-config shape of GitPwned, also when git show --output writes it; a key that names a program for one feature (gpg.program, a textconv, include.path, a shell alias or pager, a credential helper starting with !) is config.persistence, asked rather than refused, because a user's own ~/.gitconfig has 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 has hooks: are config.instructions_payload; an SSH client configuration that runs a program is config.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 to echo or printf).
  • A script a command runs is judged by what it contains. bash helper.sh, source x.sh, ./clean.sh, ./cleanup with a shell #! line, bash < x.sh, $PWD/x.sh, a script run after a cd, & .\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 hides rm -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 how nvm.sh and ~/.zshrc set themselves up (of 128 scripts on one machine, 14 raised it), while eval "$(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, is shell.unparsed instead of passing silently. Only files that are shell scripts count toward that limit, so a git add \ list or a heredoc of paths is not asked about.
  • trap bodies and cmd /c "…" / powershell -Command "…" strings are read as commands, including trap -- '…', a double-quoted trap with \" and \$ inside, and a cmd body that ends in a backslash before its closing quote.
  • A command sent over ssh is read as the command it carries, in the quotes it was given in, behind combined options (-tp 22), quoted option values, -- and sshpass: ssh prod docker rmi <image> (claude-code #94579), docker volume rm, docker compose down -v, a recursive rm outside /tmp (.. collapsed, an earlier cd followed), and a decode-and-run pipeline are asked about; docker ps, docker stop, rm -f and rm -rf /tmp/…, quoted or not, are not. A command left unread for want of room is shell.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:disable and the other firebase deletes, gcloud … delete, gsutil rm|rb, aws s3 rm|rb|sync --delete, aws <service> delete-|terminate-|deregister-|remove-…, az … delete, kubectl delete of namespaces, volumes, deployments, nodes or --all, heroku, netlify, vercel, helm, fly, docker volume rm|prune and docker system prune --volumes, gh api -X DELETE, npm unpublish, gh release delete, lftp … --delete, rsync --del / --delete* to a remote host, and git filter-branch|filter-repo|reflog expire|gc --prune=now.
  • stroq attack replays 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 ~/.zshenv escape (CVE-2026-55607), GhostApproval, the trap that removes $HOME, the agent-written git clean -xdff script, the Antigravity .vscode time bomb, the Kiro MCP server (CVE-2026-10591), the memtry onboarding harvest, Clinejection, the ssh image removal, the Firebase hosting disable, the lftp mirror --delete, the rmdir that collapsed to a drive root and the reset and force-push on a branch with an open PR. A scenario can now carry links (symlinks created in the throwaway directories) and script fixtures. --fuzz crosses 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 --fuzz escape 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 ~/Documents and ~/Desktop does 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=false was 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.json and .gemini/settings.json are 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 ordinary npx server is still ordinary work. The agent definitions under .github/agents, .github/prompts, .github/chatmodes, .kiro/steering, .kiro/hooks and .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 && turned grep -E "a|crontab|b" into a crontab command and took the file from perl -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.