mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(content): universal anti-soup guard on every agent free-text field
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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 "")
|
||||
Reference in New Issue
Block a user