feat(think): add opt-in, read-only HTTP fetch tool#1821
Merged
Conversation
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 detectedLatest commit: 937319d The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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 |
agents
@cloudflare/ai-chat
@cloudflare/codemode
create-think
hono-agents
@cloudflare/shell
@cloudflare/think
@cloudflare/voice
@cloudflare/worker-bundler
commit: |
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>
Merged
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 -->
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds an opt-in, read-only HTTP fetch capability for Think agents via a new
@cloudflare/think/tools/fetchexport and afetchToolsproperty onThink. 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:fetch_urltool (when a publicallowlistis provided), andfetch_<name>tool per named service-binding /Fetchertarget.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)
*.internaland*.localhost, and IP literals in decimal/octal/hex/shorthand forms. URL normalization + rejection of embedded credentials.https://example.com) auto-expand to cover all subpaths.maxBytes), model context (maxModelChars), and aresponse: "workspace"spill so large bodies land in the workspace instead of the model context.Acceptheader so content-negotiating endpoints return clean markdown/text/JSON instead of HTML.tool:fetchevent for an egress audit trail.Hardening
RegExponce at config time (no per-request recompilation).unsupported_content_type.onEventcallback is wrapped so a throwing observability hook can't break tool execution.Example
Integrated into the
assistantexample (kitchen-sink) with workspace spill enabled and a persona hint guiding when to preferfetch_urlover 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:buildpnpm 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
Acceptbehavior, empty/204 handling, and Think integration (tool registration gated onfetchTools, 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/think-fetch-tool.md(@cloudflare/thinkminor).Made with Cursor