diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md b/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md new file mode 100644 index 0000000..7c49e63 --- /dev/null +++ b/docs/superpowers/notes/2026-09-27-etappe-3a-rulings.md @@ -0,0 +1,111 @@ +# 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
diff --git a/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md b/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md
new file mode 100644
index 0000000..411de71
--- /dev/null
+++ b/docs/superpowers/notes/2026-09-27-etappe-3a-security-audit.md
@@ -0,0 +1,210 @@
+# Etappe 3a (Divi-Bibliothek): security audit
+
+- **Scope:** branch etappe-3, cfc81dd..186f7af. Focus: what the per-task reviews covered least (object authorization, global edits, stored-content injection, disclosure, DoS).
+- **Environment:** DDEV (WordPress 7.1.x, Divi 5.2.1), single site, no editors/authors present, `DISALLOW_UNFILTERED_HTML` off.
+- **Method:** read all new/changed `src/` files of the range plus the relevant Divi 5 render code (`BlockParser`, `BlockParserStore`, `GlobalLayout`, `DynamicContentOptions*`, `LinkUtils`, MultiView, Divi 4 `class-et-builder-element.php`), scanned all `script-library-*.js` for DOM sinks, and ran four self-cleaning `wp eval-file` probes (temp posts 1906–1914, temp user `litaudit_author`; all deleted, verified: 0 `LITAUDIT` posts, 0 postmeta/term rows for those IDs, no temp user, no probe files, `git status` clean). No [Fixture]/[Test] content was touched; no code was changed.
+
+**Result:** no Critical, High or Medium findings. The rulings P3/P5/P8a/P11/P12/P15–P19 hold. Six Low findings and three Info/hardening notes follow.
+
+---
+
+## Findings (most severe first)
+
+### L1 – Low (DoS): the P8a check loads every live usage with full content, and each dry run scans `wp_posts` 3×
+
+**Where**
+- `src/Integrations/Divi/Library/GlobalUsages.php:68-79` (`liveIds()`: no LIMIT).
+- `src/Integrations/Divi/Library/GlobalEdits.php:158-171` (`_prime_post_caches($live, …)`, then `current_user_can('edit_post')` per ID).
+- `GlobalUsages.php:29-47`: COUNT plus SELECT, each an unindexed `post_content LIKE '%…%'`.
+- Also reached from `GlobalEdits::notice()` (every delete dry run) and `LibraryListAbility::detail()`.
+
+**Scenario**
+1. A global module (newsletter box, CTA) sits in thousands of posts. That is a common Divi pattern.
+2. Any user with library rights and `edit_post` on the entry calls a Divi tool on it without `confirm` (dry run), or `lit/library-list` with `id`.
+3. `_prime_post_caches()` fetches `posts.*` for every live ID in one query, `post_content` included. At 5,000 posts × ~50 KB of Divi markup that is about 250 MB, which exceeds the usual `memory_limit` and ends the request in a fatal error.
+4. Each call also runs 3 full-table LIKE scans (6 on confirm, because dry run and confirm both call `of()` and `blocked()`). A loop of calls ties up the database on large sites.
+5. The effect stops when the requests stop, and it needs an authenticated MCP user.
+
+**Evidence:** by reading. The DDEV data set is too small to reproduce the memory limit.
+
+**Fix**
+- Use one query: `SELECT ID, post_status, post_type, post_author, post_title … LIKE …`. Derive `count`, the first 50 items and the live list in PHP, instead of COUNT + SELECT + `liveIds()`.
+- Evaluate `edit_post` in chunks of about 200 IDs, calling `_prime_post_caches()` per chunk and `clean_post_cache()` or `wp_cache_flush_runtime()` between chunks.
+- Or fail closed: above a hard cap (e.g. 1,000 live usages), refuse the confirmed edit with a hint ("im Divi-Builder bzw. von einem Administrator bearbeiten").
+
+### L2 – Low (global edits): "global" is decided by the scope term, but Divi renders every referenced entry, so edits to referenced non-global entries skip the dry run and P8a
+
+**Where**
+- `src/Integrations/Divi/Library/GlobalEdits.php:71` and `:88-90`: `if (! $global) return LibraryItem`.
+- `src/Integrations/Divi/Library/DiviLibraryStore.php:37` (`isGlobal()` → `GlobalLayout::is_global_layout_template()`, which checks the scope term, with a meta fallback for modules only).
+- Divi: `BlockParser.php:838-880` renders any `divi/global-layout` whose `globalModule` names a *published `et_pb_layout`*. It checks only `post_type` and `post_status`, not the scope.
+
+**Scenario**
+1. A live page that user U cannot edit holds an instance of library entry N, but N has no `global` scope term. This can happen when:
+ - the entry comes from an import (Divi's D4 fallback covers modules only, not sections or rows);
+ - the scope was changed later;
+ - the markup was written outside the Divi builder.
+2. U has `edit_post` on N (as its author, or as an editor) and changes N with any Divi tool.
+3. `GlobalEdits` treats N as a normal entry: no dry run, no `affected` list, no P8a check, no site-wide CSS clear.
+4. The page U cannot edit now shows the changed content.
+
+**Evidence (live)**
+- Temp entry 1910 (`scope = non_global`) and temp published page 1911 with ``.
+- `isGlobal(1910) = false`, while `GlobalUsages::liveIds(1910) = [1911]`.
+- The frontend HTTP render of 1911 shows the marker from 1910 (`LITAUDIT_NORMAL_ENTRY`).
+- Both posts were deleted afterwards.
+
+**Fix:** in `GlobalEdits::prepare()` and `notice()`, apply the global rules (confirm, `affected`, P8a, `GlobalItem` scope) whenever `isGlobal()` is true **or** `GlobalUsages` finds at least one usage. For the check, one `LIMIT 1` query is enough.
+
+### L3 – Low (disclosure): the `lit/library-list` cursor is an oracle over the *content* of entries the caller may not read
+
+**Where**
+- `src/Integrations/Divi/Library/LibraryListAbility.php:176-194`: rows the caller may not read still advance `$position`, and `next_cursor = Cursor::encode($position)` is `base64("o:N")`.
+- `src/Integrations/Divi/Library/DiviLibraryStore.php:103`: `'s' => search` makes WP search title, excerpt **and content**, although the schema at `LibraryListAbility.php:68` says "Suche im Titel".
+
+**Scenario**
+1. An author with the Divi library right creates two entries of their own that contain candidate words.
+2. They then search with `limit: 1`.
+3. The decoded cursor tells how many hidden entries match the search and sort before their own. That lets them guess words from other users' library content (codes, internal notes).
+4. Plugin rule E3 / P12 is that authors may not read those entries: `lit/library-list id=…` returns `lit_forbidden`.
+5. Mitigation: Divi's own Visual Builder library lists all entries to every user with the library right. Relative to Divi this is parity; relative to the plugin's own stricter rule it is a bypass. It is the same defect class that `PostGuard::ownPostsOnly()` already documents.
+
+**Evidence (live)**
+- Temp author and 3 temp entries: an admin entry "aaa" containing `LITAUDITSECRET pin 4711`, and author entries "zzz1" and "zzz2" containing `LITAUDITSECRET`.
+- As the author, `search=LITAUDITSECRET, limit=1` returns items `["LITAUDIT zzz1"]` with `next_cursor=bzoy` (= `o:2`, which reveals one hidden match).
+- A direct read of the admin entry returns `lit_forbidden`.
+- All temp data was deleted and the deletion verified.
+
+**Fix**
+- Restrict the query in `find()`:
+ - add `'author' => PostGuard::ownPostsOnly(DiviLibraryStore::POST_TYPE)` when it is non-null, so the cursor counts only rows the caller can see;
+ - set `'search_columns' => ['post_title']` (WP ≥ 6.2) to match the documented title-only search.
+- Keep the per-row `canRead()` filter as defence in depth.
+
+### L4 – Low (disclosure): `affected`, `blocked_ids` and the P8a refusal expose IDs, status and type of posts the caller may not read
+
+**Where**
+- `src/Integrations/Divi/Library/GlobalUsages.php:48-56`: title masked, but `post_id`, `status` and `type` are always returned.
+- `src/Integrations/Divi/Library/GlobalEdits.php:184-203`: IDs in message and details.
+- Also `lit/library-list id=…` (`affected`).
+
+**Scenario**
+1. An author owns a global entry. An administrator used it on private pages and on other users' drafts.
+2. The author's dry run lists those posts' IDs, their status (`private`, `draft`, `future`) and their post type.
+3. Only the titles are hidden.
+
+**Evidence:** by reading. The masking applies to `post_title` only.
+
+**Fix**
+- For rows without `read_post`, return only an aggregate: `hidden_count`, plus the count of blocking hidden posts.
+- Name blocked IDs only where `read_post` holds; otherwise say "N Einträge, die dieses Konto nicht lesen darf".
+
+### L5 – Low (authorization): `lit/divi-node-copy` from a library entry into a page skips Divi's role option `add_library`
+
+**Where**
+- `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php:78-96`: the source goes through `DiviPageLoader::load(…, false)`, i.e. `LibraryAccess::item()` / `canRead()` (`edit_posts`, `divi_library`, `edit_post`).
+- Compare `LibraryAccess::canInsert()` (`add_library`), which `lit/library-insert` requires.
+
+**Scenario**
+1. The site owner turns off "Aus der Bibliothek hinzufügen" (add from library) for a role in Divi → Rollen-Editor.
+2. A user of that role can still copy any node from an entry they can edit into a page, via `lit/divi-node-copy` with `source_post_id =