# Etappe 3a – Entscheidungen (Rulings), Befunde und offene Punkte Aus dem SDD-Ledger übernommen, 2026-09-27. Security-Audit: 2026-09-27-etappe-3a-security-audit.md. ## Offen für spätere Etappen - Etappe 3b: vollständiges Ausblenden von Bibliotheks-IDs bei deaktivierter Gruppe (P13); gleiche Zielregeln für rohe post_link_url_* wie für {{link:}} (P10); Theme Builder, find-similar, make-global. - Etappe 4: Presets können Lottie-Quelle/Interactions tragen – Preset-Schreiben braucht dieselben Builder-Sperren. - Etappe U: try/catch um Divi-Aufrufe (P11), einheitliche Fehlercodes der Tools-Tab-Sperren, doppelte Helfer, Test-Feinschliff, „blurb ist leer“-Warnung, Rollen-Option in divi-write-smoke. - Restrisiko (Low, P25): sehr ungewöhnliche Schreibweisen globaler Verweise auf Seiten, die der Benutzer nicht bearbeiten darf, können der Verwendungssuche entgehen (setzt Bearbeitungsrechte an der verweisenden Seite voraus). ## Pre-flight rulings Pre-flight (opus): 0 Critical, 8 Important, 19 Minor, 4 Info; fixtures match byte for byte; {{link:ID}} to draft/private shows only ?page_id=. Full tables: preflight.md. - Ruling P1 (I-1, → Task 9): lit/divi-replace-text on library entries goes through DiviWriter::commit() (dry run/confirm for global, site-wide cache clear, no Divi flags on library posts) or refuses library targets — implementer picks the variant that reuses commit(); F2 binding — cost if wrong: none - Ruling P2 (I-2, → Task 11): rebuild the 3 test rows with Divi's \u0022 escaping and assert the parsed block is not opaque — cost if wrong: none - Ruling P3 (I-3, → Task 11): StoredNodeGuard refuses, for users without unfiltered_html, non-Divi blocks, raw text parts, opaque blocks and rich-text HTML that is not kses-stable when moving stored nodes between posts — cost if wrong: editors copy less (visible refusal) - Ruling P4 (I-4, → Task 2): only the link row uses URL mode in the new placeholder test — cost if wrong: none - Ruling P5 (I-5, → Task 13): lit-library-insert reads the entry via LibraryAccess (edit_post) — cost if wrong: none - Ruling P6 (I-6, → Task 12): lit/library-save reuses NewDraft (partial_write + recordPartial on failed rollback) — cost if wrong: none - Ruling P7 (I-7, → Task 12): scope "global" additionally requires edit_global_library (Divi's rule, F12) — cost if wrong: none - Ruling P8 (I-8, → Task 9): a confirmed global edit is refused when the user cannot edit_post one of the published pages that render the entry (stricter than Divi; security first) — cost if wrong: editors with partial rights can't change shared modules - Ruling P9 (minors): confirm stays honour-based (D10); a disabled library group hides library IDs from the Divi read tools; lit/divi-node-copy follows lit/divi-insert exposure (E9); refuse the backup path when a user without unfiltered_html edits with revisions off or trash disabled (kses backup loses bytes); output schema gains affected, affected_count, backup_id; unknown dynamic field types are omitted (F4); dispatch notes per preflight (OutlineTest anchor ×3, >120-char lines, dead variable) — cost if wrong: small - Note: common.md fixed — PostChangeResult was renamed to PostSaveFollowUp in 2b W3 ## Progress - Task 1: dispatched (sonnet, BASE cfc81dd) - Task 1: complete (404f0f3, trailer normalized; 2-line config change verified by controller; 1597 tests) - Task 2: dispatched (opus, BASE 404f0f3; P4, link read_post check) - Task 2: implementer done (788943e; 1609 tests); note T16 docs: attachments refused by {{link:}} - Task 2: review (opus): Approved; live: read_post enforced (private/foreign draft/trash refused, uniform message), ID forms refused, URL-only on all paths, no leak - Ruling P10 (T2 minors → final wave): full-form post_link_url_* uses LinkTargets (same target rules as {{link:}}; trashed/unattached attachment consistent) + one shared ID parser; attachments refused by {{link:}} documented in T16 — cost if wrong: none - Task 2: complete (404f0f3..788943e) - Tasks 3–5: dispatched as one batch (sonnet, BASE 788943e, one commit per task) - Tasks 3–5: review (sonnet): all Approved (858aa6a, a3a8070, b389f98 after trailer normalization; 1617 tests); global id in outline/node-get = library post ID only (accepted, like GlobalUsages); minors → final wave: preset-cap note only when presets requested; Outline duplicates Block::shortName prefix strip - Tasks 3, 4, 5: complete (788943e..b389f98) - Tasks 6–7: dispatched (opus, BASE b389f98, one commit per task) - Tasks 6–7: implementer done (ce1ad7c, 2700fd6; 1646 tests); T7 deviations: unifyLineBreaks skips opaque blocks; deglobalize keeps invalid blockName instance opaque - Tasks 6–7: review (opus): both Approved; byte fidelity independently verified incl. deglobalize == Divi's own PortabilityPost output; deviations accepted - Ruling P11 (T6/T7 minors): → T12/T13 dispatch: refuse opaque/global roots before GlobalInstance::of (no nested instances); → final wave: try/catch Throwable around Divi calls in DiviLibraryStore/DiviRoles (E15 never fatal), stdClass localAttrs, self-closing of() for created childless roots, replaceChild attached-check, isSpecialtyColumn by effectiveName - Tasks 6, 7: complete (b389f98..2700fd6) - Task 8: dispatched (opus, BASE 2700fd6; P9 library-group hiding hook) - Task 8: implementer done (48ecc62; 1660 tests); P9 hook LibraryAccess $visible + ExposurePolicy::groupEnabled, wired as libraryVisible (GROUP 'divi-library', live with T10); T9 must merge: $libraryWritable param + LibraryAccess::GROUP already exist - Task 8: review (opus): Approved; live with temp editor/author/contributor/subscriber + role-editor right withdrawn; P9 hook verified with real ExposurePolicy - Ruling P12 (T8 I-1): users without the Divi library right at all (canList false) get PostGuard::notFound (no existence/type oracle, like P9); missing edit_post on an entry stays `forbidden` (project convention for pages) — fix round 1 now — cost if wrong: less helpful hint for users lacking the role right - Ruling P13 (T8 M-1/M-2 → final wave / T10): with the library group hidden, page outline/node-get omit global_id and the render-summary hint; T10 keeps groupEnabled semantics (group mode only) - Task 8: fix round 1 (P12; 681bb75; 1661 tests; live: logged-out 1058 vs 99999 byte-identical not_found) — verified by controller against report + live claim - Task 8: complete (2700fd6..681bb75). Note for T10+: canList()/canRead() still return forbidden when called directly (fine for list tool — no ID oracle) - Task 9: dispatched (opus, BASE 681bb75; F2, P1, P8, P9 backup refusal + schema) - Task 9: implementer done (d66ad36; 1684 tests; divi-write smoke ok; live dry run on 1059 lists 1053, bytes unchanged); concerns: backup id not named on failure after backup; P8 published only - Task 9: review (opus): live verified dry run/confirm/revision/reference semantics/P8 refusal/no flags; changes requested: I-1 P8 gap (private page: editor without rights changed what it shows), I-2 backup_id lost on failed save after backup - Ruling P8a (amends P8): confirmed global edit requires edit_post on every affected post with status publish, private or future (incl. TB layouts/other library entries found by the query); drafts/pending listed in dry run but not blocking — private/future render or will render — cost if wrong: editors blocked more often - Ruling P14: WriteFailed after a backup carries backup_id + hint (WriteFailed::withDetails); dry run warns with blocked_ids when P8 would refuse (M-1); plural fix (M-2); shared status-parameterised query (M-3); _prime_post_caches before P8 loop (M-4); M-5/M-6 → final wave/T16 - Task 9: fix round 1 dispatched - Task 9: fix round 1 (P8a, P14, M-1..M-4; 520a639; 1691 tests); re-review running - Task 9: re-review clean (live: private/future block, draft not, blocked_ids in dry run, backup_id on failed save) - Task 9: complete (681bb75..520a639) - Task 10: dispatched (opus, BASE 520a639; P12, P13 if cheap, per-entry filtering) - Task 10: implementer done (b7c7149; 1702 tests); P12 via item() for id; P13 → final wave - Task 10: review (sonnet): Approved; live: group disabled → list unregistered + read tools not_found; nur lesen → list ok, writes forbidden; P12 id byte-identical; minor: MAX_SCAN cap silent → final wave - Task 10: complete (520a639..b7c7149) - Task 11: dispatched (opus, BASE b7c7149; P2, P3, stored $variable refs via VariableRef) - Task 11: implementer done (f8275ad; 1741 tests; 30 malicious rows); concerns: readable global instances checked (localAttrs) not refused (per brief/preflight I-3); builder-written $variable refs often refused for editors (stricter than Divi); plain text kses-checked only when containing '<'; ValueCleaner::kindAt added - Ruling P15: accept checking readable global instances (a reference renders existing library content; Divi lets library-right users insert them too); stricter $variable handling accepted (visible refusal, documented in T16) — cost if wrong: editors can't copy some builder nodes - Task 11: review (opus, SEC): Approved (guard holds for P3/P15; Divi renders instances from published entries only, localAttrs+snapshot both checked). NEW Critical (pre-existing since 2b): module.decoration.interactions writable by editors → effect addAttribute onclick="…" → Divi's interactions script setAttribute → script on click; path is known in catalog so lit/divi-update can write it - Ruling P16: add decoration.interactions to ValueCleaner::BUILDER_ONLY (S7 gate: nur im Divi-Builder für Benutzer ohne unfiltered_html) — closes main pipeline and StoredNodeGuard; separate commit with tests (full form, short form if any, stored guard, removal via null); also scan the catalog for other paths whose values Divi turns into attributes/HTML/script (e.g. other effect/attribute/script-like option groups) and gate them the same way, listing what was found — cost if wrong: editors can't set interactions via MCP - Ruling P17 (T11 minors): guard URL rule `url($v) === trim($v)`; guard enforces VALUE_KEY key names and digits-only globalModule; STATE_KEYS reuses AttrWriter::STATES; >120-char test lines — in the same fix round - Task 11: fix round 1 (P16 9d92808 interactions builder-only + scan: nothing else to gate; P17 e808832; 1748 tests; smoke ok) - Ruling P18 (P17 too strict for relative links): StoredNodeGuard URL accept rule = url($v) === trim($v), OR the value has no scheme (no ':' before the first '/', '?', '#') and contains no whitespace, quotes, '<', '>' or control chars — relative links (kontakt/, /pfad, #anker, ?q=1) stay copyable, every scheme still must survive url() unchanged — cost if wrong: none (scheme-less values can't carry javascript:) - Task 11: fix round 2 (P18; 16c6d59; 1750 tests); backslash hosts also refused, //host must survive url() - Task 11: re-review: P16/P17/P18 verified live (interactions refused for editors incl. null removal + guard; 63 URL paths × entity-colon payloads safe; relative rule holds); NEW Important: divi/lottie lottie.innerContent.src → remote JSON loaded by lottie-web with expressions (eval) → script; editors could set it - Ruling P19: divi/lottie lottie.innerContent (src and any animation-source keys) builder-only for users without unfiltered_html (same S7 gate as P16), in main pipeline and StoredNodeGuard; tests set/null/copy; guides list it — cost if wrong: editors can't set Lottie via MCP - Task 11: fix round 3 (P19; 8fa285a; 1753 tests; smoke ok) verified by controller (pattern + tests) - Note Etappe 4: Divi global presets can carry a Lottie src / interactions — preset writes (design system) must apply the same builder-only gates - Task 11: complete (b7c7149..8fa285a) - Task 12: dispatched (opus, BASE 8fa285a; P6, P7, P11, guard) - Task 12: implementer done (5035092; 1783 tests; live: bytes equal Divi entry 1055, page unchanged, no flags); nested globals kept as references in normal entries (plan-conform, accepted) - Task 12: review (opus): Approved; live: rights (edit_post source, publish_posts, divi_library/save_library, edit_global_library), guard refuses all builder-only kinds as editor, P11, P6 rollback + partial_write, terms/meta identical to Divi, exposure; bytes equal after CRLF→LF; minors → final wave: REASON_CREATE placement, test line length, partial_write hint for new entries - Task 12: complete (8fa285a..5035092) - Task 13: dispatched (opus, BASE 5035092; P5, P11, guard in copy mode, direct budget 22) - Task 13: implementer done (7fe91c2; 1799 tests; smoke ok; live: 1059/1058 instance and 1055 copy byte-identical to fixture, probes cleaned) - Task 13: review (opus): Approved; live: P5 (author vs admin/private/fixture forbidden, missing not_found), P11 incl. instance into global entry refused by shapeViolation, copy-mode guard refusals as editor, exposure 22 direct; minors → final wave (test line length, multi-root unit test, "zu bearbeiten" wording) - Task 13: complete (5035092..7fe91c2) - NEW (pre-existing, Important): editor saves via any Divi write tool rewrite untouched
→
(some content_save_pre filter still active despite kses removal); no CHANGED_ON_SAVE; tree_rev may mismatch stored content → investigation dispatched (opus) in parallel with Task 14 - Task 14: dispatched (opus, BASE 7fe91c2); parallel: editor-rewrite investigation (read-only on code) - Task 14: implementer done (6f6c691; 1814 tests; smoke ok); source_tree_rev required check; not live-probed - Investigation editor rewrite: NOT reproducible (45 inputs, editor+admin, plain wp_update_post in kses window and end-to-end lit/divi-insert: byte-identical, tree_rev == Rev::of(stored)); likely tool-chain \u decoding in the reviewer's probe (known artifact); CHANGED_ON_SAVE detection works (wp_strip_custom_css_from_blocks + Divi AttributeSecurity both detected). Ruling P20: no kses-window change; final wave adds a file-based editor regression test and switches synthetic-divi-filters.html to \u003c escapes — cost if wrong: none - Task 14: review (opus): Approved; live via registered ability: source access P5/P12, guard refusals as editor (7 kinds), P11, same-page byte identity, restore point, source/target rev locks, exposure matrix; minors → final wave: shared source-node helper (dup with LibrarySave), OUTLINE_HINT reuse, test line length, 'after'=>null in test - Task 14: complete (7fe91c2..6f6c691) - Task 15: dispatched (opus, BASE 6f6c691; F4, P9 unknown types, allow-map equivalence test) - Task 15: implementer done (8808a7d; 1827 tests; allow map byte-identical JSON snapshot); concerns → final wave: {{link:}} hint for attachments vs P10, choice fields show value not label (document) - Task 15: review (sonnet): Approved; allow-map snapshot test mutation-verified (non-vacuous); sample post only editable by user (implementer fixed brief leak); live page 26 sources - Task 15: complete (6f6c691..8808a7d) - Task 16: dispatched (opus, BASE 8808a7d; docs must describe actual T1–T15 behaviour, collected points in dispatch) - Task 16: implementer done (2729e28; 1829 tests; v0.4.0); library line in instructions only when group writable; spec 'Claude' wording → final wave - Task 16 review (sonnet) and Task 17 (opus, BASE 2729e28) dispatched in parallel (disjoint files: docs vs tests/Integration + abnahme note) - Task 16: review (sonnet): Approved (docs verified against code: 22 direct, result keys, P8a/P9/P12/P16/P19/P3/P15–P18, link rules, version, topics, neutral wording); → final wave: wp-lit-mcp.php plugin header still says "mit OAuth für claude.ai"; internal spec "Claude" wording - Task 16: complete (8808a7d..2729e28) - Task 17: done (186f7af; 6 smokes ALL CHECKS PASSED; 1829 tests); Ruling P21: T17 (test script + acceptance note only) is reviewed within the final whole-branch review instead of a separate task review — cost if wrong: none - Task 17: complete (2729e28..186f7af) - Final: whole-branch review (opus) + security audit (opus) dispatched in parallel on cfc81dd..186f7af - Final review (opus): ready with fixes; must: I-1 node-copy from library skips add_library (+ library-insert exposure), I-2 P13 guide vs code - Security audit (opus): no Critical/High/Medium; Low: L1 DoS in P8a/usages (loads full posts), L2 referenced-but-not-global entry skips dry run/P8a (live), L3 library-list search covers content + decodable cursor → existence oracle for authors (live), L4 dry run lists id/status/type of unreadable posts, L5 = I-1, L6 TB layouts accepted on WP caps only; hardening: guard vs first globalModule id, confirm not bound to dry run, "0123" refs - Ruling P22: final fix wave per final-fix-brief.md (must + audit L1–L6 + cheap review items); P13 full hiding and P10 → Etappe 3b; guide sentence fixed now — cost if wrong: none - Final fix wave: 17/17 (1b42f9b..f1937ca; 1868 tests; 6 smokes ok); decisions: referenced normal entries get dry run/confirm (drafts listed, only live block, no edit_global_library needed); cursor counts shown entries only; divi/global-layout check via Divi module list; link hint dropped for internal types; divi-write-smoke roles-option restore same issue → U; scoped re-review dispatched - Final fix re-review (opus): items 1–17 addressed (L1/L3/L4/L6/item 8 live-verified); F1 open: usage search misses Divi 4 shortcode global_module="ID" and JSON-escaped globalModule key/value (Divi renders them) → author edited an entry used only on such admin pages without dry run/P8a (live); minor: 'Eintrag, die' → 'den' - Ruling P23: GlobalUsages finds references fail-closed: SQL prefilter candidates = content containing 'globalModule' OR 'global_module' OR a Divi block opener with '\\u' escapes; then PHP-parse candidates: Divi 4 shortcodes (global_module attr, intval) and Divi 5 openers (json_decode attrs → globalModule intval); an escaped opener whose globalModule can't be decoded counts as a possible reference (blocking if not editable); guide sentence adjusted; wp_block embedding documented as parity (Info) — cost if wrong: more pages listed/blocking on odd content - Final fix round 1 (P23; fec5d9d, 8f87ec6; 1887 tests; smokes ok): candidate prefilter globalModule/global_module/escaped ASCII letter; UsageMatcher parses Divi 5 (json_decode) + Divi 4; undecodable escaped opener = possible reference; parse cap 5000 - F1 re-check: live bypass fixed, prefilter not evadable; NEW G1 Divi 4 shortcode inside a Divi 5 attribute value (\u0022-escaped quotes) misparsed; G2 leading space / scientific notation cast by Divi (int); G3 global_module values casting to 0 render the NEWEST published layout (et_pb_load_global_module WP_Query p=0); _bin collation note - Ruling P24: UsageMatcher scans decoded string values of every Divi 5 opener for Divi 4 shortcodes too; shortcode attrs parsed like WordPress (shortcode_parse_atts) and cast with (int) exactly like Divi; a global_module that casts to 0 counts as a reference to EVERY library entry (fail closed; rare); prefilter case-insensitive regardless of collation (LOWER() or explicit _ci COLLATE); guide wording without absolute claim; after this round remaining exotic forms are documented Low (breaker) — cost if wrong: coarse over-listing on odd content - Final fix round 2 (P24; b6d791d; 1901 tests; smokes ok; live: shortcode in Divi 5 text value with ", leading space, abc, GLOBAL_MODULE found; other-ID control not found) - Ruling P25 (breaker): accept after round 2 without another re-review — fail-closed rule in place, remaining exotic spellings need edit rights on the referencing page, documented as residual Low in the guide and rulings; G3 side effect (an invalid global_module on a real page makes every entry edit need confirm) accepted as fail-closed — cost if wrong: a crafted unusual spelling on a page the author can't edit escapes P8a (Low) - Etappe 3a complete: cfc81dd..b6d791d