You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
http_request is scope:"read" but can POST/PUT/DELETE with the owner's key — it never reaches the write-consent gate #307
So an agent holding http_request in its declared capabilities.tools can POST / PUT / PATCH / DELETE to any third-party API — with the owner's vault key injected into the configured header or query param — without the instance ever having granted http write consent. Compare mcp_call_tool, sheets_append, github_create_issue, all of which are scope: "write" and do pass the gate. The tool is reachable from chat, from a pipeline step, from POST /v1/instances/:id/tools/http_request and over MCP.
It is not an SSRF issue — executeHttpRequest goes through safeFetch correctly. It is a consent issue: the kill switch a subscriber flips to say "this agent may not change things in the outside world" does not cover the one tool that can change anything.
Why the obvious fix is wrong
Flipping the tool to scope: "write" puts every read-only GET behind a write consent it has never needed — the Places lookups in the site-builder pipeline, the lead-finder's enrichment, pages-mode fan-out in lib/steps.ts. Those would start failing on instances that have consented to nothing, which is most of them.
Suggested fix
Gate per CALL, inside executeHttpRequest: when the resolved method is not GET/HEAD/OPTIONS, require hasConsent(env, consentInstanceOf(ctx), "http", "write") and refuse with the same wording runRegistryTool uses. That keeps reads free and makes the write path honour the switch. lib/steps.ts composes the same executor, so the pipeline path is covered by the same change.
Until then the hole is recorded as a ratcheted exception in security-invariants.test.ts (KNOWN_UNGATED): the guard fails if a second method-choosing tool appears, and it also fails when this one is fixed, so the entry cannot be left behind.
The gate is per CALL, inside executeHttpRequest — decided by the method actually resolved for this request rather than by the tool's declaration, so reads stay free and mutations honour the switch. It sits in the shared executor rather than in the http_request handler because that is the one place chat, a pipeline step, POST …/tools/:name, MCP and every compileConnector manifest tool all pass through; a manifest tool that already declares scope:"write" is simply checked twice against the same key. It runs before the vault key is read and before anything goes on the wire, so a refusal cannot have already spent the credential — asserted by a test that counts token() reads.
Your premise that the seeded pipelines are all GET is not quite right, and a naive method gate would have broken them. Google's Places (New) API has no GET search: searchText and searchNearby are POST-only, so lib/steps.ts's geocode step and the lead-finder's grid sweep (lead-finder.json) are READS that HTTP cannot express as reads. Gating them would demand blanket http write consent for a pipeline that only looks things up — which trains an owner to grant exactly the permission this gate exists to withhold. So those two endpoints are a pinned exemption (READ_SHAPED_POST), matched on origin+path and anchored, with tests for query-string and lookalike-host smuggling. Exact endpoints only, never hosts, so the same vendor's mutating routes stay gated. Heuristics were rejected deliberately: every one of them ("has a responseMap", "has no body", "looks like a search") is guessable by whoever is choosing the request.
The ratchet turned over.KNOWN_UNGATED is deleted. Guard 3b now asserts a different invariant — a method-choosing scope:"read" tool is acceptable provided the module behind it asks for consent itself — and it still ratchets both ways: the offender set is compared exactly (a second such tool fails), and the named module must still call hasConsent (deleting the gate fails).
Not verified by CI: GitHub Actions is in a declared major outage and no run was created for this push. tsc, the full 4245-test suite, biome, docs:drift and the file-size ratchet pass locally.
Activity
test(security): make the invariants fail the build when the next call…
fix(security): gate the mutating http call, fence the remote text it …
serge-ivo commentedon Aug 6, 2026
Fixed in 54b2228.
The gate is per CALL, inside
executeHttpRequest— decided by the method actually resolved for this request rather than by the tool's declaration, so reads stay free and mutations honour the switch. It sits in the shared executor rather than in thehttp_requesthandler because that is the one place chat, a pipeline step,POST …/tools/:name, MCP and everycompileConnectormanifest tool all pass through; a manifest tool that already declaresscope:"write"is simply checked twice against the same key. It runs before the vault key is read and before anything goes on the wire, so a refusal cannot have already spent the credential — asserted by a test that countstoken()reads.Your premise that the seeded pipelines are all GET is not quite right, and a naive method gate would have broken them. Google's Places (New) API has no GET search:
searchTextandsearchNearbyare POST-only, solib/steps.ts'sgeocodestep and the lead-finder's grid sweep (lead-finder.json) are READS that HTTP cannot express as reads. Gating them would demand blankethttpwrite consent for a pipeline that only looks things up — which trains an owner to grant exactly the permission this gate exists to withhold. So those two endpoints are a pinned exemption (READ_SHAPED_POST), matched on origin+path and anchored, with tests for query-string and lookalike-host smuggling. Exact endpoints only, never hosts, so the same vendor's mutating routes stay gated. Heuristics were rejected deliberately: every one of them ("has a responseMap", "has no body", "looks like a search") is guessable by whoever is choosing the request.The ratchet turned over.
KNOWN_UNGATEDis deleted. Guard 3b now asserts a different invariant — a method-choosingscope:"read"tool is acceptable provided the module behind it asks for consent itself — and it still ratchets both ways: the offender set is compared exactly (a second such tool fails), and the named module must still callhasConsent(deleting the gate fails).Not verified by CI: GitHub Actions is in a declared major outage and no run was created for this push. tsc, the full 4245-test suite, biome, docs:drift and the file-size ratchet pass locally.
fix(api,console): the tool listing reports the write-consent gate it …
fix(console): the connector a consent chip names has a switch to gran…
fix(agent): the tool list states the consent an agent HAS, not the ru…
fix(connectors): a write tool's description no longer contradicts its…
feat(http): execution policy for credential profiles — host allowlist…