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