Skip to content

feat(think): add opt-in, read-only HTTP fetch tool#1821

Merged
threepointone merged 2 commits into
mainfrom
think-fetch-tool
Jun 27, 2026
Merged

feat(think): add opt-in, read-only HTTP fetch tool#1821
threepointone merged 2 commits into
mainfrom
think-fetch-tool

Conversation

@threepointone

@threepointone threepointone commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in, read-only HTTP fetch capability for Think agents via a new @cloudflare/think/tools/fetch export and a fetchTools property on Think. Disabled by default.

This addresses #1344 — letting agents read allowlisted web pages and APIs directly, without standing up a separate browsing service, while keeping strong Workers-grounded safety guarantees.

createFetchTools() generates:

  • a generic, allowlisted fetch_url tool (when a public allowlist is provided), and
  • one fetch_<name> tool per named service-binding / Fetcher target.

Using per-target named tools (instead of one polymorphic tool) gives the model a clear, unambiguous surface and lets each binding carry its own base URL, allowlist, and fixed headers.

Safety model (Workers-grounded)

  • GET-only. Mutations are out of scope for v1; they belong in dedicated action tools.
  • SSRF defenses. Blocks private / loopback / link-local / CGNAT / ULA hosts, *.internal and *.localhost, and IP literals in decimal/octal/hex/shorthand forms. URL normalization + rejection of embedded credentials.
  • Allowlist-aware redirects. Each hop is re-checked against the allowlist; cross-origin hops strip credentials/headers. Bare-origin patterns (https://example.com) auto-expand to cover all subpaths.
  • Bounded responses. Separate limits for download (maxBytes), model context (maxModelChars), and a response: "workspace" spill so large bodies land in the workspace instead of the model context.
  • Header safety. Model headers go through an allowlist; fixed binding headers take precedence over model-provided ones.
  • Markdown-first. Configurable (and disablable) default Accept header so content-negotiating endpoints return clean markdown/text/JSON instead of HTML.
  • Observability. Emits a tool:fetch event for an egress audit trail.

Hardening

  • Allowlist globs are precompiled to RegExp once at config time (no per-request recompilation).
  • Unconsumed response bodies are cancelled on non-2xx and redirect paths to avoid dangling streams.
  • Empty bodies (e.g. 204) are treated as successful empty reads rather than unsupported_content_type.
  • The onEvent callback is wrapped so a throwing observability hook can't break tool execution.

Example

Integrated into the assistant example (kitchen-sink) with workspace spill enabled and a persona hint guiding when to prefer fetch_url over the rendered-page browsing tools.

Test plan

  • pnpm exec vitest run --config src/tests/vitest.config.ts fetch-tools — 49 passing (unit + Think integration)
  • pnpm exec nx run think:build
  • pnpm run check (sherif + export checks + oxfmt + oxlint + typecheck across 113 projects)

Covered: allowlist matching + bare-origin normalization, SSRF rejections across IP formats, redirect policy + cross-origin header stripping, size limits + workspace spill, header precedence, default Accept behavior, empty/204 handling, and Think integration (tool registration gated on fetchTools, capability-prompt advertisement).

Docs

  • packages/think/README.md, docs/think/index.md — usage, safety model, allowlist semantics, fetch vs Browser Run vs RPC guidance, exports/config tables.
  • design/think.md — design rationale; design/test-coverage-matrix.md — new coverage row.
  • Changeset: .changeset/think-fetch-tool.md (@cloudflare/think minor).

Made with Cursor


Open in Devin Review

Adds a new `@cloudflare/think/tools/fetch` export and a `fetchTools`
property on `Think` that lets agents read allowlisted HTTP resources
(docs, APIs) directly. Disabled by default.

`createFetchTools()` generates a generic, allowlisted `fetch_url` tool
plus one `fetch_<name>` tool per named service-binding/`Fetcher` target,
so the model gets a clear per-target surface instead of one polymorphic
tool.

Safety model (Workers-grounded):
- GET-only; mutations are out of scope for v1.
- SSRF defenses: blocks private/loopback/link-local/CGNAT/ULA hosts,
  `*.internal` and `*.localhost`, and IP literals in decimal/octal/hex/
  shorthand forms; URL normalization; rejects embedded credentials.
- Allowlist-aware redirect policy with cross-origin header stripping;
  bare-origin patterns auto-expand to cover all subpaths.
- Separate size limits for download (`maxBytes`), model context
  (`maxModelChars`), and a `response: "workspace"` spill for large
  bodies.
- Model header allowlist; fixed binding headers take precedence over
  model-provided ones.
- Markdown-first default `Accept` (configurable / disablable) so
  content-negotiating endpoints return clean text instead of HTML.
- `tool:fetch` observability event for an egress audit trail.

Hardening: allowlist globs are precompiled to RegExp at config time;
unconsumed response bodies are cancelled on non-2xx and redirect paths.

Integrated into the `assistant` example (kitchen-sink) with workspace
spill enabled. Includes Worker-runtime unit + Think integration tests
(`fetch-tools.test.ts` + `agents/fetch-tools.ts` fixture), README/docs/
design updates, test-coverage-matrix row, and a changeset.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Jun 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 937319d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cloudflare/think Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 27, 2026

Copy link
Copy Markdown

Open in StackBlitz

agents

npm i https://pkg.pr.new/agents@1821

@cloudflare/ai-chat

npm i https://pkg.pr.new/@cloudflare/ai-chat@1821

@cloudflare/codemode

npm i https://pkg.pr.new/@cloudflare/codemode@1821

create-think

npm i https://pkg.pr.new/create-think@1821

hono-agents

npm i https://pkg.pr.new/hono-agents@1821

@cloudflare/shell

npm i https://pkg.pr.new/@cloudflare/shell@1821

@cloudflare/think

npm i https://pkg.pr.new/@cloudflare/think@1821

@cloudflare/voice

npm i https://pkg.pr.new/@cloudflare/voice@1821

@cloudflare/worker-bundler

npm i https://pkg.pr.new/@cloudflare/worker-bundler@1821

commit: 937319d

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread packages/think/src/tools/fetch.ts
Comment thread packages/think/src/tools/fetch.ts
Comment thread packages/think/src/tools/fetch.ts
The WHATWG URL parser serializes `[::ffff:127.0.0.1]` to hex form
`[::ffff:7f00:1]`, which the dotted-decimal-only regex never matched,
so IPv4-mapped IPv6 addresses bypassed our own private-network check
(the Workers egress layer still blocked them, but the defense-in-depth
layer had a gap). Decode the trailing two hextets back into dotted IPv4
and reuse the v4 rules. Adds test coverage for both mapped forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
@threepointone
threepointone merged commit de6a695 into main Jun 27, 2026
4 checks passed
@threepointone
threepointone deleted the think-fetch-tool branch June 27, 2026 12:05
@github-actions github-actions Bot mentioned this pull request Jun 27, 2026
jonastemplestein added a commit to iterate/iterate that referenced this pull request Jul 13, 2026
## Summary

- keep secret streams fully public: any caller may append any event, and
every `secret/updated` event without material clears retained material
- bind each new ciphertext to its project, normalized secret path,
canonical complete origin policy, and committed event offset with
AES-GCM AAD
- require replacement material and its complete egress policy in the
same `secret.update`, using compare-and-append plus re-encryption on
offset contention
- fence refreshes on both sides of provider I/O so a stale request
cannot mint or resurrect material after any intervening update
- constrain platform-backed refresh credentials to their provider
endpoints and result origins, including GitHub App identity and
installation ownership
- replace credential-bearing bare fetches with bounded manual redirects
that revalidate every hop and reject cross-origin redirects
- reconstruct terminal responses so URL-path secrets cannot escape
through `Response.url`, navigation headers, or runtime error metadata
- intentionally invalidate legacy secret ciphertext/checkpoints;
production secret material will be wiped rather than migrated

## Security properties

Secret event types are not privileged. Public events may change
metadata, egress, or refresh configuration and may destroy availability,
but they cannot create usable material. The reducer owns the committed
offset stored beside ciphertext; copied or replayed ciphertext fails
authentication in a different event, project, path, or effective-origin
context.

A material write must explicitly name its complete egress policy. It
predicts the next event offset, encrypts for that exact context, and
compare-appends. Losing a race discards the envelope and re-encrypts
against a fresh offset. A refresh additionally records the reducer-owned
update offset observed by the request, revalidates it before provider
I/O, and compare-appends the result afterward.

Credential requests follow at most five redirects. Each hop is manual
and revalidated; cross-origin redirects are rejected before another
credential-bearing request is sent. Terminal responses are rebuilt with
empty fetch provenance and without URL-bearing navigation headers. Fetch
and malformed-redirect errors become stable sanitized responses.

## Research

- Cloudflare Request documentation:
https://developers.cloudflare.com/workers/runtime-apis/request/
- Cloudflare Response documentation:
https://developers.cloudflare.com/workers/runtime-apis/response/
- Cloudflare Workers RPC documentation:
https://developers.cloudflare.com/workers/runtime-apis/rpc/
- Cloudflare Think implementation:
https://github.com/cloudflare/agents/blob/762998da1c873701305a44c598e9c029617047b4/packages/think/src/tools/fetch.ts#L418-L606
- Think redirect hardening PR:
cloudflare/agents#1821

Think uses the same core shape—manual bounded redirects, per-hop
validation, request reconstruction, and credential/header removal across
origins. This patch uses the smaller, stricter policy appropriate for
secret material: credential-bearing cross-origin redirects are rejected.

## Verification

- `pnpm format`
- `pnpm typecheck`
- `pnpm lint` (zero warnings)
- `pnpm test` (OS: 1,189 passed; 1 skipped; all other workspace suites
passed)
- focused crypto/reducer/platform/config tests
- 8 live-worker e2e tests covering public event replay, explicit
material policy, both refresh race windows, cross-origin redirects,
URL-path response concealment, and project egress
substitution/interception

<!-- CLOUDFLARE_PREVIEW -->
## Environment Config Lease

<!-- CLOUDFLARE_PREVIEW_STATE -->
<!--
{
  "apps": {
    "os": {
      "appDisplayName": "OS",
      "appSlug": "os",
      "status": "deployed",
      "updatedAt": "2026-07-13T11:40:41.320Z",
      "headSha": "b78f30f2e60d8ad992f2950c440c6d7b26944d80",
      "message": null,
      "publicUrl": "https://os.iterate-preview-6.com",
"runUrl":
"https://github.com/iterate/iterate/actions/runs/407068669292171",
      "shortSha": "b78f30f",
      "deployDurationMs": 80088,
      "testDurationMs": 175652,
"testRetries": "3 retried: DESIRED: a live-capability fetch handler
serves WebSockets at an app host (vitest x1) · delivery expressions must
end in a property step, rejected before commit (vitest x1) · runs
\"scheduler-agent-checkin\" through the project REPL (specs x1)",
      "workerSizeKib": 16082.86,
      "workerGzipKib": 4338.82,
      "mainWorkerGzipKib": 4335.47
    },
    "auth": {
      "appDisplayName": "Auth",
      "appSlug": "auth",
      "status": "deployed",
      "updatedAt": "2026-07-13T11:37:45.885Z",
      "headSha": "b78f30f2e60d8ad992f2950c440c6d7b26944d80",
      "message": null,
      "publicUrl": "https://auth.iterate-preview-6.com",
"runUrl":
"https://github.com/iterate/iterate/actions/runs/407068669292171",
      "shortSha": "b78f30f",
      "deployDurationMs": 17650,
      "testDurationMs": 214,
      "testRetries": null,
      "workerSizeKib": 4032.01,
      "workerGzipKib": 819.66,
      "mainWorkerGzipKib": null
    },
    "streams-example-app": {
      "appDisplayName": "Streams Example App",
      "appSlug": "streams-example-app",
      "status": "deployed",
      "updatedAt": "2026-07-13T11:38:04.341Z",
      "headSha": "b78f30f2e60d8ad992f2950c440c6d7b26944d80",
      "message": null,
      "publicUrl": "https://streams.iterate-preview-6.com",
"runUrl":
"https://github.com/iterate/iterate/actions/runs/407068669292171",
      "shortSha": "b78f30f",
      "deployDurationMs": 13830,
      "testDurationMs": 18668,
      "testRetries": null,
      "workerSizeKib": 4890.81,
      "workerGzipKib": 1399.72,
      "mainWorkerGzipKib": null
    }
  },
  "environmentConfigLease": {
    "dopplerConfig": "preview_6",
    "leasedUntil": 1784029547191,
    "leaseId": "486ab6b4-f050-4c1c-b04b-bc9fdbacf4eb",
    "slug": "preview-6",
    "type": "environment-config-lease"
  },
  "notice": null
}
-->
<!-- /CLOUDFLARE_PREVIEW_STATE -->

<details>
<summary>Lease: preview-6 | Doppler config: preview_6 | Type:
environment-config-lease | Leased until:
2026-07-14T11:45:47.191Z</summary>

| app | status | commit | preview | size (gzip) | deploy duration | test
duration | retries | cleanup duration | workflow run | updated | summary
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | ---
|
| Auth | deployed | `b78f30f` |
[https://auth.iterate-preview-6.com](https://auth.iterate-preview-6.com)
| 819.7 KiB | 17.6s | 214ms | | | [Workflow
run](https://github.com/iterate/iterate/actions/runs/407068669292171) |
2026-07-13T11:37:45.885Z | |
| OS | deployed | `b78f30f` |
[https://os.iterate-preview-6.com](https://os.iterate-preview-6.com) |
4.24 MiB (+3.3 KiB vs main) | 80.1s | 175.7s | 3 retried: DESIRED: a
live-capability fetch handler serves WebSockets at an app host (vitest
x1) · delivery expressions must end in a property step, rejected before
commit (vitest x1) · runs "scheduler-agent-checkin" through the project
REPL (specs x1) | | [Workflow
run](https://github.com/iterate/iterate/actions/runs/407068669292171) |
2026-07-13T11:40:41.320Z | |
| Streams Example App | deployed | `b78f30f` |
[https://streams.iterate-preview-6.com](https://streams.iterate-preview-6.com)
| 1.37 MiB | 13.8s | 18.7s | | | [Workflow
run](https://github.com/iterate/iterate/actions/runs/407068669292171) |
2026-07-13T11:38:04.341Z | |

</details>
<!-- /CLOUDFLARE_PREVIEW -->

<!-- loc-report -->
| Group | Lines | Significant |
| --- | ---: | ---: |
| Product | +676 -165 | +557 -144 🟩🟩🟩🟩🟥 |
| Tests | +736 -29 | +670 -29 🟩🟩🟩🟩🟩 |
| Docs | +52 -2 | +49 -2 🟩⬜⬜⬜⬜ |
| Generated | +55 -27 | +25 -13 🟩⬜⬜⬜⬜ |
| **Total** | **+1,519 -223** | **+1,301 -188** 🟩🟩🟩🟩🟥 |

<sub>Lines counts every changed line; Significant ignores blank lines
and JS comments. Between 71e99e5 and b78f30f, bucketed first-match-wins
into the groups defined in `scripts/ci/loc-report.ts`.</sub>
<!-- /loc-report -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant