From db74f546a396f4ceba9cd2d895627f32e3fa5f52 Mon Sep 17 00:00:00 2001 From: Renn F Date: Sun, 21 Jun 2026 04:20:07 +0200 Subject: [PATCH] feat(content): universal anti-soup guard on every agent free-text field --- agents/prompts/base.md | 1 + roboco/services/gateway/content_actions.py | 38 +++++++++++++++++++ tests/unit/gateway/test_content_actions.py | 7 +++- tests/unit/gateway/test_content_soup_guard.py | 37 ++++++++++++++++++ 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 tests/unit/gateway/test_content_soup_guard.py diff --git a/agents/prompts/base.md b/agents/prompts/base.md index 3be21dac..fee15804 100644 --- a/agents/prompts/base.md +++ b/agents/prompts/base.md @@ -78,6 +78,7 @@ Channel arguments take the slug **without** the `#` prefix: `"backend-cell"`, no - `env`/`printenv` is **denied** — secrets are not readable from your container. - `Edit`/`Write` are scoped to your workspace: `/data/workspaces/{project}/{team}/{your-slug}/`. - Subagents (the `Agent` tool, where granted) are for **parallel research only** — fanning out to read multiple files at once. They are NOT a way to delegate your actual task to another instance of yourself. +- **Everything you write is validated — no filler, no word soup, anywhere.** `say`/`dm`/`note`/`progress`/`notify` reject empty/placeholder text (`asdf`, `wip`, `tbd`, `...`); state what actually happened. Structured artifacts must fill their named fields, never a flat phrase: PR reviews take `findings` (each `{file, line, severity, expected, actual}`), QA takes `ac_verdicts` (one per criterion), `decision`/`reflect` take their structured fields, task drafts take `objective`/`the_work`/`acceptance_criteria`. ## Branch and commit conventions (handled by the gateway) diff --git a/roboco/services/gateway/content_actions.py b/roboco/services/gateway/content_actions.py index 23166b8e..cd2ae2ad 100644 --- a/roboco/services/gateway/content_actions.py +++ b/roboco/services/gateway/content_actions.py @@ -21,6 +21,7 @@ import structlog from roboco.config import settings from roboco.exceptions import GitError from roboco.foundation.policy import communications as _comms +from roboco.foundation.policy.content.validators import reject_trivial from roboco.foundation.policy.journaling import Scope as _Scope from roboco.services.gateway.commit_validator import validate_commit_message from roboco.services.gateway.envelope import Envelope @@ -332,6 +333,27 @@ class ContentActions: return None return _not_active_claimant(task.id) + @staticmethod + def _reject_soup(value: str, *, field: str, min_chars: int = 3) -> Envelope | None: + """Universal anti-soup guard for agent free-text. + + Returns a remediation Envelope (never a raw 422 — a 422 at the route + trips the do-server circuit breaker) when ``value`` is empty, too short, + or a placeholder/filler token, so soup lands NOWHERE. ``None`` = clean. + """ + try: + reject_trivial(value, field=field, min_chars=min_chars) + except ValueError as exc: + return Envelope.invalid_state( + message=str(exc), + remediate=( + f"write a substantive {field} (>={min_chars} chars, no filler " + "like 'asdf'/'wip'/'tbd'/'...'); state what actually happened." + ), + context_briefing={}, + ) + return None + async def commit( self, *, @@ -486,6 +508,8 @@ class ContentActions: is taken from ``structured["title"]`` when present, otherwise from the first line of ``text``. """ + if rej := self._reject_soup(text, field="note", min_chars=8): + return rej if scope not in _VALID_NOTE_SCOPES: return Envelope.invalid_state( message=f"invalid scope {scope!r}", @@ -538,6 +562,12 @@ class ContentActions: proposal. On CEO approval the system provisions a repo per target cell, registers the projects, and seeds the first Main-PM task. """ + for _pf, _pv in ( + ("problem", problem), + ("proposed_solution", proposed_solution), + ): + if rej := self._reject_soup(_pv, field=_pf, min_chars=15): + return rej from pydantic import ValidationError as PydanticValidationError from roboco.models.pitch import PitchCreate @@ -601,6 +631,8 @@ class ContentActions: `ChannelAccessDeniedError`; we convert it into a friendly `not_authorized` Envelope listing the agent's writable channels. """ + if rej := self._reject_soup(text, field="message", min_chars=2): + return rej from roboco.enforcement.channel_access import ( ChannelAccessDeniedError, get_agent_channels, @@ -661,6 +693,8 @@ class ContentActions: skill: str | None = None, ) -> Envelope: """A2A direct message. Requires task_id (active or explicit).""" + if rej := self._reject_soup(text, field="message", min_chars=2): + return rej # Spec §5.5: auditor is silent — defense-in-depth runtime guard. # See say() above for rationale. Mirrored here because dm() is # the other channel through which the auditor could "speak". @@ -740,6 +774,8 @@ class ContentActions: """ from roboco.models import NotificationPriority + if rej := self._reject_soup(text, field="notification", min_chars=5): + return rej if priority not in _VALID_NOTIFY_PRIORITIES: return Envelope.invalid_state( message=f"invalid priority {priority!r}", @@ -972,6 +1008,8 @@ class ContentActions: the single-claimant guard so a reaped/handed-off assignee cannot keep writing. """ + if rej := self._reject_soup(message, field="progress update", min_chars=5): + return rej t = await self.task.get(task_id) if t is None: return Envelope.not_found(message=f"task {task_id} not found") diff --git a/tests/unit/gateway/test_content_actions.py b/tests/unit/gateway/test_content_actions.py index 846ac005..ee66ebcc 100644 --- a/tests/unit/gateway/test_content_actions.py +++ b/tests/unit/gateway/test_content_actions.py @@ -678,7 +678,7 @@ async def test_notify_explicit_task_not_found_rejected() -> None: env = await ca.notify( agent_id=agent_id, target="be-dev-1", - text="hi", + text="Please review the assembled PR before merge.", priority="normal", task_id=uuid4(), ) @@ -803,7 +803,10 @@ async def test_progress_unknown_plan_step_invalid_state_lists_valid() -> None: actions = ContentActions(_make_deps(task=task)) env = await actions.progress( - agent_id=agent_id, task_id=t.id, message="?", plan_step="bogus" + agent_id=agent_id, + task_id=t.id, + message="Finished the auth refactor step.", + plan_step="bogus", ) body = env.as_dict() assert body["error"] == "invalid_state", body diff --git a/tests/unit/gateway/test_content_soup_guard.py b/tests/unit/gateway/test_content_soup_guard.py new file mode 100644 index 00000000..c4d80c74 --- /dev/null +++ b/tests/unit/gateway/test_content_soup_guard.py @@ -0,0 +1,37 @@ +"""Universal anti-soup guard — no filler/placeholder text in ANY agent field. + +``ContentActions._reject_soup`` is applied to every free-text content tool +(say/dm/note/progress/notify/pitch) so soup lands nowhere. It returns a +remediation Envelope (never a raw 422 — that trips the do-server circuit +breaker), or None when the text is substantive. +""" + +from __future__ import annotations + +import pytest +from roboco.services.gateway.content_actions import ContentActions + +_guard = ContentActions._reject_soup + + +@pytest.mark.parametrize("soup", ["", " ", "asdf", "...", "wip", "tbd", "x", "-"]) +def test_rejects_placeholders_and_filler(soup: str) -> None: + assert _guard(soup, field="message", min_chars=2) is not None + + +def test_accepts_substantive_text() -> None: + assert _guard("LGTM — merging after CI.", field="message", min_chars=2) is None + assert _guard("ok", field="message", min_chars=2) is None # terse but real + + +def test_min_chars_enforced_per_field() -> None: + # A note must be a sentence, not a fragment. + assert _guard("hi", field="note", min_chars=8) is not None + assert _guard("Reviewed the auth refactor.", field="note", min_chars=8) is None + + +def test_returns_remediation_envelope() -> None: + env = _guard("asdf", field="progress update", min_chars=5) + assert env is not None + assert env.error is not None + assert "progress update" in (env.remediate or "")