Skip to content

http_request is scope:"read" but can POST/PUT/DELETE with the owner's key — it never reaches the write-consent gate #307

Closed

Description

@serge-ivo

Found while building the guard tests in #306.

http_request (lib/connectors/http.ts) is declared scope: "read":

{
  name: "http_request",
  tier: "connector",
  connector: "http",
  scope: "read",
  ...
  properties: { method: { type: "string", description: "HTTP method (GET, POST, …). Default GET." }, body: {...}, auth: {...} }
}

runRegistryTool gates the per-instance write consent (#90) on exactly that field:

if (tool.scope === "write") {
  const authority = consentInstanceOf(...);
  if (!tool.connector || !(await hasConsent(ctx.env, authority || undefined, tool.connector, "write"))) return refused;
}

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.

Activity

  1. added
    bugSomething isn't working
    backendBackend / Worker / API work
    securitySecurity hardening / audit finding
    on Aug 6, 2026
  2. serge-ivo commented on Aug 6, 2026

    @serge-ivo
    ContributorAuthor

    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 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backendBackend / Worker / API workbugSomething isn't workingsecuritySecurity hardening / audit finding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions