feat(content): universal anti-soup guard on every agent free-text field

This commit is contained in:
Renn F
2026-06-21 04:20:07 +02:00
parent b086dc2c41
commit db74f546a3
4 changed files with 81 additions and 2 deletions
+1
View File
@@ -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)
@@ -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")
+5 -2
View File
@@ -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
@@ -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 "")