harden(agent-team): identity-aware macro-preservation guard
Address the verifier's residual on the macro-loss fix: the guard was raw-count based, so a body that DROPPED the real Mermaid macro while ADDING an unrelated macro (equal count) could slip through. Replace count_storage_macros in the guard with storage_macro_signature (per-ac:name multiset) and refuse if ANY macro identity loses occurrences. +2 tests.
This commit is contained in:
parent
ed8e9a13ab
commit
f56ee1c58d
4 changed files with 94 additions and 13 deletions
|
|
@ -43,6 +43,7 @@ import difflib
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
from urllib import error as _urlerror
|
||||
|
|
@ -62,6 +63,7 @@ __all__ = [
|
|||
"body_diff",
|
||||
"count_storage_macros",
|
||||
"init_auth",
|
||||
"storage_macro_signature",
|
||||
]
|
||||
|
||||
# Confluence storage-format macros (Mermaid diagrams, info panels, etc.) are
|
||||
|
|
@ -72,6 +74,14 @@ __all__ = [
|
|||
_STORAGE_MACRO_RE = re.compile(
|
||||
r"<ac:(?:structured-macro|adf-extension)\b", re.IGNORECASE
|
||||
)
|
||||
# Opening tag of a macro element + its attribute blob, so the macro's IDENTITY
|
||||
# (its ``ac:name``) can be read — an identity-aware guard refuses a body that
|
||||
# DROPS a specific diagram macro even when an unrelated macro keeps the raw count
|
||||
# equal. ``[^>]*`` matches both self-closing and paired opening tags.
|
||||
_STORAGE_MACRO_TAG_RE = re.compile(
|
||||
r"<ac:(structured-macro|adf-extension)\b([^>]*)>", re.IGNORECASE
|
||||
)
|
||||
_AC_NAME_RE = re.compile(r'ac:name\s*=\s*"([^"]*)"', re.IGNORECASE)
|
||||
|
||||
# Default 2LO token endpoint (overridable via CONFLUENCE_OAUTH_TOKEN_URL).
|
||||
DEFAULT_OAUTH_TOKEN_URL = "https://auth.atlassian.com/oauth/token"
|
||||
|
|
@ -574,6 +584,34 @@ def count_storage_macros(body: str) -> int:
|
|||
return len(_STORAGE_MACRO_RE.findall(body))
|
||||
|
||||
|
||||
def storage_macro_signature(body: str) -> Counter[str]:
|
||||
"""Return a multiset of macro IDENTITIES in a storage-format body.
|
||||
|
||||
Each ``<ac:structured-macro>`` is keyed by its ``ac:name`` (e.g.
|
||||
``"macro:mermaid-cloud"``), unnamed macros under ``"macro:_unnamed"``, and
|
||||
``<ac:adf-extension>`` under ``"adf-extension"``. Comparing the current
|
||||
page's signature to a proposed body's lets a writer refuse a storage update
|
||||
that DROPS a specific diagram macro even when an unrelated macro keeps the
|
||||
raw count equal (an identity-blind count guard would miss that). Empty/None
|
||||
body yields an empty ``Counter``.
|
||||
"""
|
||||
sig: Counter[str] = Counter()
|
||||
if not body:
|
||||
return sig
|
||||
for kind, attrs in _STORAGE_MACRO_TAG_RE.findall(body):
|
||||
if kind.lower() == "adf-extension":
|
||||
sig["adf-extension"] += 1
|
||||
continue
|
||||
name_match = _AC_NAME_RE.search(attrs)
|
||||
key = (
|
||||
f"macro:{name_match.group(1).strip().lower()}"
|
||||
if name_match
|
||||
else "macro:_unnamed"
|
||||
)
|
||||
sig[key] += 1
|
||||
return sig
|
||||
|
||||
|
||||
def _extract_storage_body(page: dict[str, Any]) -> str:
|
||||
"""Pull ``body.storage.value`` from a v2 page object, defaulting to ``""``."""
|
||||
body = page.get("body")
|
||||
|
|
|
|||
|
|
@ -635,21 +635,29 @@ def _assert_macros_preserved(page_id: Any, current_body: str, new_body: Any) ->
|
|||
|
||||
A wholesale storage-format body replacement silently destroys ``<ac:...>``
|
||||
macro extensions (Mermaid diagrams) the model-authored body does not
|
||||
reproduce — the exact failure that erased every diagram on page 1540098. If
|
||||
the current page carries more macros than the proposed body, refuse rather
|
||||
than overwrite. Counting is delegated to the client's storage-macro counter.
|
||||
"""
|
||||
from agent_team.confluence.client import count_storage_macros
|
||||
reproduce — the exact failure that erased every diagram on page 1540098.
|
||||
|
||||
current = count_storage_macros(current_body or "")
|
||||
proposed = count_storage_macros(str(new_body or ""))
|
||||
if current > proposed:
|
||||
The check is IDENTITY-aware, not a raw count: it compares the per-macro-name
|
||||
multiset (``storage_macro_signature``) of the current page against the
|
||||
proposed body and refuses if ANY macro identity loses occurrences. So a body
|
||||
that drops the real Mermaid macro but adds an unrelated macro (keeping the
|
||||
raw count equal) is still refused.
|
||||
"""
|
||||
from agent_team.confluence.client import storage_macro_signature
|
||||
|
||||
current = storage_macro_signature(current_body or "")
|
||||
proposed = storage_macro_signature(str(new_body or ""))
|
||||
dropped = {
|
||||
key: count - proposed.get(key, 0)
|
||||
for key, count in current.items()
|
||||
if count > proposed.get(key, 0)
|
||||
}
|
||||
if dropped:
|
||||
raise ConfluenceWriteError(
|
||||
f"refusing storage write to page {page_id}: the current page carries "
|
||||
f"{current} macro(s) but the proposed body has {proposed} — a "
|
||||
"wholesale storage update would DROP diagram/extension macros (e.g. "
|
||||
"Mermaid). Edit via the ADF path (get_page_adf/update_page_adf) or "
|
||||
"preserve the existing macros in body_storage."
|
||||
f"refusing storage write to page {page_id}: a wholesale storage update "
|
||||
f"would DROP diagram/extension macros {dropped} (e.g. Mermaid). Edit via "
|
||||
"the ADF path (get_page_adf/update_page_adf) or preserve the existing "
|
||||
"macros in body_storage."
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from agent_team.confluence.client import (
|
|||
body_diff,
|
||||
count_storage_macros,
|
||||
init_auth,
|
||||
storage_macro_signature,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -497,6 +498,21 @@ def test_count_storage_macros_counts_structured_and_adf_extensions() -> None:
|
|||
assert count_storage_macros("<p>no macros here</p>") == 0
|
||||
|
||||
|
||||
def test_storage_macro_signature_keys_by_identity() -> None:
|
||||
body = (
|
||||
'<ac:structured-macro ac:name="mermaid-cloud"/>'
|
||||
'<ac:structured-macro ac:name="info"/>'
|
||||
"<ac:structured-macro>x</ac:structured-macro>" # unnamed
|
||||
"<ac:adf-extension>y</ac:adf-extension>"
|
||||
)
|
||||
sig = storage_macro_signature(body)
|
||||
assert sig["macro:mermaid-cloud"] == 1
|
||||
assert sig["macro:info"] == 1
|
||||
assert sig["macro:_unnamed"] == 1
|
||||
assert sig["adf-extension"] == 1
|
||||
assert storage_macro_signature("") == {}
|
||||
|
||||
|
||||
def test_page_has_macros_true_when_body_carries_macro() -> None:
|
||||
page = _storage_page("1540098", 7, '<ac:structured-macro ac:name="mermaid"/>')
|
||||
http = FakeHttp(get=(200, page))
|
||||
|
|
|
|||
|
|
@ -577,6 +577,25 @@ def test_conf_write_storage_refuses_to_drop_macros() -> None:
|
|||
assert client.update_calls == [] # nothing was written
|
||||
|
||||
|
||||
def test_conf_write_storage_refuses_macro_swap_at_equal_count() -> None:
|
||||
# Identity-aware guard: the proposed body DROPS the Mermaid macro but ADDS an
|
||||
# unrelated macro, keeping the raw count equal (1 -> 1). A count-only guard
|
||||
# would wave this through; the signature guard must still refuse.
|
||||
client = _MacroBodyClient(current_macros=1) # current: one mermaid-cloud macro
|
||||
draft = dict(_VALID_DRAFT)
|
||||
draft["body_storage"] = (
|
||||
'<ac:structured-macro ac:name="info"><ac:rich-text-body>'
|
||||
"<p>note</p></ac:rich-text-body></ac:structured-macro>"
|
||||
)
|
||||
with pytest.raises(ConfluenceWriteError, match="would DROP diagram/extension"):
|
||||
conf_write_node(
|
||||
_state(confluence_draft=draft),
|
||||
config={"confluence_apply": True},
|
||||
client=client,
|
||||
)
|
||||
assert client.update_calls == []
|
||||
|
||||
|
||||
def test_conf_write_storage_allows_when_macros_preserved() -> None:
|
||||
# When the new body reproduces the macro count, the write proceeds.
|
||||
client = _MacroBodyClient(current_macros=1)
|
||||
|
|
|
|||
Reference in a new issue