agentleFS
AGGIBAGGIB/Stroq/docs/AGENTS.md

Stroq

AGENTS.md213 starsChanged 25 days agoApache-2.0View on GitHub

The Coverage by agent table in the guide is the summary, and HOSTS.md is the generated channel-by-channel matrix for stroq attack --host <id>. This file is the full detail behind both: every hook event Stroq installs on, the wire format it reads, and every documented limit — the honest edges, not just the happy path. Each install command below also appears in the README; this is where the "why" for each adapter lives. What "taints the session" means on this…

  • Pipes a download into a shell
  • Reads credentials
  • Deletes or force-pushes
  • Installs packages
  • Where files are headingNew AGENTS.md files have more rules about keeping secrets out of the code, and fewer about code style.

    Of the AGENTS.md files we read, the 31,439 first seen in the last six months, against the older ones: the kind of rule that gained most share, and the kind that lost most. About files like this in general, not this one, and an estimate, since a kind is a pattern in the words.

  • In this repository3 other files it gives its agents.

The file

We split this AGENTS.md into the rules it gives an agent

6 rules. About 0 are common, 1 is distinctive, and 5 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 5

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Run the Cursor demo yourself: pnpm install && pnpm build && ./examples/demo/run-cursor-demo.sh.

    line 65
  2. Very specific:

    Run the Codex demo yourself: pnpm install && pnpm build && ./examples/demo/run-codex-demo.sh.

    line 102
  3. Very specific:

    Run the Copilot demo yourself: pnpm install && pnpm build && ./examples/demo/run-copilot-demo.sh.

    line 149
  4. Very specific:

    Run the OpenClaw demo yourself: pnpm install && pnpm build && ./examples/demo/run-openclaw-demo.sh.

    line 204
  5. Very specific:

    Run the Windsurf demo yourself: pnpm install && pnpm build && ./examples/demo/run-windsurf-demo.sh.

    line 254
  6. Distinctive:

    Every substitution is in the audit log, by placeholder and kind, never by value:

    line 379

≈ “Common” is an estimate: at least 1 in 5 of the 40,246 AGENTS.md files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
AGENTS.md

Per-agent integration details

The Coverage by agent table in the guide is the summary, and HOSTS.md is the generated channel-by-channel matrix for stroq attack --host <id>. This file is the full detail behind both: every hook event Stroq installs on, the wire format it reads, and every documented limit — the honest edges, not just the happy path. Each install command below also appears in the README; this is where the "why" for each adapter lives.

What "taints the session" means on this page. A flag on text somebody else wrote (a fetched page, an MCP result, a file an agent loads as instructions, a dependency's files, a file the session downloaded, or what a command that is not all local programs printed) taints the session, and its network, fetch and secret reads are then denied. A flag on a file of the project read with a file tool, or on what a local command printed, only notes the session: what is copied from that text, and a call that reads secrets in a command that is not all local programs, are asked about. This holds on every agent below, since each adapter hands file reads and shell output to the engine as Claude Code's Read and Bash (see How it works).

Host--agent / --hostConfig written by stroq initDetail section
Claude Codeclaude-code.claude/settings.local.json (--user: ~/.claude/settings.json)Claude Code
Cursorcursor.cursor/hooks.json (--user: ~/.cursor/hooks.json)Cursor
Codexcodex.codex/hooks.json + .codex/config.toml (--user: ~/.codex/...)Codex
Copilot CLIcopilot.github/hooks/stroq.json (--user: ~/.copilot/hooks/stroq.json)Copilot CLI
OpenClawopenclaw~/.openclaw/extensions/stroq-guard/OpenClaw
Windsurfwindsurf.windsurf/hooks.json (--user: ~/.codeium/windsurf/hooks.json)Windsurf
Google Antigravityantigravity.agents/hooks.json (--user: ~/.gemini/antigravity-cli/hooks.json)Google Antigravity
MCP proxy (any MCP client)mcpwraps mcpServers entries in client configMCP proxy (any MCP client)

Claude Code

npx @stroq/cli init                  # in your project: writes .claude/settings.local.json

--user writes ~/.claude/settings.json instead, --dry-run prints the merged file without writing it. Stroq installs PreToolUse for Bash|PowerShell|Monitor|Write|Edit|MultiEdit|NotebookEdit|Read|Grep|WebFetch|mcp__.*, and PostToolUse / PostToolUseFailure for Read|WebFetch|WebSearch|Bash|PowerShell|Grep|mcp__.*.

  • PreToolUse classifies shell commands (Bash, PowerShell, Monitor), file writes (Write, Edit, MultiEdit, NotebookEdit), file reads (Read, Grep), web fetches (WebFetch) and MCP calls (mcp__<server>__<tool>) and returns allow, ask or deny via hookSpecificOutput.permissionDecision. The installed pre-hook matcher excludes WebSearch; its results are scanned only after the call.
  • PostToolUse and PostToolUseFailure scan the returned tool output (Bash, PowerShell, Read, WebFetch, WebSearch, mcp__.*), taint the session on a suspect match, record provenance atoms, and surface a warning via hookSpecificOutput.additionalContext.
  • Limits. Hook enforcement covers tool calls that pass through Claude Code's hook pipeline; OS-level process sandboxing is the agent's own, or one you start it in (for example Anthropic's srt). Live enforcement was verified on Claude Code 2.1.271 (packages/cli/test/fixtures/hosts/claude-code/2.1.271/deny.real.acceptEdits.jsonl).

Cursor

npx @stroq/cli init --agent cursor   # in your project: writes .cursor/hooks.json

--user writes ~/.cursor/hooks.json instead, --dry-run prints the merged file without writing it. Restart Cursor afterwards; stroq doctor then shows a cursor hooks line next to the Claude Code one. Re-running init is idempotent and replaces an older Stroq entry rather than stacking a second one; foreign hooks and foreign events in the file are left untouched.

Stroq installs on seven of Cursor's hook events:

Cursor eventWhat Stroq doesCan it stop the action?
preToolUse (Write/Delete matcher)Classifies every reported file path before the editor tool runs; refuses unreadable or oversized path listsYes — deny; ask becomes deny because Cursor does not enforce it here
beforeShellExecutionClassifies the command, applies your policyYes — deny / ask
beforeMCPExecutionClassifies the MCP call and its arguments, secret egress includedYes — deny / ask
beforeReadFileScans the file body before the agent sees it; taints the sessionAllow/deny only — a suspect file is allowed with a warning; a credential file under taint is denied
afterShellExecutionScans the terminal output, taints the session, records provenanceNo
afterMCPExecutionScans the MCP result, taints, records provenanceNo — but a suspect result adds additional_context for the agent (best-effort, see Limits)
afterFileEditRecords a completed edit as a post observation, separately from the preToolUse decisionNo

preToolUse (matched to Write and Delete), beforeShellExecution and beforeMCPExecution are installed with failClosed: true, so a crashed or missing Stroq blocks those actions. Blocking hooks return explicit {"permission":"allow"} for allowed actions: Cursor desktop 3.24.18 also treats empty output as failure under failClosed. The four observation/read hooks are installed without it. The beforeReadFile refusal of a credential path under taint remains best-effort: an internal error on that event allows the read.

Limits.

  • Editor writes are covered only when Cursor emits a matched Write or Delete tool call. The generic preToolUse gate denies edits to protected config and Git execution files, checking every recognised path field, file_path and the camelCase relativeWorkspacePath/targetFile Cursor uses in its own records included. A write whose path it cannot find is allowed and recorded as cursor-write-path-unread rather than denied: Cursor, not the model, chooses those field names, so an unknown shape is a gap in what Stroq knows rather than a way around it, and a deny there would block every file the agent writes. More than 64 paths is still denied. afterFileEdit records completed edits as cursor-edit-observed, without claiming that its post event enforced the decision. Cursor Tab edits use separate Tab hooks, which Stroq does not install. Measured on Cursor desktop 3.24.18, Windows, October 14, 2026: real Write/Delete pre-events returned explicit allow for ordinary scratch files and deny-self-tamper for a harmless protected sentinel; disk snapshots confirmed both outcomes. A control Write to that same sentinel succeeded with only preToolUse disabled, and the gate was restored exactly. Actual afterFileEdit observations and audit attribution are retained in the versioned fixture. No separate ungated Delete control was run. CI only replays these records; the remaining documented payload variants are synthetic.
  • The project follows the workspace root, not the agent's shell cwd. Stroq resolves the project directory as workspace_roots[0], falling back to Cursor's own cwd field only when the workspace root is absent, and to the process's cwd() as a last resort. A cd /tmp inside the agent's shell does not shed the project's .env* secret index — the workspace root, not the shell's current directory, decides which project's secrets and paths apply. Windows Cursor 3.24.18 reports URI-style /C:/... workspace roots beside native file paths; Stroq removes that leading slash on Windows.
  • A multi-root workspace is indexed through its first root only. workspace_roots[0] is the project for every event, so .env* files in the other roots are not in the secret index and their values are not recognised on the way out. Open the root whose secrets you care about first, or install Stroq per project.
  • Cursor's own web reads are not scanned. Cursor has no hook for the content its agent fetches from the web, so a poisoned page reached that way is neither scanned nor taints the session — unlike Claude Code, where WebFetch/WebSearch go through PostToolUse. Content that arrives through a file read, a terminal command or an MCP call is covered as usual.
  • sandbox is ignored. beforeShellExecution reports whether Cursor will run the command sandboxed; the policy applies either way, because a sandboxed curl still exfiltrates.
  • additional_context on afterMCPExecution is best-effort. That field is documented by the community rather than on Cursor's official hooks page, so a client that ignores it simply gets no warning text — the taint it accompanies is set regardless and is enforced on the next action.
  • A poisoned terminal output taints silently. afterShellExecution honours no output, so the agent is not told; where the output is somebody else's (see the top of this page), the next network command, secret read or external push is denied all the same.
  • beforeReadFile cannot ask. A file that scans as suspect is allowed with a user_message warning and taints the session when it is somebody else's (a file of the project only notes it); only a credential path (fs.secrets) under an already-tainted session is denied. An internal error on this event allows the read, so a taint can be missed — it is not a high-impact action.
  • Not used: Cursor's Tab hooks (beforeTabFileRead, afterTabFileEdit), postToolUse, beforeSubmitPrompt, updated_input rewriting and enterprise/team hook locations. preToolUse is matched only to Write and Delete to avoid duplicate shell/MCP decisions.
  • Untested: the Cursor CLI (cursor-agent), other desktop versions/OS combinations, and live shell/MCP enforcement in this bounded capture. Windows desktop Write/Delete and post-edit behavior was measured only in the versioned scope above. There is no plugin install path — Cursor has no plugin system, so stroq init --agent cursor is the only one.

Run the Cursor demo yourself: pnpm install && pnpm build && ./examples/demo/run-cursor-demo.sh.

Codex

npx @stroq/cli init --agent codex   # in your project: writes .codex/hooks.json

--user writes ~/.codex/hooks.json instead, --dry-run prints the merged file without writing it. stroq doctor then shows a codex hooks line next to the other two. Re-running init is idempotent and replaces an older Stroq entry rather than stacking a second one; foreign matchers, foreign events and any other key in the file are left untouched. Stroq always writes the official nested shape: a file that kept its events at the root instead of under the hooks wrapper has them migrated into it, groups and all, because a hook written in the shape Codex is not reading is a hook that never runs. Nothing is dropped — an event declared in both places keeps both, and a root value Stroq cannot read as hook groups is left exactly where it was.

Three things to check after installing, all specific to Codex:

  • Approve the hooks. Codex runs a new or changed hook only after you approve it: start codex and approve Stroq's hooks when it lists them for review. It records the approval in ~/.codex/config.toml as hooks.state."<key>".trusted_hash; until then Stroq does nothing in Codex, and stroq doctor fails the codex hooks line as NOT APPROVED. Upgrading Stroq changes the hook command, and Codex asks again. doctor cannot tell which recorded approval is Stroq's — the key is Codex's own — so it fails the line only when none is recorded at all. Measured on Codex 0.158.0-alpha.2 (2026-09-29): a user-level Stroq hook ran under codex exec only with --dangerously-bypass-hook-trust.
  • On releases where hooks are still opt-in, add [features] / hooks = true to ~/.codex/config.toml.
  • A project-local .codex/ layer only loads once you trust the project — Codex prompts the first time it sees one. --user writes the home-directory copy, which needs no project trust; its hooks still need approving. In the same measurement, project hooks did not load under codex exec at all, even with the project trusted and the approval bypassed — if you drive Codex through exec, install with --user.

Stroq installs on two of Codex's events:

Codex eventMatcherWhat Stroq doesCan it stop the action?
PreToolUseBash|exec_command|shell|local_shell|apply_patch|ApplyPatch|mcp__.*Classifies the shell command, every path an apply_patch declares, or the MCP call and its arguments (secret egress included), and applies your policyYes — deny (see the ask limit below)
PostToolUseBash|exec_command|shell|local_shell|mcp__.*Scans the command output or MCP result, taints the session, records provenanceNo — but a suspect result adds additionalContext for the model

Only Bash, apply_patch and mcp__<server>__<tool> are tool names OpenAI documents; exec_command, shell, local_shell and ApplyPatch are defensive aliases. Matching a name Codex never sends costs nothing, while missing one it does send costs the whole decision. For the same reason the adapter reads the shell command from command, cmd, input, script or raw (a string, an argv array, or one level of nesting) and unions the patch body across command, input, patch, raw, cmd, script and arguments. Where a payload carries a command under more than one of those names, every one of them is classified and the most severe decision wins — exactly as it does for the files a patch declares — so a harmless-looking first field cannot shadow a dangerous later one.

apply_patch carries a patch body rather than a path, so Stroq reads the *** Add File: / *** Update File: / *** Delete File: / *** Move to: headers and classifies every file the patch declares, taking the most severe decision — a patch that quietly deletes .codex/hooks.json alongside a legitimate edit is denied by deny-self-tamper, and every path is in stroq log. .codex/hooks.json and .codex/config.toml are protected the same way .claude/settings.json and .cursor/hooks.json already were, for every agent.

Limits.

  • ask becomes deny. Codex's hook contract has no way to prompt, so a decision the policy makes an ask — a destructive command, an external push, an npx for a package that came out of tool output — is denied instead, with a reason that says so and names the rule: Stroq would ask before this action (ask-destructive): … Codex hooks cannot prompt, so it is denied; run it yourself or relax the rule in ~/.stroq/policy.yaml. The audit still records the policy's real ask; only the wire answer is lossy. If that trade is wrong for you, set those rules' effect to allow in your own policy.yaml — but then nothing stops them.
  • Codex fails open at runtime, and there is no failClosed knob. If the hook command cannot start at all (no Node on PATH, a bad entry path), Codex logs a hook failure and continues. Stroq covers its own errors — including a failure to read the event off stdin — by exiting 2 with the reason on stderr, the one block Codex honours without parsing stdout, for PreToolUse on every high-impact tool in the matcher above. Everything else answers an error with silence, because there is nothing there to block. For the smallest chance of a failed start, npm install -g @stroq/cli rather than relying on npx.
  • Codex's own web reads are not scanned. Hosted tools such as WebSearch never reach hooks, so a poisoned page Codex fetches itself is neither scanned nor taints the session — unlike Claude Code, where WebFetch/WebSearch go through PostToolUse. Content that arrives through a command's output or an MCP call is covered as usual.
  • A call Stroq cannot read is denied, not allowed. Stroq only trusts a patch header at column 0; a *** Add File: line inside the patch body (prefixed with +, - or a space) is body text, not a claim about which files are touched. If Codex sends a tool_input Stroq cannot get a command or a single path out of — a field spelling it does not know, a shape it does not expect — the call is denied with codex-unreadable-input and the reason names the top-level keys it saw (never their values, which is where a secret would be), so you can report the payload shape. An empty tool_input has nothing to act on and is unaffected. A patch declaring more than 64 files is denied outright (codex-patch-too-large) rather than classified path by path, because the classification would risk running past the hook timeout — and a timed-out hook fails open.
  • Recorded wire coverage is limited to Codex CLI 0.162.1 on Windows. The live protection driver has real allow/deny/control fixtures for shell calls and the host's stderr hook-denial diagnostic. Other aliases, patch and MCP payloads still have defensive hand-written fixtures; those shapes are not established by the live capture. the live check for Codex refuses other versions/platforms and user or managed hook sources, including ~/.codex/hooks.json written by init --user. Its inline hook runs through a private policy/audit-home wrapper with sandbox and hook trust bypassed. It proves enforcement of that isolated hook, not ordinary installed-hook loading.
  • Not used in v1: PermissionRequest (Codex's own approval prompt; Stroq has already decided in PreToolUse), updatedInput rewriting, SessionStart/SessionEnd/Stop/Interrupt/compaction events, inline [hooks] tables in config.toml (they work, but init does not write them), and plugin-bundled hooks.
  • Windows: the isolated prove driver was measured with CLI 0.162.1; general project-hook loading, patch and MCP tool coverage remain unmeasured there. commandWindows is not written.

Run the Codex demo yourself: pnpm install && pnpm build && ./examples/demo/run-codex-demo.sh.

Copilot CLI

npx @stroq/cli init --agent copilot   # in your project: writes .github/hooks/stroq.json

--user writes $COPILOT_HOME/hooks/stroq.json (or ~/.copilot/hooks/stroq.json) instead, --dry-run prints the file without writing it. Copilot reads its hooks when the CLI starts, so restart copilot afterwards; stroq doctor then shows a copilot hooks line next to the other three.

Copilot loads every *.json in its hooks directory independently, so there is nothing to merge: Stroq owns stroq.json and rewrites it whole, which makes re-running init idempotent by construction and leaves every other file in the directory — and in your repository — untouched. Put hooks of your own in a sibling file, not in stroq.json. If a stroq.json is already there and Stroq did not write it, init says so on stderr before replacing it (replacing <path>, which Stroq did not write, or would replace … under --dry-run, so --dry-run | jq still works) — the name is Stroq's by contract, but the overwrite is never silent.

Stroq installs on two of Copilot's events, with no matcher:

Copilot eventWhat Stroq doesCan it stop the action?
preToolUseClassifies the shell command, every path a file tool or an apply_patch declares, every URL a web_fetch carries, or the MCP call and its arguments (secret egress included), and applies your policyYes — deny and a real ask
postToolUseScans the command output, the file body, the fetched page or the MCP result, taints the session, records provenanceNo — but a suspect result adds additionalContext for the model

No matcher is written on purpose. A matcher is a regex over the native tool name, and Copilot's hooks never reveal an MCP server, so any list Stroq could write would be a list of the tools it already knows about — and the MCP call it has never heard of would be the one that skipped the hook. Every tool goes through Stroq instead; one it does not care about returns nothing in a few milliseconds.

Two things follow from that same blind spot. First, a tool name Stroq does not recognise is treated as an MCP call and classified as mcp__copilot__<tool>, because an MCP call has to reach the secret-egress guard; the mis-guess is safe in one direction only, so an unlisted native tool is merely scanned. Second, str_replace_editor carries its own sub-command in a field called command — view, create, str_replace, insert, undo_edit — which is not a shell command: Stroq reads it only to tell a read from a write, and never hands it to the shell classifier.

Only bash and powershell are documented by GitHub as shell tools, so Stroq also treats shell, sh, zsh, exec_command, local_shell and run_command as shells. A spelling Stroq did not recognise would be classified as an MCP call instead, and the shell rule set — curl … | sh, encoded execution, destructive commands — would never run on it. The same reasoning applies to the fields inside a call: a shell command is read from command, cmd, input, script or raw, a file path from path, file_path or raw, and a fetched URL from url, uri, href or raw. Every spelling a payload actually carries is judged on its own and the worst decision wins, so a harmless first field cannot shadow a dangerous later one.

The decision is a top-level object, not Claude Code's hookSpecificOutput envelope, which Copilot does not honour for a decision:

{
  "permissionDecision": "deny",
  "permissionDecisionReason": "Stroq blocked this action (deny-self-tamper): …"
}

.github/hooks/*, .github/copilot/settings(.local).json, ~/.copilot/hooks/*, ~/.copilot/settings.json and ~/.copilot/config.json are protected the same way .claude/settings.json, .cursor/hooks.json and .codex/hooks.json already were, for every agent — disableAllHooks: true in Copilot's settings would switch the firewall off, so that file is guarded alongside the hooks themselves.

Limits.

  • A hook that times out fails open, and Stroq cannot change that. Copilot treats a hook slower than timeoutSec as an allow and discards its late deny, even on preToolUse (github/copilot-cli#2893). Stroq answers in well under a second and installs timeoutSec: 30 — Copilot's own default, deliberately more than the 15 seconds the other three agents get, because here a shorter budget is strictly less safe. Copilot also dispatches hooks serially under parallel tool use, so keep npm install -g @stroq/cli rather than relying on an npx download inside the budget. A hook that cannot start is a different case: Copilot reads that as a hook error and denies, which is the good one.
  • ask is a real prompt in the interactive CLI, and a deny in the cloud. Copilot's coding agent turns every ask into a deny, so a destructive command that would prompt you at the terminal simply stops there. That is Copilot's behaviour, and it fails in the conservative direction.
  • MCP server names are invisible to hooks. Every MCP call is classified as mcp__copilot__<tool>, so a policy rule keyed on a server cannot be written for Copilot the way it can for Claude Code and Cursor. Rules keyed on the tool name, on mcp.call/mcp.side_effect, and the secret-egress guard all work normally.
  • A call Stroq cannot read is denied, not allowed. If Copilot sends a toolArgs Stroq cannot get a command, a patch path, a file path or a URL out of — a field spelling it does not know, a shape it does not expect — the call is denied with copilot-unreadable-input, and the reason names the top-level keys it saw (never their values, which is where a secret would be) so you can report the payload shape. A web_fetch is in that list for a reason: a URL that did not survive the mapping would classify as a fetch with no host and nothing for the secret guard to look at, which is an allow. An empty toolArgs has nothing to act on and is unaffected. A call naming more than 64 files or URLs — an apply_patch's files, a web_fetch's URLs — is denied outright (copilot-too-many-targets) rather than classified one target at a time, because the classification would risk running past the timeout, and a timed-out hook fails open. The list Stroq fans out over is always the one it computed itself from the fields above; a urls or file_paths the payload brought with it is dropped, so it can neither add targets nor hide the real one.
  • The Copilot wire format is inferred, not recorded. It comes from GitHub's hooks reference, the Copilot CLI tutorials and the SDK's preToolUse documentation, plus the open issues above; the fixtures in this repository are hand-written from that reading. That is why the adapter accepts toolArgs as an object and as a JSON string, reads several field spellings, and denies what it cannot read.
  • Hooks may not fire everywhere. Copilot does not run hooks defined by plugins (#2540) and may not run them inside some subagent contexts (#2392) — Stroq installs as a repository or user hook, never as a plugin, which is the path that does fire.
  • Not used in v1: permissionRequest, modifiedArgs/modifiedResult rewriting, postToolUseFailure (a failed tool's error text is not scanned), the PascalCase (VS Code) event format, inline hooks in settings.json, the /etc/github-copilot/policy.d directory, and plugin packaging.
  • Untested on real hardware: Windows. A powershell entry is written beside every bash one, and no part of the install has been exercised on a Windows machine. The classifier is no longer blind there — protected paths, credential paths and git-exec paths are matched with backslashes as well as slashes, and the PowerShell and cmd forms of download-and-execute, encoded execution, recursive deletion and self-tamper are classified (see the PowerShell subset) — but the install, spawn and hook-invocation paths are still only reasoned about, not run.

Run the Copilot demo yourself: pnpm install && pnpm build && ./examples/demo/run-copilot-demo.sh.

OpenClaw

npx @stroq/cli init --agent openclaw   # installs the plugin, then links and enables it

OpenClaw has no hooks file: it loads plugins, in process, from a directory. So init does something different here from what it does for the other four agents — it copies the five-file plugin @stroq/cli ships into ~/.stroq/openclaw-plugin/ (or $STROQ_HOME/openclaw-plugin/), writes a stroq.json beside it recording how to start Stroq, and then runs these two for you when openclaw is on PATH, or prints them when it is not:

openclaw plugins install --link ~/.stroq/openclaw-plugin
openclaw plugins enable stroq

Restart the Gateway afterwards: plugins are loaded when it starts. stroq doctor then shows an openclaw plugin line next to the other four. --dry-run prints the directory, the files and the two commands as JSON and writes nothing. There is no project/user split — an OpenClaw plugin belongs to a Gateway host, not to a repository — so --user and the default scope write the same directory, and re-running init overwrites it, which is what makes the install idempotent.

The plugin's entry point is deliberately tiny — index.js is under 200 lines of dependency-free JavaScript, with the spawn-and-parse mechanics factored into a small sibling module, run-stroq.js — because it runs inside the Gateway process. That is also the trust boundary: Stroq's gate registers at priority 100 so it answers before ordinary hooks, and a lower-priority plugin cannot override its block: true — but a plugin that runs before Stroq may rewrite params, and Stroq judges the call it is handed, so a Gateway loading untrusted plugins is trusting them. All it does is spawn stroq hook openclaw pre|post, hand it the event as JSON on stdin, and map the reply:

OpenClaw hookWhat Stroq doesCan it stop the action?
before_tool_call (priority 100, no matcher)Classifies the shell command, every path a file tool or an apply_patch declares, every URL a web_fetch carries, or the call and its arguments as an MCP call (secret egress included), and applies your policyYes — block, and a real requireApproval prompt for ask
after_tool_callScans the command output, the fetched page, the file body or the tool result, taints the session, records provenanceNo — observe-only; the warning is logged and the taint is enforced on the next call

An ask becomes a genuine approval request: the run pauses and you answer /approve <id> allow-once or deny in the chat or the UI. allow-always is deliberately not offered — Stroq audits every ask, and a remembered allow is one it would never be asked about again.

No matcher is written, for the same reason as on Copilot: a matcher is a list of the tools Stroq already knows about, and the one it has never heard of would be the one that skipped the gate. A tool name Stroq does not recognise is treated as an MCP call and classified as mcp__openclaw__<tool>, which is what puts its arguments in front of the secret-egress guard — so a .env value inside a message body or a browser form fill is caught. exec, read, write, edit, apply_patch, web_fetch, web_search and x_search map onto Stroq's own tool names; ask_user, progress_card, heartbeat_respond and get_goal are passed through and classify to nothing — exactly these four, since none of them leaves the session, returns external content, or mutates state. view_image, the media generators (image_generate, music_generate, video_generate, tts), the tool_search/tool_describe family and the other goal tools (create_goal, update_goal) are classified as MCP calls instead, the same as browser or message: each one returns external content or otherwise warrants the same scrutiny a real MCP call gets, so self-mapping any of them the way ask_user is would have exempted it from the scan, the secret-egress guard and the fail-closed path all at once. exec is the documented shell tool, and Stroq also treats bash, sh, zsh, shell, exec_command, local_shell, run_command and terminal as shells: a shell spelling classified as an MCP call would never meet the shell rule set at all. Inside a call, a shell command is read from command, cmd, input, script or raw, a file path from path, file_path or raw, and a fetched URL from url, uri, href or raw — every spelling a payload actually carries is judged on its own and the worst decision wins, so a harmless first field cannot shadow a dangerous later one.

The CLI answers in Stroq's own JSON, because the only thing reading it is the plugin in this repository:

{
  "decision": "deny",
  "ruleId": "deny-self-tamper",
  "reason": "Modifying agent security configuration is blocked; that change is yours to make, outside the agent (to take Stroq out: stroq uninstall)"
}

.openclaw/openclaw.json and the .openclaw/plugins/ and .openclaw/extensions/ directories are protected the same way .claude/settings.json, .cursor/hooks.json, .codex/hooks.json and .github/hooks/ already were, for every agent — plugins.entries.stroq.enabled = false in that config would switch the firewall off, so it is guarded alongside the directories a replacement plugin would be dropped into. The commands that switch the plugin off are guarded too: openclaw plugins disable|remove|uninstall … and any openclaw config set plugins.… are self-tampering when an agent runs them — which also means the repairs Stroq asks for (adding stroq to plugins.allow, setting plugins.entries.stroq.enabled or …config.workspace) are yours to run, outside the agent. Everything else under .openclaw — agent instructions, skills, memory — is ordinary work and is not touched.

Limits.

  • No warning reaches the model after a suspect result. after_tool_call is an observe hook: OpenClaw ignores what it returns, so a poisoned page or command output taints the session silently and is logged through the plugin's logger. The taint is still enforced on the next tool call, which is where the network command, the secret read or the external push actually happens. tool_result_persist and agentToolResultMiddleware could carry the warning back to the model and are not used in v1.
  • ask needs somewhere to ask. requireApproval reaches you through the UI or a configured chat channel. With no route, or if nobody answers inside the timeout (2 minutes by default, askTimeoutMs to change it), OpenClaw blocks the call — the conservative direction, but it means an unattended Gateway turns every ask into a deny.
  • The plugin spawns a process per tool call. About 100–200 ms, dominated by Node's start-up. Keep npm install -g @stroq/cli on the Gateway host; the plugin blocks the call if Stroq does not answer inside timeoutMs (10 s by default), which is fail-closed but also the one way a slow disk can stop your agent.
  • STROQ_HOME is not recorded in the plugin. init writes the plugin under whatever STROQ_HOME was set when you ran it, but the plugin spawns a Stroq that reads that variable again at run time. If you use a non-default home, set it for the Gateway process too, or the audit log and sessions the plugin writes will be under ~/.stroq instead.
  • MCP tool names are not documented for OpenClaw. Every non-native tool is classified as mcp__openclaw__<tool>, so a policy rule keyed on a server cannot be written the way it can for Claude Code and Cursor. Rules keyed on the tool name, on mcp.call/mcp.side_effect, and the secret-egress guard all work normally. process and code_execution are classified this way too rather than as shells — terminal is not; it takes a command line, so it is treated as a shell exactly like exec — which also means the secret-egress guard sees only its command, not the rest of the call, unlike a process call whose whole record is scanned. The other two keep the MCP classification because their parameter shapes are undocumented, and the shell kind reduces a call to one command field: a call whose shape Stroq could not read would then be denied outright as openclaw-unreadable-input rather than run. As side-effect tools they are still scanned on post, still guarded against secret egress on pre, and still subject to the mcp.side_effect rules under taint — but in an untainted session their command text is not classified by the shell rule set.
  • The working directory is always the plugin's own, never the call's. Every tool — exec included — is judged against plugins.entries.stroq.config.workspace, or the Gateway's own directory when that config is unset; a tool call's own params.cwd is never read for this, because a model that could point it elsewhere could point the project's .env* secret index and path rules at an empty directory and walk a credential straight past the guard. The home-directory sources (~/.aws/credentials, ~/.npmrc, ~/.netrc, credential-shaped environment variables) are indexed regardless of cwd either way. Files on a remote or sandboxed exec host are not indexed at all.
  • A call Stroq cannot read is blocked, not allowed. If a tool sends params Stroq cannot get a command, a patch path, a file path or a URL out of, the call is denied with openclaw-unreadable-input, and the reason names the top-level keys it saw (never their values, which is where a secret would be) so you can report the payload shape. An empty params has nothing to act on and is unaffected, and so is a read: a read whose path Stroq cannot find is allowed rather than denied, the same trade-off as an internal error on a low-impact tool — blocking every unreadable read in a session buys less than it costs, at the price of a tainted read of .env slipping through an unfamiliar payload shape. A call naming more than 64 files or URLs is denied outright (openclaw-too-many-targets) rather than classified one target at a time, because that would risk running past the hook timeout. The list Stroq fans out over is always the one it computed itself; a urls or file_paths the payload brought with it is dropped, so it can neither add targets nor hide the real one.
  • Everything fails closed except the reads. A missing binary, a spawn error, a non-zero exit, a timeout, an aborted run or an answer the plugin cannot parse all block the call — which is also OpenClaw's own policy for this hook. The exception is a pre on a tool that only looks at things (read, web_search, x_search, ask_user and the other pass-through tools), where a Stroq internal error allows rather than blocking; blocking every read in a session because Stroq failed once buys less than it costs, but it does mean a read of .env under taint could slip through an internal error.
  • Plugin loading has its own switches, and the config is read once. plugins.entries.stroq.enabled has to be true, and stroq has to be in plugins.allow if you use an allowlist. plugins.entries.stroq.config is read when the plugin registers, so restart the Gateway after changing workspace, timeoutMs, askTimeoutMs or stroqBin — an edit alone changes nothing in the running process. openclaw hooks list may not show hook-only plugins (rtk#1717), so use openclaw plugins inspect stroq --runtime to check. stroq doctor only tells you the plugin is on disk — it deliberately does not run openclaw to find out whether the Gateway loaded it.
  • The OpenClaw wire format is inferred, not recorded. It comes from OpenClaw's plugin and tools documentation plus one production hook-only plugin; the fixtures in this repository are hand-written from that reading. That is why the adapter accepts params as an object and as a JSON string, reads several field spellings, and denies what it cannot read.
  • Not used in v1: tool_result_persist and agentToolResultMiddleware, trusted tool policies (api.registerTrustedToolPolicy), params rewriting, before_agent_run and the message hooks, and publishing the plugin to ClawHub.
  • Untested: Windows. The plugin is plain Node and should work wherever the Gateway does, and nothing here has been exercised there.

Run the OpenClaw demo yourself: pnpm install && pnpm build && ./examples/demo/run-openclaw-demo.sh.

Windsurf

npx @stroq/cli init --agent windsurf   # in your project: merges into .windsurf/hooks.json

--user writes ~/.codeium/windsurf/hooks.json instead, --dry-run prints the merged file without writing it. Windsurf's docs do not say when hooks.json is read, so restart Windsurf (or reload the window) if the hooks do not fire; stroq doctor then shows a windsurf hooks line next to the other five.

Windsurf merges one hooks file per level — system, then user, then workspace — so Stroq's entries share a file with your own, and init merges into it the way it does for Cursor: every foreign entry, every foreign event and every other key of the file is preserved, and re-running init replaces Stroq's own entries rather than stacking a second copy. The JetBrains plugin reads ~/.codeium/hooks.json, which init does not write; copy the entries there if you use it. Every hook file Windsurf and Devin Desktop read (listed below) is protected from tampering either way.

Devin Desktop. Windsurf's documentation now lives at docs.devin.ai under the name Devin Desktop, and there the workspace file is .devin/hooks.json: .windsurf/hooks.json, the file init writes, is read only when .devin/hooks.json is absent or defines no hooks. A repository that ships a .devin/hooks.json of its own, or an agent that writes one, therefore switches a project install off without touching a byte of Stroq's file. stroq doctor reports that as SHADOWED (and stroq exposure stops counting the install as guarding), stroq init prints a warning when it finds such a file, and the way out is stroq init --agent windsurf --user: the user file has no Devin twin and the levels are merged, so a repository cannot replace it. This is taken from the documentation, not from a live Devin Desktop build. Devin's command-line agent is a different product: it reads .devin/hooks.v1.json, .devin/config.json, ~/.config/devin/config.json and, by default, Claude Code's .claude/settings.json hooks (docs.devin.ai/cli/extensibility/hooks/overview). Stroq has no adapter for it, and does not protect those files beyond .claude/settings.json.

Stroq installs on six of Windsurf's twelve events, with no matcher (the format has none):

Cascade eventWhat Stroq doesCan it stop the action?
pre_read_codeClassifies the file path and applies your policyYes — exit 2
post_read_codeOpens the file Cascade just read (up to 1 MiB), scans it, taints the session, records provenanceNo — but a suspect file exits 2 so Cascade sees the warning
pre_write_codeClassifies every path the write names and applies your policyYes — exit 2
pre_run_commandClassifies the command line and applies your policy, secret egress includedYes — exit 2
pre_mcp_tool_useClassifies the call as mcp__<server>__<tool> and scans its whole argument object for secret valuesYes — exit 2
post_mcp_tool_useScans the MCP result, taints the session, records provenanceNo — but a suspect result exits 2 so Cascade sees the warning

The other six are deliberately not installed on, each for a stated reason: post_write_code (Cascade wrote the content, so there is nothing untrusted to scan), post_run_command (its payload carries the command line and working directory only — no output — so a hook there is a Node start per command that can scan nothing), pre_user_prompt (your own words are not Stroq's to police), post_cascade_response and post_cascade_response_with_transcript (the model's own text, delivered asynchronously) and post_setup_worktree. Stroq still answers those events, and any future one, with exit 0 and no output: it does not block what it does not understand.

There is no stdout contract in Windsurf's hook API, so everything meaningful is an exit code plus stderr: 0 proceeds, 2 shows the message to Cascade and — on a pre_* event — blocks the action, and any other exit is an allow. init writes show_output: true on every entry so a block reason and a taint warning are visible in the Cascade UI as well; on an allow Stroq prints nothing at all, so nothing shows.

Stroq blocked this action (deny-self-tamper): Modifying agent security configuration is blocked; that change is yours to make, outside the agent (to take Stroq out: stroq uninstall)

.windsurf/hooks.json, .devin/hooks.json, ~/.codeium/windsurf/hooks.json, ~/.codeium/hooks.json and the system files /etc/{windsurf,devin}/hooks.json, /Library/Application Support/{Windsurf,Devin}/hooks.json and C:\ProgramData\{Windsurf,Devin}\hooks.json are protected the same way .claude/settings.json, .cursor/hooks.json, .codex/hooks.json and .github/hooks/ already were, for every agent. .windsurf/rules/ and .windsurf/workflows/ stay editable: the match is on the hooks file, not the directory.

Limits.

  • No ask. Cascade hooks can only allow or block, so a policy ask is rendered as a block that names the rule and says how to proceed (Stroq would ask before this action (<rule>): … Windsurf hooks cannot prompt, so it is denied; run it yourself or relax the rule in ~/.stroq/policy.yaml.). The audit keeps the real ask.
  • Command output is invisible to hooks. post_run_command carries the command line and working directory only, so a poisoned command output cannot taint a Windsurf session the way it taints Claude Code, Cursor, Codex or Copilot. Files Cascade reads do taint — post_read_code gives Stroq the path, and Stroq opens the file itself, reading at most 1 MiB of it — and so do MCP results. Web pages Cascade fetches have no hook event at all and cannot taint the session either.
  • A directory read scans nothing, on both sides of the call. Cascade reads directories recursively; post_read_code opens what it names, and a directory reads as empty, so a poisoned file inside a directory Cascade read does not taint the session — the file itself, read individually, still does. On pre_read_code, a tainted read of ~/.ssh or ~/.aws is denied with or without a trailing slash, as is one of a file inside them.
  • No hook timeout setting, and the docs do not say what a hung hook does. Stroq answers in well under a second; keep npm install -g @stroq/cli so no npx download runs inside the hook.
  • Any exit other than 0 or 2 fails open, which is Windsurf's contract. Stroq therefore answers its own internal errors on a high-impact pre event — pre_run_command, pre_write_code, pre_mcp_tool_use — with exit 2 and the reason on stderr, and never exits 1 on purpose. An internal error on pre_read_code, on any post_* event or on an event Stroq did not install on answers with silence: the same trade-off every other adapter makes for its read tool, and after the fact there is nothing left to block.
  • The working directory used for policy is the hook's own, which Windsurf sets to the workspace root, and never the cwd a command names: a model that could point that elsewhere could point the project's .env* secret index and the path rules at an empty directory. A command run in another directory is still judged against this workspace's secret index.
  • A call Stroq cannot read is denied, not allowed. If a pre_write_code or a pre_run_command arrives with a non-empty tool_info Stroq cannot get a path or a command out of, it is denied with windsurf-unreadable-input, and the reason names the top-level keys it saw (never their values, which is where a secret would be) so you can report the payload shape. An empty tool_info has nothing to act on and is unaffected, and so is a pre_read_code. A call naming more than 64 files is denied outright (windsurf-too-many-targets); no documented Windsurf payload can reach that bound, and it exists so that no future candidate list is unbounded.
  • A bare delete of the directory between the protected file and the protected root is not caught. rm -rf .windsurf, rm -rf .codeium, mv .windsurf .bak and rm -rf .windsurf/* are denied, and so is a write to any protected hook file; rm -rf ~/.codeium/windsurf — which takes the global hook file with it without naming the protected root or file directly — is only ask-destructive. Subpaths such as .windsurf/rules/ and .windsurf/workflows/ stay editable.
  • pre_write_code's audit summary is the file path alone. edits are never written to the audit: summarizeInput returns the first preferred field it finds as a bare string, and file_path comes before the fallback that would stringify the whole record. The secret egress guard does not scan file writes — it guards egress — the same as for every other agent.
  • The Windsurf wire format is taken from documentation, not recorded from a session. It comes from Windsurf's Cascade Hooks reference cross-checked against four third-party integrations that read the same payloads; the fixtures in this repository are hand-written from that reading. That is why the adapter accepts tool_info as an object and as a JSON string and reads several field spellings — a command from command_line, command, cmd, input, script or raw, a path from file_path, path or raw — judging every spelling a payload actually carries and taking the worst. An MCP call's arguments are the exception: mcp_tool_arguments, then arguments, then args is first-wins, not judge-every-spelling, so a payload carrying an empty mcp_tool_arguments beside a populated args scans the empty one — accepted, since Windsurf itself never writes anything but mcp_tool_arguments.
  • Not used in v1: post_write_code, post_run_command, pre_user_prompt, post_cascade_response, post_cascade_response_with_transcript (a later adapter could scan the JSONL transcript for command outputs after each turn — too late for that turn, in time for the next) and post_setup_worktree. init does not write the system-level files or the JetBrains plugin's ~/.codeium/hooks.json.
  • Untested: Windows. A powershell entry is written beside every command one, and nothing here has been exercised there. The protected-path match accepts either separator and is tested on backslash spellings, but no live Windsurf or Devin Desktop on Windows has been run against it.

Run the Windsurf demo yourself: pnpm install && pnpm build && ./examples/demo/run-windsurf-demo.sh.

Google Antigravity

npx @stroq/cli init --agent antigravity   # in your project: merges into .agents/hooks.json

--user writes ~/.gemini/config/hooks.json instead, --dry-run prints the merged file without writing it. Antigravity's docs do not say when hooks.json is read, so restart it if the hooks do not fire; stroq doctor then shows an antigravity hooks line next to the other six.

Antigravity's hooks file is keyed by a hook name at the top level, which no other supported format is — so Stroq owns one key, stroq, and rewrites it whole. Re-running init is idempotent by construction, and every other hook name in the file, and every other key of it, is left untouched. Put hooks of your own under a name of your own, not inside this one. init writes "enabled": true explicitly even though true is the default, because that one field switches Stroq off while the handlers still look installed: writing it means re-running init repairs an enabled: false, and stroq doctor reports an entry carrying false as not installed.

Stroq installs on three of Antigravity's events, with no matcher on the two that take one:

Antigravity eventWhat Stroq doesCan it stop the action?
PreToolUseClassifies the shell command, every path a file tool declares, every URL a fetch carries, or the call and its arguments as an MCP call (secret egress included), and applies your policyYes — deny, and a real prompt through force_ask
PostToolUseOpens the file a read named (up to 1 MiB), scans it, taints the session, records provenance; scans a failed call's error textNo — and it cannot even warn: stdout must be {}
PreInvocationBefore the model is called: if the session is tainted, injects one ephemeral statement of what was read and why it is untrustedNo — it is the only channel a taint has on this agent

PreInvocation is the only place in any supported agent where Stroq can put text into the model's context, and here it is the only channel there is: PostToolUse has no field that could carry a warning back. The note is a statement of fact — what was read, which rules it matched, what Stroq will now do — and never an instruction, because text arriving unattributed ahead of the model's own reasoning and telling it what to do is structurally the thing Stroq exists to detect. It is an ephemeralMessage, so it does not persist into the transcript, and never a userMessage, which would forge a turn by you. The file name or URL inside it is the one attacker-influenced value, so it is reduced to a path-shaped token: everything outside a narrow path alphabet becomes _, both ends of an over-long value are kept, and at most three sources and three rule ids are named.

A Stroq ask becomes Antigravity's force_ask, not its ask. Antigravity is only the second agent with a real prompt, so nothing is lost on the wire — but its permission model lets a standing allow rule satisfy a plain ask, and every Stroq ask exists because the context makes a normally-allowed action dangerous: a destructive command, an external push, an npx for a package that came out of tool output. A standing command(git *) grant is exactly what would swallow that prompt. deny_unless_prior_grant is deliberately never used, and Stroq never writes permissionOverrides: both would make the outcome depend on grant state Stroq cannot see or audit.

Antigravity's arguments are PascalCase, which is the single most likely way to ship an adapter that looks installed and classifies nothing: every reader Stroq already had looks for command/cmd/input/script/raw and path/file_path/url, and an Antigravity payload carries none of them. CommandLine, AbsolutePath, TargetFile and Url are read as additional spellings in the shared lists every agent uses. Only CommandLine and Cwd are documented; the others are read off the Windsurf/Cascade lineage this agent is built on, so a spelling guessed wrong surfaces as an antigravity-unreadable-input deny naming the keys it saw, never as a silent allow.

The decision is Stroq's own object on stdout, with exit code 0:

{
  "decision": "deny",
  "reason": "Stroq blocked this action (deny-self-tamper): …"
}

.agents/hooks.json, ~/.gemini/config/hooks.json and ~/.gemini/antigravity-cli/settings.json are protected the same way .claude/settings.json, .cursor/hooks.json, .codex/hooks.json, .github/hooks/ and the Windsurf hook files already were, for every agent. The match is the FILE in each case: .agents holds agent definitions and ~/.gemini is the Gemini CLI's whole home, so an edit to anything else under either stays the ordinary work it is.

Limits.

  • Nothing a tool returns reaches PostToolUse. Antigravity's post payload is the same envelope as PreToolUse plus an optional error — there is no result field at all. So a poisoned command output, a fetched page and an MCP result cannot taint an Antigravity session the way they taint Claude Code, Cursor, Codex or Copilot. What does taint: a file the agent read, because the path is in the arguments and Stroq opens the file itself (up to 1 MiB, the same as Windsurf), and a failed call's error text, which the model does see. This is the largest limit of this adapter.
  • PostToolUse cannot warn. Its stdout must be {}. The taint is set and enforced on the next action, and the warning reaches the model through PreInvocation instead — which is why stroq doctor treats an install missing PreInvocation as no install at all.
  • What a failed or timed-out hook does is undocumented, except for one case that a user's report showed. A hook line that cannot START at all (on Windows: '\"C:\Program Files\nodejs\node.exe\"' is not recognized as an internal or external command) makes Antigravity refuse every tool call and show the model the error, so the agent cannot be asked to repair it; see Windows below. Antigravity documents its stdout contract and says nothing about exit codes, crashes or timeouts. Stroq therefore never signals through an exit code: a deny, an internal error and stdin that was not JSON are all the documented deny object on stdout with exit 0 (the reason is also written to stderr, so a broken install is visible in Antigravity's logs). init writes timeout: 30, Antigravity's own default — if a timeout fails open a longer budget is strictly safer, and if it fails closed a longer budget only delays an answer Stroq produces in well under a second. Keep npm install -g @stroq/cli so no npx download runs inside it.
  • MCP server names are invisible to hooks. Every MCP call is classified as mcp__antigravity__<tool>, so a policy rule keyed on a server cannot be written the way it can for Claude Code, Cursor and Windsurf. Rules keyed on the tool name, on mcp.call/mcp.side_effect, and the secret-egress guard all work normally. A tool name Stroq does not recognise is treated as an MCP call, which is the safe direction: start_subagent, generate_image and the browser_* family are classified this way on purpose, since each brings content into the session from outside it. So is find_by_name — Cascade's spelling of find_file, the tool in Pillar Security's prompt-injection-to-RCE chain (disclosed 2026-01-07, fixed 2026-02-28) — because for a search tool the fallback is strictly more scrutiny than naming it would be.
  • A call Stroq cannot read is denied, not allowed. A run_command, file tool or fetch whose non-empty arguments yield no command, path or URL is denied with antigravity-unreadable-input, and the reason names the top-level keys it saw (never their values, which is where a secret would be). Empty arguments have nothing to act on and are unaffected, and neither a read, a search nor an MCP call can hit this rule. A call naming more than 64 files or URLs is denied outright (antigravity-too-many-targets) rather than classified one target at a time — that bound matters more here than elsewhere, because one of the two things a timed-out hook might do is allow, and Stroq cannot tell which it is facing.
  • The project is workspacePaths[0], and never args.Cwd. Cwd is model-chosen, and honouring it would let a tool call point the project's .env* secret index and the path rules at an empty directory. A multi-root workspace is indexed through its first root only, the same limit Cursor has.
  • A bare delete of the directory between the protected file and the protected root is not caught. rm -rf .agents and rm -rf ~/.gemini are denied, and so is a write to any of the three files; rm -rf ~/.gemini/antigravity-cli — which takes the global settings file with it — is only ask-destructive. Pre-existing for every agent's own directory except .stroq and .github/hooks, and a core follow-up rather than something this adapter fixes.
  • Antigravity's terminal sandbox is separate from Stroq, and on by default on macOS and Linux. A command Stroq allows may still be refused by the sandbox, and a command the sandbox would refuse is not thereby allowed by Stroq. The one case worth watching is an agent asking to leave it: an unsandboxed(...) allow rule, enableTerminalSandbox: false, or a prompted escape request all widen what the agent can reach, and a tainted session asking for any of them is a request to read closely rather than click through. Stroq does not classify those requests specially in v1.
  • The wire format is taken from Antigravity's documentation, not recorded from a session, and the fixtures in this repository are hand-written from that reading. That is why the adapter accepts arguments as an object and as a JSON string, reads several field spellings, and denies what it cannot read.
  • Not used in v1: PostInvocation and Stop (neither carries a tool call or untrusted content, and whether a run continues is not a firewall's decision), injectSteps beyond the one ephemeral note, permissionOverrides, deny_unless_prior_grant, plugin-level hooks.json (it has no fixed path and is not protected either), and the inline hooks block inside ~/.gemini/antigravity-cli/settings.json, which init does not write though it is protected.
  • Windows: the hook line has no quote in it. Antigravity hands the line to cmd.exe with each quote escaped by a backslash, and cmd.exe reads that as part of a name, so the line init used to write ("C:\Program Files\nodejs\node.exe" "C:\Users\you\AppData\Roaming\npm\node_modules\@stroq\cli\dist\index.js" hook antigravity pre) could not start, and Antigravity then refused every tool call. On Windows init now writes the paths bare, with the 8.3 name for a blank (C:\PROGRA~1\nodejs\node.exe C:\Users\you\AppData\Roaming\npm\node_modules\@stroq\cli\dist\index.js hook antigravity pre), and before it keeps a line it starts it the way that host does (cmd.exe /d /c with the line as one argument, each quote escaped) with a harmless action and with curl … | sh. The program is always a path and never a bare node: cmd.exe looks for a bare name in the current directory first, and the current directory of a hook is the project's, which a cloned repository can fill with a node.cmd of its own. When no spelling of the line starts (a profile folder with a blank and no 8.3 names) it writes nothing and says which path has no spelling without a blank, how to install where one exists, and how to take out an earlier install. stroq doctor calls a line with a quote in it BROKEN on Windows, in the plain report and whichever other agents are installed. To repair an install that already blocks every tool, from a terminal outside Antigravity: stroq init --agent antigravity (it rewrites the stroq key whole), or take the hook out with stroq uninstall --agent antigravity, each with --user for ~/.gemini/config/hooks.json instead of the project's .agents/hooks.json, or rename the file. Cursor and Codex get the same quote-free line because their hosts have not been seen on Windows, with forward slashes first: that spelling is the one a POSIX shell and PowerShell read as well as cmd.exe (the check starts it through cmd.exe only), and the backslash spelling is taken when cmd.exe cannot start the other. When no spelling starts they keep the quoted line, with a warning. Claude Code, Copilot and Windsurf keep the quoted line, which their hosts read.
  • Untested: Antigravity itself on Windows: the line is checked by starting it the way a host that escapes each quote is modelled to (from cmd /? and the error in the report), not by Antigravity. The tests that start the lines with the real cmd.exe (hook-command.windows.test.ts) run only on the Windows job of CI, which is where the model is shown right or wrong. There is no demo script for this adapter yet; the unit, shape, decision and end-to-end suites cover the wire mapping in its place.

MCP proxy (any MCP client)

npx @stroq/cli init --agent mcp --client claude-desktop  # or windsurf, cursor, claude-code
npx @stroq/cli init --agent mcp --config ~/path/to/mcp.json   # any file with an "mcpServers" object

For clients with no hook API at all — Claude Desktop above all — Stroq goes in front of the MCP server itself. (Use init --strict to withhold what the proxy could not read; see --strict below.) init rewrites each stdio entry of the client's config so it launches stroq mcp -- <the original command>; the proxy then sits on the two pipes, judging every tools/call on its way to the server and scanning every result on its way back. Restart the client afterwards: it launches its servers once, when it starts.

// before
"github": {
  "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"],
  "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_…" }
}
// after
"github": {
  "command": "/usr/local/bin/node",
  "args": ["/usr/local/lib/node_modules/@stroq/cli/dist/index.js", "mcp",
           "--server", "github", "--client", "claude-desktop", "--cwd", "/Users/me/project",
           "--pass-env", "GITHUB_PERSONAL_ACCESS_TOKEN",
           "--", "npx", "-y", "@modelcontextprotocol/server-github"],
  "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_…" }
}

--user picks Cursor's ~/.cursor/mcp.json over the project file, --dry-run prints the rewritten config to stdout (with the per-entry outcome lines on stderr, so --dry-run | jq still sees only JSON) and writes nothing, and --unwrap puts every entry back the way it was, removing any runtime pin along with the wrapper and printing a notice. Re-running init preserves an existing --pin, including malformed values that must keep failing closed, and replaces Stroq's own wrapper rather than nesting a second one — recognised by a Stroq-shaped entry path immediately followed by mcp --server <name> --client <client> — which is how an upgrade updates the recorded entry path; stroq doctor then shows an mcp proxy line counting the wrapped stdio servers of every client config it finds, and names any wrapper whose recorded entry path no longer exists as stale (an upgrade or uninstall that moved or removed it without init being re-run) rather than counting it as protected — re-running stroq init --agent mcp replaces it. A config whose mcpServers is present but not an object (a hand-edited array, say) is refused — cannot rewrite <file>: mcpServers is not an object, exit 1 — rather than guessed at.

Runtime pins. A wrapper with --pin sha256:<64 lowercase hex> starts only when its server name and digest have a local confirmed MCP record in passports.json, and its self-contained Node tree still matches that digest. Whole absolute arguments inside the store tree are relocated to the private verified copy; embedded store references are refused. The server runs with that copy as its working directory; --cwd still identifies the project for policy and secret-index decisions. Pinned legacy wrappers without --pass-env receive the filtered infrastructure environment. doctor and exposure check pins without starting servers. The copy normally disappears after shutdown, but an uncatchable kill or cleanup failure can leave it under <STROQ_HOME>/run.

A pin is written by stroq add --kind mcp, which 0.30 removed, so this is for wrappers an older version made. The proxy also compares the command line it was started with (node, its flags, the Stroq entry, every flag and the command after --) with the entry add recorded, token by token, and refuses to start on any difference (an added --cloak or a hand-made rewrite included; stroq init --agent mcp leaves such a wrapper as it is; stroq init --agent mcp --unwrap takes the pin out, a line the refusal and stroq doctor print); stroq doctor also compares the entry's env, cwd and other keys. The pin is tamper-evident, not tamper-proof: the same user can edit the store, lock or copy. Packages absent from the confirmed tree may still resolve from ancestor node_modules and are outside the pin. stroq add --kind mcp refused a server that lists dependencies, imports a package by name or has files it cannot read with certainty, so a wrapper it wrote did not knowingly depend on one; that reading of the text is best-effort and not a proof, and a hand-written wrapper can pin such a server. An agent that can edit an unprotected client config such as .mcp.json or .cursor/mcp.json can remove --pin; diagnostics only check entries still carrying it. The plain lock identifies no confirming human. stroq add --kind mcp wrote the record after a person typed the digest at a terminal (an agent cannot answer that prompt), and a record written by hand with only a name and a digest is accepted and held to those. See the CLI README's MCP runtime pins section for the shared {v:1,skills,mcp} schema and limits.

Follow-up, not built: Node's permission model for a pinned server. The reading of the text that add --kind mcp does is best-effort, and its known limits are listed in the guide. The enforcement that does not depend on it is to start a pinned server with node --permission --allow-fs-read=<the private copy> (Node 24 and later; --experimental-permission on 22), so that resolving or loading anything outside the copy, starting a child process, starting a worker or loading a native addon fails at run time. The grants a server needs would be recorded in the lock and shown on the confirmation screen. See section 14a of docs/superpowers/specs/2026-10-10-safe-install.md.

The environment a wrapped server gets. The client launches stroq mcp, so the proxy's own environment is the client's: every credential in your shell, plus whatever the entry's env block declared. A wrapped server is started with neither — only the variables its own entry declares (their NAMES are what --pass-env records; the values stay in the config and never enter argv) plus the ones any process needs to run: PATH, HOME and the Windows equivalents, the temp and locale variables, the platform variables Windows itself requires, and the proxy/CA settings a corporate network needs. If a server needs a variable it does not declare, add it to that entry's env block and re-run init. Wrappers written before this existed record no --pass-env and keep inheriting everything, rather than losing a credential on upgrade: they say so on stderr at startup and are counted separately by stroq doctor (1 inherits the full environment: re-run init). Re-running stroq init --agent mcp is the fix.

MessageWhat Stroq doesCan it stop the action?
tools/call requestClassifies it as mcp__<server>__<tool> and applies your policy to the argument object — scanned whole up to 2 MiB, refused above (see Limits), secret egress includedYes — the call is never forwarded and the client gets a tool result with isError: true
tools/call resultScans every text, structured and embedded-resource field, taints the session, records provenanceNo — but a suspect result is forwarded with one extra text block carrying the warning
tools/list resultScans every tool's name, title, description, annotations and input schema; taints the sessionNo — the listing is forwarded unchanged, and the next action is where the taint is enforced
resources/read, prompts/get resultsScans the contents and the messages, taints the sessionNo — same as tools/list
a JSON-RPC batch containing a tools/callRefuses it whole, one isError per call and -32600 for the restYes — nothing in the batch is forwarded
everything elseNothingForwarded byte for byte, key order and whitespace included

The server's own stderr is inherited and never touched, so its logging reaches the client exactly as before. --server is the config key init wrapped, so a policy rule keyed on an MCP server works here — the name is never read from the wire, where a hostile server could forge it. All the proxies of one client share one Stroq session, so a poisoned result from server A taints the calls that go to server B.

Stroq blocked this action (deny-secret-egress): Arguments contain the value of a known secret; outbound use is blocked Evidence: the arguments contain the value of DEMO_API_KEY from /Users/me/project/.env.

claude_desktop_config.json and mcp_config.json join .claude/settings.json, .cursor/hooks.json, .codex/hooks.json, .github/hooks/ and the Windsurf hook files as config.self paths for every adapter: unwrapping the proxy out of a user-level client config switches Stroq off just as surely as deleting a hook file.

--cloak: keeping the server's data away from the model provider

npx @stroq/cli init --agent mcp --client claude-desktop --cloak

Everything above is about what the agent is allowed to do. --cloak is about what the model provider gets to see. With it on, the proxy replaces values it can identify in a tools/call result with stable placeholders before the model reads them, and restores them on the way back to that same server:

server result → [cloak] → model sees [STROQ_EMAIL_1] → model emits tools/call using [STROQ_EMAIL_1] → [uncloak] → real value → server

So the agent can act on a customer record it was never shown. It is off by default: it changes what a third-party server receives and it writes a reversible dictionary to disk, and neither should happen because a default said so.

What it detects: the exact values of credentials this machine holds (the same .env*, ~/.aws/credentials, ~/.npmrc, ~/.netrc, ~/.docker/config.json and environment index the egress guard uses — strictly stronger than any pattern), plus email, phone (E.164's 7–15 digit window), iban (ISO 13616 mod-97), card (Luhn) and ssn (the SSA allocation rules, ITINs included). Person names and street addresses are claimed from the field they arrive in rather than from the characters in them: an MCP result is JSON, so first_name, surname, attendees, street_address and their spellings label the value more reliably than a model reading it could, at no dependency and for names no English-trained model has seen. A bare name and a bare address are deliberately excluded — the first is a filename or a branch as often as a person, the second an IP or a wallet — and so are username and login, which are identifiers the server looks up.

What it does not detect: a name or an address written only in prose, without a labelled field in the same result. That needs NER, and the credible offline option would be this project's first native runtime dependency — the same call that made Stroq shell out to srt rather than link it. Run the MCP demo and the customer record shows both halves: the labelled fields come back as placeholders while an unlabelled name in a separate result travels untouched; that is the gap, left visible on purpose. docs/CLOAK-COMPARISON.md puts this next to AgentCloak Desktop row by row.

A credential is cloaked on the way in and never restored on the way out. The model only ever saw the placeholder, so restoring it would be Stroq itself sending your credential to a third party — the exact thing deny-secret-egress exists to stop. Such a call is denied as mcp-cloak-secret-restore, naming the credential and never its value.

Every substitution is in the audit log, by placeholder and kind, never by value:

#3 post mcp__crm__get_customer [mcp:demo] cloak(cloak)   mcp cloak: 3 value(s) replaced in a tools/call result {email:[STROQ_EMAIL_1]×2 card:[STROQ_CARD_2]×1 ssn:[STROQ_SSN_3]×1}
#5 pre  mcp__crm__send_message [mcp:demo] cloak(uncloak) mcp uncloak: 1 placeholder(s) restored, 0 unresolved and forwarded as written {email:[STROQ_EMAIL_1]×1}

Cloak limits.

  • tools/call results only. A tools/list is a schema the client caches and validates against, so rewriting a tool description or an input schema would break the client rather than protect anyone. resources/read and prompts/get are not cloaked in v1 either, and their values reach the model intact.
  • Object keys are never rewritten. A key is structure: a record keyed by email address would come back with a key the server cannot look up. A secret placeholder hidden in a key is still found and still refuses the call — it is just never resolved.
  • A result Stroq cannot read whole is dropped, not forwarded. Above 8 MiB on the wire it is never parsed; above 2 MiB once parsed it is past what the detector scans. Either way it would be delivered with exactly the values the cloak was switched on to withhold, so it is dropped and audited as mcp-cloak-unscannable-result — and the client's request for it goes unanswered. That costs what a server which never replied would cost, and a hostile server can already do that at will. Without --cloak the same result is forwarded unscanned, as it always was.
  • A placeholder the dictionary has forgotten travels as literal text. The server receives [STROQ_EMAIL_9]. That is the fail-safe direction: a lost dictionary can never mean the wrong value is restored.
  • The dictionary is reversible, and it is on disk. ~/.stroq/cloak/, mode 0600 in a 0700 directory, one file per (session, server), entries forgotten 12 idle hours after they were last used, at most 2000 of them. It is the only reversible record Stroq keeps; rm -rf ~/.stroq/cloak removes all of them. SECURITY.md has its own section on it.
  • No cross-server restore. The dictionary is per server, so a placeholder minted from server A's data and echoed into a call to server B is not resolved — B never heard of it. Deliberate, and the safe direction.
  • Two message kinds stop being forwarded byte for byte. A cloaked result and a restored call are re-serialised. Everything else, --cloak on or off, is still forwarded exactly as it arrived.
  • The model has to echo the placeholder verbatim. It is text; a model that paraphrases it loses the link, and the call goes out with a broken placeholder rather than with a value. The cloak appends one text block to each rewritten result saying exactly this.
  • EIN (NN-NNNNNNN) is not detected. It has no checksum, and the IRS campus-prefix list is the kind of table that goes stale inside a shipped binary — where a stale list is a silent miss. ITINs are covered, since they share the SSN shape.
  • URLs are deliberately not cloaked, unlike AgentCloak. A URL carries action classification in Stroq — origin.* provenance, shell.network — so cloaking one would blind the firewall the cloak is bolted onto.

--strict: nothing reaches the client that Stroq could not read

npx @stroq/cli init --agent mcp --client claude-desktop --strict
# For a server confirmed by add, confirm the strict wrapper instead:
npx @stroq/cli add <name> --kind mcp --strict --force
# a wrapper can also be written by hand:
#   stroq mcp --server github --strict [--strict-max-withheld 20] -- npx -y @modelcontextprotocol/server-github

The default proxy scans the results it knows how to read and forwards everything else exactly as it arrived: whatever it cannot parse, whatever answers no request, whatever it throws on, and whatever a server says on its own initiative. --strict turns that around. Every string and every key of a message Stroq delivers was part of the text it scanned, and a message it cannot hold to that is withheld. The exceptions are stated, not hidden: the payload of an image, audio or blob that Stroq recognised as one (a picture or a sound is delivered unread), and the two protocol members it checks instead of scanning, jsonrpc (exactly "2.0") and the request id (the one the client sent). A field that exists to carry one protocol word is held to its shape as well as scanned: resultType (lowercase letters and underscores, up to 32), a prompt message's role (user or assistant), and an embedded resource has no type of its own. If it belongs to a request the client is waiting on, that request gets a fixed JSON-RPC error naming the reason class (and never a word the server wrote), so a withheld result is never a hang. Every withheld message is audited as mcp-strict-<reason> and counted, in one of two counts. A server with more than 20 of the malformed or hostile kind in one run (--strict-max-withheld <n>, 1 to 1000), or more than 1000 of the kinds a benign server also produces, is ended: the process is stopped at once, its waiting requests get an error, the proxy says why on stderr and exits non-zero, and any request the client sends afterwards is answered with the same error and never forwarded.

The server sendsDefault proxy--strict
a line above 8 MiBforwarded whole, never parsedwithheld (oversize); the request is answered when the id is in the first 512 characters of the line
a line that is not JSONforwardedwithheld (unparsable); same rule for the id
a response with an unknown, already answered, or differently typed idforwardedwithheld (unknown-id, id-mismatch); for a wrong type the request it was evidently for is answered
a notification or request of its own (sampling, elicitation, roots, log lines…)forwardedwithheld (unsolicited) except the five methods below; a withheld request is answered with -32601 so the server is not left waiting
a JSON-RPC error with textforwarded unscanned, no taintscanned, tainting as a result does; a suspect one carries the warning in its message
a result whose scan throws (nesting that overflows serialisation)forwarded unscannedwithheld (scan-failed)
a result the scanner is slow on (one ~100 KB result held the proxy for 12 to 18 s)the proxy stalls, then marks it suspectthe scan is stopped at a hard 4-second deadline and the result withheld (scan-timeout)
image or audio data, a resource blobforwarded unreaddelivered only if it is wholly base64 of a recognised picture or sound format whose first bytes match its media type; otherwise withheld (unread-content)
result keys beyond a short list per methodforwarded unreadwithheld (unread-content)
a result of the wrong shape (a content that is not a list, a batch, an object that is no message)forwardedwithheld (invalid-shape)
_meta, outputSchema, an item's annotations and other keys the scan skippednot scannedscanned
text past the first 200,000 characters of a resultdelivered, its tail never readwithheld (oversize-text)
the result of initialize, resources/list, completion/complete, any other callforwarded unscannedscanned whole (every string and key), then delivered (an empty result, such as a ping answer, has nothing to read)

What is still forwarded when a server starts the exchange, and why. Exactly five methods, each carrying no free text the server could put its own words in: notifications/tools/list_changed, notifications/resources/list_changed and notifications/prompts/list_changed with no parameters (they only make the client ask again, and the answer comes back through the proxy and is scanned; dropping them would hide a legitimate tool change); notifications/progress when it is a token and numbers only (its message is free text, so a progress notification with one is withheld); and ping with no parameters (a liveness check that carries nothing). Everything else is withheld, and anything not on this list is withheld by default: sampling/createMessage (the server asking the client's model to run a prompt it wrote), elicitation/create, roots/list, notifications/message (a server's logging belongs on its stderr, which Stroq leaves alone), notifications/resources/updated, notifications/cancelled, and any method added to the protocol later. When it was unsure, the answer was to withhold.

What counts toward which cap.

  • Counts toward --strict-max-withheld (default 20), the malformed or hostile kind: unparsable, invalid-shape, unknown-id, id-mismatch, oversize, unread-content, scan-failed, and unsolicited for every method but the two below (sampling, elicitation and roots requests, a list_changed notification that carries anything).
  • Counts toward the separate cap of 1000, the kinds a benign server also produces: scan-timeout, oversize-text, cloak-refused, the five forwarded server-initiated methods (ping, the three list_changed notifications, plain notifications/progress), and unsolicited for notifications/message (MCP logging) and notifications/progress with a message, when they are notifications: a message with those names that has an id is a request, the server asking the client for something, and counts toward the hostile cap.

Both are audited line by line, the message that crosses a cap included, so the audit a flood can write is bounded by the two caps together (plus one line each). A server that logs a great deal is not an attacker; one that sends a thousand log lines is flooding, and is ended all the same.

What it scans. Under --strict a result is read in four kinds of views: up to three aggregate texts plus one per-string view for each distinct decoded string. Each is scanned on its own, and the result is as suspect as the worst of them (one warning is added, whichever views matched). No single text suits every rule:

  • The default proxy's own text. For the four methods the default proxy scans (tools/call, tools/list, resources/read, prompts/get), exactly what it hands its scanner: the same reader, unchanged. Strict mode therefore scans at least what the default scans, by construction and not by a resemblance that has to be kept up. A sentence spread over four content blocks stays a sentence in it (the blocks are consecutive lines), and a rule that wants the text to end where the sentence does sees a text that ends there.
  • The decoded strings. Every string value and every object key of the result, a real newline between them. Never only the JSON form of a field, in which a line break or a tab between the words of a sentence becomes a backslash and a letter, so a sentence the model reads as one reads as clean to the rules; and never a field skipped because a sibling was read instead (an embedded resource's text behind a non-empty outer text is a string like any other). This is the view the mode is proved on: before anything is scanned or sent, Stroq checks that every string of the message it is about to deliver is in it, and withholds the message (unread-content) if one is not. The only strings left out are the payload of an image, audio or blob item that Stroq recognised as one. A result whose decoded strings do not fit the 200,000-character window is withheld (oversize-text).
  • The result as JSON. Read as far as the first 200,000 characters of the JSON (the same window the default proxy clips its own text to; no string is cut to a shorter length of its own, so a long value cannot push what follows it out of the text). About forty of the 622 bundled rules are written for JSON ("query"\s*:\s*"…'; DROP TABLE), a key and its value side by side, and in a list of decoded strings the key and the value are two lines and they would never fire.
  • The decoded strings alone (per-string). Each decoded string of the result scanned on its own, so a rule that must match at the end of a text (such as ATR-2026-02009, ending in \s*[:=]?\s*$) matches even when another field or content block follows.

They are separate texts rather than one joined text because the text decides what a rule can see: the decoded strings put a key on a line of its own between two values, which breaks a sentence the default text keeps whole, and anything appended after a text moves the end that an end-anchored rule looks for. A view identical to an earlier one is scanned once. One scan is the record of the result (the first view's): it writes the audit line the default proxy writes and remembers the atoms of EVERY view, so an address or a package that only an embedded resource's text, _meta or a key holds is remembered too, each atom once, with the cap of 200 atoms per text that every text always had. A result is audited with one line and remembered once, however many views it has. The other views are extra readings: they scan by the same rules and taint the session when they match, but remember nothing and are audited only when they matched (that line names the view). What is remembered of a result is flagged as suspect when any of its views matched.

What it does to the messages it delivers. The message the client receives is the JSON Stroq parsed, written again — not the bytes the server sent. That is deliberate: two JSON parsers can disagree about a message with a duplicate key, and the client must see the structure that was scanned, not the one a different parser would have read. The price is that delivered strict messages are no longer forwarded byte for byte, key order and whitespace aside; a number beyond 2^53 loses precision exactly as it does in any JavaScript client. Every line Stroq writes under --strict, delivered or built by Stroq itself, writes U+2028, U+2029 and U+0085 inside a string as the escapes \u2028, \u2029 and \u0085 (a JSON parser reads back the same character), so a client that splits its input on those characters does not cut a message in two at one the server chose. The tools/call request direction is unchanged, and so are the wrapper's other flags: --strict --cloak scans first and cloaks only what was scanned whole, and a result the cloak refuses is now answered (cloak-refused) where --cloak alone leaves the request unanswered.

Strict limits.

  • It is a filter in the proxy, not a sandbox for the server. The server is a process on your machine with the permissions you gave it; strict mode controls what the CLIENT is shown, not what the server does. Withholding a message cannot undo an effect the server already had, and a server can still write to its stderr, which Stroq does not touch.
  • Read does not mean safe. Strict mode guarantees a delivered message was read in full by the scanner, with the same rules the default proxy uses; an instruction those rules do not match is still delivered (and still taints on a match, with the same warning). Pictures and sounds that pass the media check are delivered unread: text drawn into an image, or spoken in a recording, reaches the model unscanned. The check itself is only that the payload is wholly base64 and starts with the signature of the format its media type names (the first dozen bytes): base64 of a sentence placed after a real header passes it.
  • A result that cannot be read whole is a result you do not get. A tool that legitimately returns more than 200,000 characters (a large file read), a listing larger than that, an image above 8 MiB on the wire, or a server whose log lines and progress messages are text, is withheld or counted. A chatty server that crosses the limit is ended. Use strict mode for servers whose results are small and whose behaviour is plain, and leave it off for the others.
  • The 4-second bound covers the scan, not the whole of handling a message, and it is a measured cost, not a proof. The hard deadline stops a dry run of the scans; parsing the line, reading the result, serialising what is delivered, and the engine's own scans sit outside it. That is an availability effect on that one wrapper's proxy, which answers late; it does not deliver anything unread, and it does not reach other servers. Each distinct view is scanned in the dry run and again by the engine. The view count grows with the number of distinct decoded strings: it is not capped at three scans or at three times the default proxy's cost. The dry run of all the views of a message shares ONE deadline of 4 seconds, not one each. The engine then scans the views separately under its own budgets, without a shared deadline for that second pass, so a slowdown between passes can make total handling exceed 4 seconds. A real scan that exceeds its budget withholds the result as scan-timeout. Before per-string views were added, review measurements recorded 1.6–6 seconds for an 8 MB clean result (about 10 seconds once, cold), and about 1.1 seconds for a 190,000-character result across the three aggregate views, compared with about 0.3 seconds in the default proxy. These are historical measurements, not measurements or bounds for the per-string implementation. The dry-run deadline is fixed and is not read from STROQ_SCAN_BUDGET_MS.
  • The dry run uses the bundled rules. It scans with the 622 rules Stroq ships, not with whatever rule set the engine was built with, so a slow rule an embedder of @stroq/core adds to the engine is not covered by the hard bound (the CLI's own engine uses the bundled rules only, so today every rule it runs is).
  • A rule written for JSON reads a window of the JSON. The JSON view stops at 200,000 characters, as the default proxy's text does, and the JSON of a large result is longer than its decoded strings (every quote, backslash and line break costs a character more). A payload that sits beyond that in the JSON of a very large result is read as decoded strings (a key and a value on two lines) and not as JSON there, and a rule that is written for JSON syntax can miss it; the default proxy has the same cut for the text it scans. Whether a rule is written for JSON is not recorded anywhere; counting patterns that name a quoted key and a colon finds 41 of 622, and 217 mention a quote at all.
  • The server-initiated messages that are forwarded are counted, audited and capped. The five methods named above (ping, the three list_changed notifications and a plain notifications/progress) reach the client as a message Stroq rebuilt from the fields it checked; each is audited as mcp-strict-forwarded and counts toward the benign cap of 1000 so a flooding server cannot make the client do unbounded work.
  • A stroq trust entry suppresses only the view it was made from. A trust entry is a hash of exact text (pinned to its source), and each view of a result is a different text, so under --strict a trusted document stops being warned about in the view whose text the entry covers and is still warned about, and still taints the session, through any other view that matches. The default proxy has one text and so one answer.
  • Ending the server signals the process group on POSIX and the tree on Windows. On POSIX Stroq spawns the server in a new process group and signals -pid (and, after the grace period, kills it), reaching descendants when the wrapped command is a launcher such as sh -c or npx. On Windows the whole tree is killed. A server that ignores the signal keeps reading its stdin until the kill, which Stroq never writes to after the end.
  • A limit shared with the default proxy: the 200,000-character window. A rule that wants the text to end where its sentence ends (ATR-2026-02009, "a secret key:" with nothing after it) was once evaded by any field that follows the sentence in the same text in the default proxy; strict mode scans each decoded string alone in a per-string view so that only the default proxy retains that evasion. A payload past the 200,000-character window of a text is missed in both, JSON escape-heavy padding included: a control character costs six characters of JSON (\u0001), so 33,000 of them fill the JSON view, and the decoded-strings view reads them at one character each.
  • Wrappers written by different versions do not mix. A Stroq without --strict cannot start a wrapper that has it: it fails on the unknown option and the server does not start (closed, not open). And an older Stroq's init --agent mcp run again on a strict wrapper rewrites it without the flag, silently. Neither happens within one version; run stroq doctor, which counts strict wrappers, after switching versions.
  • Memory is bounded by lines and by text. While the proxy works through a message the server's output waits in a queue; strict mode stops reading the server when 256 lines or about 32 MiB of text are waiting, whichever comes first (a line count alone is no bound when a line can be 8 MiB), and starts again at half. One chunk of lines can arrive past the bound.
  • A pinned wrapper keeps its --strict. stroq init --agent mcp --strict keeps pinned wrappers unchanged and prints the command that takes the pin out (stroq init --agent mcp --unwrap); strict arguments that were recorded in the lock stay in the wrapper, and flags edited directly in the client config fail the exact launch check. init --strict for any agent but mcp is refused, not ignored.
  • Re-running init does not turn it off. A wrapper that has --strict keeps it (and its limit) when init --agent mcp is run again without the flag, as a rewrite keeps a --pin; a wrapper confirmed by add is left unchanged. To remove strict mode from an unconfirmed wrapper, --unwrap and wrap again. stroq doctor shows how many wrapped servers are strict.
  • stdio servers only, like the rest of the proxy; and the audit of a withheld message names the reason class, the tool and the method (when it is a plain name), never the server's text.
  • Unaddressable replies fail pending requests instead of leaving them waiting. An unparsable or oversize line whose id cannot be recovered from its first 512 characters, or a response whose id matches no waiting request, is withheld and all requests still pending on this wrapper receive fixed errors under their own client ids. This can fail unrelated concurrent requests too: strict mode refuses to guess which one a malformed response meant. A batch from the server is withheld whole as invalid-shape; any waiting requests whose ids it carries are answered, and an unaddressable member also fails the remaining pending requests. A batch that is not valid JSON or is above the line limit cannot be associated by its first id alone, so all pending requests receive fixed errors. Completed ids are remembered in a bounded set so a readable duplicate late reply does not fail newer requests; reuse of an id for a new client request clears that memory. Nothing of the unaddressable response is delivered. Default mode is unchanged.

Limits.

  • No ask. An MCP proxy has no channel to a human, so a policy ask is rendered as a blocked tool result naming the rule (Stroq would ask before this action (<rule>): … An MCP proxy cannot prompt, so it is denied; run it yourself or relax the rule in ~/.stroq/policy.yaml.). The audit keeps the real ask.
  • stdio servers only. Entries with url or serverUrl are HTTP servers with no subprocess to wrap: init skips them, lists them as skipped, and they are not protected. VS Code's .vscode/mcp.json (key servers) and Codex's config.toml use other shapes and are not rewritten in v1.
  • The warning block modifies the tool result the model sees on a suspect scan: one extra { "type": "text" } item appended to content. structuredContent, isError and every other field are untouched. Clients that validate content strictly still accept an extra text item.
  • Results delivered outside the tools/call response are not scanned: the tasks extension (tasks/get), resource subscriptions, and sampling or elicitation payloads inside legacy server-initiated requests.
  • The project directory is the one init ran in. Claude Desktop launches its servers from /, so the directory is recorded in the wrapper as --cwd and nothing on the wire can change it. A user-level config wrapped from another directory indexes that directory's .env, plus the home credential files as always. Re-run init from the project you want indexed.
  • Batches containing a tools/call are refused rather than judged call by call. Batching was removed from the MCP protocol in 2025-06-18, so no current client sends one.
  • Session taint is per client, not per conversation. Claude Desktop has no conversation id on the wire, so a taint set in one chat persists into the next until the session expires. stroq untaint --session mcp:claude-desktop clears it.
  • .mcp.json and .cursor/mcp.json are not self-tamper protected. Adding an MCP server to a project config is routine agent work, and denying it would be the false positive the protected-path list was narrowed to avoid — so an agent can add an unwrapped server to a project config. The two user-level client configs above are protected. What is asked about is narrower: a write to one is config.instructions (asked in a tainted session), and one whose text registers a shell or an inline interpreter as the server is config.instructions_payload (asked in any session). A check that protects only the wrapped entries is still the follow-up.
  • A tools/call Stroq cannot read or address is denied, not allowed. A call with no string params.name is answered with mcp-proxy-malformed-call, and a batch containing one with mcp-proxy-batch; both name the shape and never a value. A tools/call whose request id cannot be classified as a valid JSON-RPC id is dropped and audited as mcp-proxy-unaddressable-call, since no reply could ever be addressed to it. A client line that is not valid JSON — after a leading byte-order mark is stripped — is otherwise forwarded unchanged (the client is trusted on framing), UNLESS its text names tools/call (matched case-insensitively): that one is dropped and audited as mcp-proxy-unparseable-call rather than forwarded unread, since an unreadable line is exactly how a secret-bearing call could otherwise dodge every check above.
  • Tool arguments above 2 MiB are refused fail-closed. The secret-egress guard scans the serialised argument object in overlapping windows up to 2 MiB, so a call whose arguments serialise past that would be judged on a prefix of itself — and enough padding ahead of a .env value is all it would take to put that value outside every window. Such a call is answered with mcp-proxy-arguments-too-large and never forwarded; the reason names the bound and never a value, and the audit summary names the argument keys only. The engine denies the same call anyway (secret.unscannable → deny-secret-unscannable); refusing here is cheaper and keeps a 2 MiB serialisation out of the audit summary. A real tool call is nowhere near this size, but a legitimate one that is (a large file body passed inline, say) is blocked rather than sent unscanned.
  • A server result above 8 MiB is forwarded unparsed, unscanned and untainted. The bound applies to server output only — a client line is always parsed however large — and it exists so one hostile server response cannot grow the proxy's memory without bound. A server can therefore pad a poisoned result past 8 MiB to keep it out of the scan entirely: no taint, no provenance, no warning block, and the model reads it in full. This is a documented limit, not a guarantee — unless --strict is on (see above), which withholds that result and answers the request instead.
  • A throw while judging is a deny (mcp-proxy-internal-error); a throw while scanning is a forward. An engine that cannot answer must never become an allow on the way in; on the way out the result the model asked for still reaches it, and the failure is logged, which is the same observe-only trade-off every hook adapter makes on post. Under --strict a throw while scanning withholds the result (mcp-strict-scan-failed) and answers the request. (Arguments large enough to overflow core's provenance-atom extraction — a content-dependent limit somewhere above a few MiB, tracked as a core follow-up — can no longer be reached through the proxy: the 2 MiB refusal above stops such a call first.)
  • The wire handling is built from the specification and three open-source proxies, not recorded from a client, and the demo's server is hand-written. Windows: the rewritten command is Node's absolute path, which works there, but nothing has been exercised on Windows.

Run the MCP demo yourself: pnpm install && pnpm build && ./examples/demo/run-mcp-demo.sh.

The PowerShell subset

Copilot and Antigravity both list powershell as a shell tool, and the command text used to go straight to the POSIX classifier, which looks for curl … | sh, for rm -rf and for base64 -d. PowerShell writes all three differently, so the measured result for every one of the flagship dangerous shapes was an empty class list and an allow.

Why there is no dialect flag. The obvious fix is for the adapter to say which shell a command is written in. Both agents also expose run_command and terminal, which on Windows are PowerShell under a dialect-neutral name, and a bash tool on Windows may be Git Bash, WSL or a shim — nothing in the payload settles it. A flag would be a guess, and a guess that came out wrong would put the silent clean back, one layer further up where it is harder to see. So the PowerShell detectors run on every command, and each is written to be inert on POSIX text: Invoke-WebRequest, Remove-Item, Format-Volume and -EncodedCommand do not occur in a shell command that means something else. The cost is the one the self-tamper gate already accepts: a command that merely quotes one of these names can be classified, which is the conservative direction.

What is covered. Download-and-execute (iwr … | iex, IEX (New-Object Net.WebClient).DownloadString(…)) as shell.exec_encoded; Invoke-WebRequest/Invoke-RestMethod/Start-BitsTransfer and the certutil/bitsadmin fetch forms as shell.network; -EncodedCommand and [Convert]::FromBase64String as shell.exec_encoded; Remove-Item -Recurse / rd /s aimed outside the working tree, plus Format-Volume, Clear-Disk and diskpart, as shell.destructive; Get-ChildItem Env: as an env dump; and PowerShell's and cmd's own write verbs (Remove-Item, Set-Content, Move-Item, del, …, matched case-insensitively) against the protected-path list, so Remove-Item -Force .claude\settings.json is config.self.

shell.unparsed, and why it is narrow. A subset that stays quiet about what it did not understand is the same silent clean in a new place, so a dynamic-execution sink whose operand cannot be read — iex $payload, & $cmd, a pipeline fed into Invoke-Expression from something that is not a fetch — yields shell.unparsed, which the default policy turns into an ask. That verdict is triggered by the construct and never by failure to recognise a command: prompting on every unfamiliar PowerShell command would put a confirmation in front of Get-ChildItem, and a check that fires on ordinary work is a check people switch off. The POSIX shells have the same construct: a shell that reads a program it was not given by name (from a pipe, a here-string, a here-document, <(…), a -c string, eval) is shell.unparsed when that program is a command word that is a variable, a substitution, a glob or a brace expansion, or comes from a command Stroq cannot read; a program written out is read as the commands it carries. It is the construct that asks, never an unfamiliar command.

What is still uncovered. A dangerous cmdlet this list does not name, spelled plainly with literal arguments, is classified as nothing. A command invoked by fully-qualified Windows path (C:\Windows\System32\del.exe …) resolves to a command word the POSIX tokenizer cannot read, so it reaches the self-tamper gate as ask rather than deny. Dot-sourcing an expression (. $script) is deliberately not flagged, because . $HOME/.bashrc is ordinary POSIX work. iex is also the Elixir REPL, so the bare word is never a signal on its own — only as a pipeline sink standing alone, or with an operand that is plainly an expression.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.