Skip to content

WebFetch deny is unconditional: claude.ai artifact URLs are unreachable via the suggested ctx_* replacements (breaks Artifact updates) #1006

Description

@nwilliams-lucas

Summary

The PreToolUse WebFetch hook denies unconditionally. For URLs whose retrieval depends on credentials held by the host agent rather than the sandbox — concretely https://claude.ai/code/artifact/<uuid> — the suggested replacement cannot succeed, so the redirect does not relocate the work, it removes the capability.

In Claude Code this breaks a first-party workflow outright: updating an existing Artifact requires reading its current version first, and that read path is WebFetch.

Version: 1.0.169 (also present in 1.0.162). Platform: Claude Code.

Why the redirect can't work for this host

ctx_fetch_and_index and ctx_execute fetch from the sandbox with no access to the host's authenticated claude.ai session. claude.ai/code/artifact/* is a private, client-rendered page behind that session, so the sandbox receives only the SPA shell.

Measured, same URL, same moment:

ctx_fetch_and_index(url: "https://claude.ai/code/artifact/<uuid>")
→ Fetched and indexed 1 sections (0.1KB)
→ content: "Claude Artifact\n\n[](/code/artifacts) Content is user-generated and unverified."

0.1 KB of shell. The actual page is ~30 KB of HTML. ctx_search over that index returns nothing useful because nothing useful was indexed.

WebFetch retrieves the same URL correctly — Claude Code's own tool documentation calls this out explicitly:

Fails on authenticated/private URLs — use an authenticated MCP tool or gh for those instead. Exception: claude.ai/code/artifact/{uuid} URLs ARE fetchable via your claude.ai login — use WebFetch, not curl (curl gets the SPA shell or a Cloudflare 403).

So the host tool has a documented capability here that the sandbox tools structurally do not, and the hook redirects from the one that works to the ones that can't.

Impact

Artifact updates are read-before-write. The publish call refuses with:

This session hasn't viewed the latest version of the artifact. Read it first (WebFetch the URL), reapply your edits, then publish. force:true discards the other session's changes — use it only if the user asks to overwrite.

With WebFetch denied, that precondition is unsatisfiable. The remaining options are both bad:

  1. Mint a second artifact — the user asked for one page at a stable link and now has two, one of them stale.
  2. force: true — overwrite a page you were never able to read, which is exactly what that flag warns against.

I hit this on a real task. The user's instruction was "update the status artifact"; I ended up creating a duplicate specifically because I could not satisfy the read, then had to go back and force-overwrite after confirming with them. The failure mode is quiet, too — ctx_fetch_and_index reports success ("Fetched and indexed 1 sections"), so an agent that doesn't check the byte count will confidently summarize an empty shell as the page's contents.

Reproduce

  1. Claude Code, context-mode 1.0.169 enabled.
  2. Publish any artifact, note its URL.
  3. In a new session (so the read-tracking is not already satisfied), attempt Artifact with url: pointing at it → refused, "hasn't viewed the latest version".
  4. WebFetch that URL → denied by the context-mode hook.
  5. ctx_fetch_and_index that URL → "1 sections (0.1KB)", SPA shell only.
  6. No path to a successful update remains except force.

Cause

hooks/core/routing.mjs, WebFetch branch (~line 874 in 1.0.169):

// ─── WebFetch: deny + redirect to sandbox ───
if (canonical === "WebFetch") {
  const url = getWebFetchUrl(toolInput);
  return mcpRedirect({
    action: "deny",
    reason: `context-mode: WebFetch redirected. Call ${t("ctx_fetch_and_index")}(...)`,
    ...
  });
}

url is already in scope and used only for the message — nothing consults it before denying. There is no host/URL allowlist and no env or config escape hatch (I checked configs/, and the only process.env reads in routing.mjs are the security-bundle path and warning suppressors).

Suggested fix

Minimal, and it uses the value that's already bound:

if (canonical === "WebFetch") {
  const url = getWebFetchUrl(toolInput);
  // Retrieval depends on the host agent's session; sandbox fetches get a login
  // shell, so redirecting here removes the capability rather than relocating it.
  if (/^https:\/\/claude\.ai\/code\/artifact\//i.test(String(url ?? ""))) return null;
  return mcpRedirect({ /* … unchanged … */ });
}

null is already this function's no-decision path, so the call proceeds normally.

Better still, make it configurable rather than hardcoding one host — a user-supplied list of URL patterns exempt from WebFetch routing. The general rule worth encoding: if the sandbox cannot obtain the same bytes the host tool can, don't redirect. Other credentialed-host URLs will hit this too.

Worth considering separately: ctx_fetch_and_index reporting success on what is obviously a login/shell response is its own hazard. A warning when an indexed page is implausibly small, or when the content matches a known shell pattern, would stop agents from treating an empty fetch as a real one.

Workaround, for anyone else hitting this

A SessionStart hook that re-applies the patch above each session, so it survives ctx upgrade. One caveat that cost me a debugging cycle: the executing copy is version-pinned at ~/.claude/plugins/cache/context-mode/context-mode/<version>/hooks/core/routing.mjs. Patching ~/.claude/plugins/marketplaces/context-mode/... changes nothing at runtime and looks like it worked. Discover the files rather than hardcoding paths.

Happy to open a PR for the narrow fix or the config-driven version — say which you'd prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions