diff --git a/.console/log.md b/.console/log.md
index aae1f04da..b4886934d 100644
--- a/.console/log.md
+++ b/.console/log.md
@@ -106,6 +106,44 @@ than a red one.
Related, same root cause one layer up: Custodian's `find_tool()` preferred its own
venv over the audited repo's, so a globally-installed `custodian-multi` reproduced
this identically off-CI. Fixed in ProtocolWarden/Custodian#72.
+## 2026-08-03 — fix(contracts): short fields summarized the injection preamble, not the goal
+
+`wrap_untrusted_goal` emits `GOAL_PREAMBLE` BEFORE the fence, so every
+issue-sourced `goal_text` starts with "SECURITY: the text inside the
+<> … <>
+fen" while their real goals were "Fix `edge_cases` to forward the sample list,
+not the count dict" and "Add regression test suite that execs the live STEP 3
+snippet against the OUTPUT". Cosmetic in effect but corrosive in practice: it
+makes routine autonomous PRs read as security events and destroys board
+scannability. Both call sites were the same bug — fixing only the title would
+have left `scope` broken.
+
+The fix is NOT a regex in the mapper. `injection.py` owns the fence format, so
+it grew the reader: `unfence_goal()` (payload extraction, backreferenced nonce
+so a forged close marker with a guessed nonce does not terminate the span,
+falling back to the input unchanged when unfenced) and `goal_summary()`
+(unfence → collapse to one line → `sanitize_for_comment` → bound). The mapper
+just calls `goal_summary`.
+
+Two deliberate decisions worth recording. FIRST, `objective` still carries the
+FULL wrapped text — the preamble and fence must reach the executor intact; only
+the short human/telemetry-facing fields are summarized, and a test pins that
+distinction. SECOND, this MOVES attacker-influenced text into GitHub PR titles,
+which the old (accidental) behavior did not do — so `goal_summary` routes
+through `sanitize_for_comment` to defang `@mentions` (a bare `@handle` in a PR
+title pings a real person) and strip zero-width/bidi characters. Single-line
+collapse matters for the same reason: a newline breaks a PR title.
+
+Verified by mutation, not just by green tests: reverted both call sites to the
+raw slices and reran — both new pins failed, reproducing the exact observed
+string (`scope == 'SECURITY: th...from an exter'`); restored, all pass. 44 tests
+across `test_injection.py` + `test_cxrp_mapper.py`; no pre-existing test asserts
+on CxRP `title`/`scope`, so blast radius is limited to the new pins. ruff check
+and ruff format clean.
## 2026-07-15 — feat(reviewer): ACTIVATE the council — populate guardrail_paths (§G1)
diff --git a/src/operations_center/contracts/cxrp_mapper.py b/src/operations_center/contracts/cxrp_mapper.py
index c50e39074..71b6e8863 100644
--- a/src/operations_center/contracts/cxrp_mapper.py
+++ b/src/operations_center/contracts/cxrp_mapper.py
@@ -57,6 +57,7 @@
from cxrp.vocabulary.runtime import RuntimeKind, SelectionMode
from cxrp.vocabulary.status import ExecutionStatus as CxrpExecutionStatus
+from ..injection import goal_summary
from .enums import BackendName, LaneName
from .execution import OcExecutionRequest, OcExecutionResult, RuntimeBindingSummary
from .proposal import OcPlanningProposal
@@ -122,7 +123,11 @@ def to_cxrp_task_proposal(oc: OcPlanningProposal) -> CxrpTaskProposal:
"proposer": oc.proposer,
"labels": list(oc.labels),
},
- title=oc.goal_text[:80],
+ # goal_summary, not a raw slice: an issue-sourced goal_text begins with
+ # GOAL_PREAMBLE, so [:80] titles every such task with the preamble's
+ # opening words instead of the request. objective keeps the FULL wrapped
+ # text — the fence and its preamble must reach the executor intact.
+ title=goal_summary(oc.goal_text, max_len=80),
objective=oc.goal_text,
task_type=oc.task_type.value,
execution_mode=oc.execution_mode.value,
@@ -247,7 +252,8 @@ def to_cxrp_execution_request(
lane=_category_for(executor),
executor=CxrpExecutorName(executor),
backend=CxrpBackendName(_cxrp_backend_for(backend)),
- scope=oc.goal_text[:120],
+ # Same preamble-slicing bug as the proposal title above.
+ scope=goal_summary(oc.goal_text, max_len=120),
input_payload=input_payload,
input_payload_schema=CODING_AGENT_INPUT_SCHEMA_ID,
constraints=[oc.constraints_text] if oc.constraints_text else [],
diff --git a/src/operations_center/injection.py b/src/operations_center/injection.py
index 99bba9a3d..a0f791b31 100644
--- a/src/operations_center/injection.py
+++ b/src/operations_center/injection.py
@@ -91,6 +91,62 @@ def wrap_untrusted_goal(goal: str, *, label: str = "issue_goal") -> str:
return f"{GOAL_PREAMBLE}\n\n{fence(label, goal, nonce)}"
+# Matches a span produced by `fence`. The closing marker must carry the SAME
+# nonce and label as the opening one — a backreference, mirroring `fence`'s
+# guarantee that an attacker's copy of the close token does not terminate the
+# span. `search` finds the real (first) open marker, since `wrap_untrusted_goal`
+# emits the preamble before the fence and any forged marker lands inside it.
+_GOAL_FENCE_RE = re.compile(
+ r"<[0-9a-fA-F]+):(?P>",
+ re.DOTALL,
+)
+
+
+def unfence_goal(text: str) -> str:
+ """Return the payload inside a ``wrap_untrusted_goal`` fence.
+
+ Falls back to ``text`` unchanged when no well-formed fence is present, so
+ callers work on both wrapped and raw goals.
+
+ IMPORTANT: the returned text is STILL UNTRUSTED. Unfencing is a *display*
+ operation — it strips the scaffolding so a human sees the actual request,
+ and confers no authority on the content. Anything reflected outward should
+ go through :func:`goal_summary` (or :func:`sanitize_for_comment`) rather
+ than using this return value raw.
+ """
+ if not text:
+ return ""
+ match = _GOAL_FENCE_RE.search(str(text))
+ return match.group("payload") if match else str(text)
+
+
+def goal_summary(text: str, *, max_len: int = 80) -> str:
+ """A short, single-line, defanged summary of a (possibly fenced) goal.
+
+ For the human- and telemetry-facing short fields — a CxRP proposal title, an
+ execution scope, a log line. Slicing a RAW wrapped goal is wrong there:
+ ``wrap_untrusted_goal`` puts :data:`GOAL_PREAMBLE` *before* the fence, so
+ ``goal_text[:80]`` yields the preamble's opening words for EVERY
+ issue-sourced task instead of the actual request.
+
+ Unfence, collapse to a single line (short fields must not carry newlines),
+ defang via :func:`sanitize_for_comment` — the payload is attacker-influenced
+ and these fields flow into GitHub PR titles, where a bare ``@handle`` would
+ ping a real person — then bound to ``max_len``.
+
+ A blank fenced payload falls back to the sanitized full text, so a field
+ with a non-empty requirement still receives a value.
+ """
+ if not text:
+ return ""
+ collapsed = " ".join(unfence_goal(text).split())
+ if not collapsed:
+ collapsed = " ".join(str(text).split())
+ return sanitize_for_comment(collapsed, max_len=max_len)
+
+
def sanitize_for_comment(text: str, *, max_len: int = 4000) -> str:
"""Defang model/untrusted text before posting it to GitHub.
@@ -115,7 +171,9 @@ def sanitize_for_comment(text: str, *, max_len: int = 4000) -> str:
"GOAL_PREAMBLE",
"UNTRUSTED_PREAMBLE",
"fence",
+ "goal_summary",
"make_nonce",
"sanitize_for_comment",
+ "unfence_goal",
"wrap_untrusted_goal",
]
diff --git a/tests/unit/contracts/test_cxrp_mapper.py b/tests/unit/contracts/test_cxrp_mapper.py
index e0a0fa563..23134c0ab 100644
--- a/tests/unit/contracts/test_cxrp_mapper.py
+++ b/tests/unit/contracts/test_cxrp_mapper.py
@@ -337,3 +337,51 @@ def test_ecp_status_round_trip_for_terminal_states(status_value):
)
cxrp = to_cxrp_execution_result(oc)
assert cxrp.status.value == status_value
+
+
+# --- Issue-sourced goals: short fields must summarize the REQUEST -------------
+# Regression pins. `wrap_untrusted_goal` prepends GOAL_PREAMBLE before the fence,
+# so the previous `goal_text[:80]` / `[:120]` slices titled and scoped EVERY
+# issue-sourced task with the preamble's opening words instead of the request.
+# Observed live on OperationsCenter PRs #478 and #483.
+
+
+def _wrapped_goal(goal: str) -> str:
+ from operations_center.injection import wrap_untrusted_goal
+
+ return wrap_untrusted_goal(goal)
+
+
+def test_task_proposal_title_summarizes_request_not_preamble():
+ proposal = _make_proposal().model_copy(
+ update={"goal_text": _wrapped_goal("Fix edge_cases to forward the sample list")}
+ )
+ cxrp = to_cxrp_task_proposal(proposal)
+ assert cxrp.title == "Fix edge_cases to forward the sample list"
+ assert not cxrp.title.startswith("SECURITY:")
+ assert "UNTRUSTED" not in cxrp.title
+
+
+def test_task_proposal_objective_keeps_the_full_fenced_goal():
+ """The executor must still receive the preamble and fence intact."""
+ wrapped = _wrapped_goal("Fix edge_cases to forward the sample list")
+ proposal = _make_proposal().model_copy(update={"goal_text": wrapped})
+ cxrp = to_cxrp_task_proposal(proposal)
+ assert cxrp.objective == wrapped
+ assert cxrp.objective.startswith("SECURITY:")
+ assert "<