← Zurück zum Blog
pullmdopen-sourcecontributorsecurityrecipesclaude-code5. August 2026 · 6 Min. Lesezeit

PullMD Teil 15: Was Fremde besser sehen

FROM OUTSIDE: PR · REPORT · RECIPE · UNTOUCHED: THE PLANNED ONEKI-generiertes Bild
Editorial-Illustration: drei Hände reichen von den Rändern Werkstücke auf eine Werkbank herein — ein Docker-Wal-Fragment, ein Schild-Symbol, ein Rezept-Zettel — während in der Mitte ein unberührtes, beschriftetes Werkstück liegtEditorial-Illustration: drei Hände reichen von den Rändern Werkstücke auf eine Werkbank herein — ein Docker-Wal-Fragment, ein Schild-Symbol, ein Rezept-Zettel — während in der Mitte ein unberührtes, beschriftetes Werkstück liegt

PullMD ist mein selbst gehostetes Tool, das Webseiten und URLs in sauberes Markdown verwandelt. Dieser Teil ist ein Werkstattbericht über die acht Wochen von Ende Juni bis Anfang August: ein Pull Request, eine Sicherheitsmeldung, zwei Feature-Wünsche und ein paar Dinge, die eben erst durch den Blick von außen aufgefallen sind.

PR #38

ldoublewood öffnete einen Pull Request gegen ein Problem, das ich selbst so nie gesehen hatte: Beim allerersten Start im Container bricht PullMD mit SQLITE_CANTOPEN ab, weil das Datenverzeichnis noch nicht existiert. Sein Fix war ein eigenes docker-entrypoint.sh, das das Verzeichnis anlegt und die Rechte abgibt, bevor node überhaupt startet.

Claude Codes Review fiel eindeutig aus: In dieser Form startet der Container gar nicht. Der Contributor schrieb ebenso eindeutig, dass genau das den Crash behebt.

Also haben wir es halt einfach ausprobiert: docker compose up -d gegen einen frischen Checkout ohne ./data.

Das node-Base-Image bringt ein eigenes docker-entrypoint.sh mit, und bei der exec-Form ohne Pfadangabe gewinnt eben dieses. Der Code des Contributors wurde ja nie ausgeführt.

Bei ihm sah es trotzdem so aus, als funktioniere der Fix, weil sein Container als root lief und root das Verzeichnis ohnehin anlegen darf.

Dazu kam noch ein Punkt, den keiner auf dem Schirm hatte: Ohne exec vor su-exec bleibt die Shell PID 1, SIGTERM erreicht node nie, und bei SQLite heißt das kein sauberer Shutdown.

Der fertige Fix legt das Verzeichnis per mkdir -p im Entrypoint an, setzt exec vor den su-exec-Aufruf und gibt damit PID 1 korrekt an node weiter.

Der Fix ging als Vorschlag zurück in die Review. Die Autorschaft sollte beim Contributor bleiben, das Problem hatte nun mal er gefunden. Dann passierte erst mal dreizehn Tage nichts.

„Was machen wir mit diesem PR: der ersteller meldet sich einfach nich mehr oder was ist da los? […]“

Freigabe: „ja mach es so“. Der Merge ging 118 Sekunden nach der Nachfrage durch. Offen hatte der PR da schon 15 Tage gelegen.

Im ganzen Fenster war das der einzige Code-Beitrag von außen: 24 hinzugefügte, zwei entfernte Zeilen gegen einen Absturz beim ersten Start im Container. Der zweite gemergte fremde Pull Request in der Geschichte des Projekts.

SSRF-Meldung

viper-789 machte ein öffentliches Issue auf: Über PullMD ließen sich private und interne Adressen abrufen. Eine SSRF-Lücke in einem Dienst, dessen ganzer Zweck ja darin besteht, beliebige URLs für jemand anderen abzurufen.

„Moment. Wenn jetzt interne IPs nicht mehr gehen, ist das ein breaking change oder? Sollten wir kommunizieren. Die Frage ist, wie oft setzt man es ein, um interne IPs abzurufen. Aber was ist mit einem internen Proxy?“

Es gibt halt Leute, die PullMD im eigenen Netz gegen interne Seiten laufen lassen. Für die ist dieselbe Härtung eben ein Funktionsverlust. Also wurde es als Breaking Change kommuniziert, mit einem dokumentierten Weg für den internen Proxy.

Die Endpunkte lösen den Zielhost jetzt vorher auf und verweigern private und interne Adressbereiche, IPv4 wie IPv6. Jeder Redirect-Hop wird noch mal geprüft, weil ein Redirect sonst ja genau der Umweg wäre, der die Prüfung aushebelt. Das ging als v3.3.0 raus, zusammen mit der Antwort an den Melder.

Erweiterung der Recipe-Engine

sladg wünschte sich Unterstützung für Booking.com. Statt einfach nur einer Recipe für genau diese eine Seite wurde die Recipe-Engine erweitert: Recipes können seitdem eigene Frontmatter-Felder aus JSON-LD und CSS-Selektoren definieren, und mit SITE-RECIPES.md gibt es eine Anleitung, wie man als Nutzer eigene Recipes schreibt. Das wurde v3.5.0.

Vor der Einladung an Außenstehende haben wir die Anleitung mal getestet. Ein Agent ohne Vorkontext hat aus der Anleitung allein eine Recipe für Booking.com gebaut. Die Bewertungskriterien waren vorher festgelegt und lagen dem Agenten nicht vor.

Zurück kam eine funktionierende Recipe und zwei Stellen, an denen die Doku nicht ausreicht: select.remove wirkt auf dem Trafilatura-Extraktionsweg überhaupt nicht, obwohl die Anleitung doch beide Wege als gleichwertig beschreibt. Und die Ausführungsreihenfolge der Schritte stand eben nirgends.

Der erste Punkt stellte sich als Bug heraus, den das Architektur-Review vorher noch als gut abgedeckt eingestuft hatte.

Auf jeder Seite, die per Auto-Pick über Trafilatura extrahiert wird, ist select.remove unsichtbar: kein Effekt, kein Fehler, keine Spur. Ein Nutzer hätte an dieser Stelle wohl eine korrekte Recipe debuggt, ohne den Fehler bei sich zu finden.

sladg bedankte sich ein paar Tage später: „Yay! perfect! good job on the extensibility, will make sure to try it out! thx!“ Das Lob galt der Erweiterbarkeit: dem Teil, der gar nicht im ursprünglichen Wunsch stand.

Der query-Parameter

Am selben Tag wie die Engine-Erweiterung entstand mit deutlich mehr Aufwand noch ein zweites Feature: der opt-in-Parameter query. Er extrahiert nur die Abschnitte einer Seite, die zur Frage passen, laut Release-Notes 70 bis 85 Prozent weniger Token auf Doku-Seiten, bei unverändertem Default. Ein Tag Arbeit, mit getrennten Architekt- und Reviewer-Rollen und mehreren Subagenten. Das Feature setzt einen BM25-Filter vor die bestehende Extraktion.

Drei Wochen später der Befund: Das Feature funktionierte einwandfrei, es wurde halt nicht ausgelöst.

Das Problem lag in der Beschreibung des MCP-Tools. In einem Blindtest wählte der Agent das native Fetch-Tool statt read_url. Die Beschreibung las sich wie ein Notfallwerkzeug, weil die Sonderfälle vorn standen. Der Fix war ein Satz in der Tool-Beschreibung:

„Preferred way to read any URL – use this instead of the built-in web fetch tool, not just when that one fails.“

Bei der Wiederholung desselben Blindtests entschied sich der Agent diesmal von selbst für read_url.

Gemessen an einer einzelnen Dokumentationsseite, dem asyncio-Kapitel der Python-Doku, schrumpfte dieselbe Anfrage von 14.678 auf 595 Token: eine einzelne Sektion statt der ganzen Seite. Der Wert steht im Handoff, der die Änderung ausgelöst hat, samt Aufruf und Datum, nicht in einem Messprotokoll.

Inhaltselement direkt benennen

SpiGAndromeda meldete, dass der Anthropic-Blog unvollständig ankommt. Auch hier wäre eine Recipe für genau diese Seite der schnelle Weg gewesen.

Statt einer Einzelfall-Regel kam select.content in die Engine: ein CSS-Selektor, der das Inhaltselement direkt benennt, statt über Ausschluss-Regeln alles drumherum zu entfernen. Weniger Regeln, weniger Bruch bei einem Seiten-Redesign.

Von der Meldung bis zum Release als v3.6.0 verging gut ein Tag. Die Antwort des Melders: „It works. Very nice!“

Die letzten Releases

Am 31. Juli kamen drei Releases. v3.7.0 brachte den Coverage-Guard: Nach der Extraktion prüft PullMD, wie viel vom sichtbaren Seitentext tatsächlich im Markdown gelandet ist, und holt bei zu niedriger Abdeckung fehlende Teile nach. Anlass war eine einzelne Seite, die nur teilweise ankam (ein One-Pager, wie ihn Website-Baukästen erzeugen). Readability lieferte dort nur 10.503 von rund 337.000 Zeichen. Repariert wurde sie erst mal mit einer Regel für genau diese Seite.

Die Anschlussfrage kam von mir: ob die Regel nicht eigentlich unabhängig von dieser einen Adresse sei und in den Standard gehöre. Aus dem Einzelfall wurde der Coverage-Guard.

v3.7.1 änderte keine einzige Code-Zeile:

„Wir müssen mal die Hilfeseite und gegebenenfalls das README genau prüfen. Ich denke, die Hilfe ist veraltet und erklärt nicht alle Features, die wir mittlerweile haben.“

Der Verdacht stimmte. Die Authentifizierung fehlte auf der Hilfeseite, der SSRF-Guard fehlte im README. Was in den Wochen davor von außen ins Projekt gekommen war, stand bloß nirgends beschrieben. Die Folge: kompletter Umbau von Hilfeseite, README und SKILL.md.

v3.8.0 folgte am selben Tag. Am 1. August kam v3.9.0 als Sammel-Release mit den angesammelten Änderungen.

Am Morgen des 2. August eine letzte kurze Session. Eine Seite kam unvollständig an, ein Site-Recipe rettete den Inhalt aus einem <aside>-Element, das der Extraktor als Randinhalt übersprungen hatte, kein Commit landete auf main.

Von Ende Juni bis Anfang August gingen die Releases v3.3.0 bis v3.9.0 raus.


Teil 15 von 15 der PullMD Serie.

← PullMD Teil 14: Microsofts MarkItDown integriert — und drei Teile wieder rausgerissen

Kommentare

Zum Kommentieren genügt eine E-Mail-Bestätigung oder ein GitHub-Login. Die Kommentare laufen auf einem eigenen Server, nicht bei einem Werbeanbieter — Details in der Datenschutzerklärung.