From 5617e8b19ed12cffded2cdcba9cd05f90d57fdd0 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 26 Aug 2026 12:52:03 -0700 Subject: [PATCH] [eric] agents: the CLI's prose policy refusal is the provider talking, not the agent's answer (ENG-411) --- backend/apps/agents/core/error_classify.py | 31 +++++++- .../streaming/provider_error_speech.py | 6 +- backend/tests/test_cli_policy_refusal.py | 71 +++++++++++++++++++ backend/tests/test_overcorrection_audit.py | 16 +++++ 4 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_cli_policy_refusal.py diff --git a/backend/apps/agents/core/error_classify.py b/backend/apps/agents/core/error_classify.py index b0d8c395..3be2df52 100644 --- a/backend/apps/agents/core/error_classify.py +++ b/backend/apps/agents/core/error_classify.py @@ -73,7 +73,22 @@ P_PROVIDER_ENVELOPE = re.compile( re.IGNORECASE, ) -P_CONTENT_POLICY_BLOCK = re.compile(r"blocked\s+as\s+it\s+seems\s+to\s+violate|legal/aup|acceptable\s+use\s+policy", re.IGNORECASE) +# "Usage Policy" is the wording the CLI itself prints; "Acceptable Use Policy" is the API's. Both are +# the same refusal, and matching only one under-counted the class for days. +P_CONTENT_POLICY_BLOCK = re.compile( + r"blocked\s+as\s+it\s+seems\s+to\s+violate" + r"|violat\w*\s+(?:our\s+)?(?:acceptable\s+use|usage)\s+policy" + r"|legal/aup" + r"|acceptable\s+use\s+policy" + r"|duplicating\s+model\s+outputs", + re.IGNORECASE, +) + +# The CLI hands the filter's verdict back as if the model had written it: no "API Error:", no status +# code, just prose. It is still the provider talking, and letting it stand as assistant content is +# exactly how policy language ends up in every later request in that chat. +P_REFUSAL_OPENER = re.compile(r"unable\s+to\s+respond\s+to\s+this\s+request", re.IGNORECASE) +P_REFUSAL_OPENS_WITHIN = 60 @typechecked @@ -81,6 +96,18 @@ def is_content_policy_block(text: str) -> bool: return bool(P_CONTENT_POLICY_BLOCK.search(text)) +@typechecked +def opens_with_provider_refusal(text: str) -> bool: + """A refusal that OPENS the message, the same "must open it" rule the router-stamp check uses. + + An answer that merely mentions a policy keeps its work; only a reply that is nothing but the + refusal is treated as the provider speaking. + """ + stripped = text.strip() + p_at = P_REFUSAL_OPENER.search(stripped) + return bool(p_at and p_at.start() <= P_REFUSAL_OPENS_WITHIN and is_content_policy_block(stripped)) + + # Asking one agent to reproduce another's work verbatim is the shape the subscription lane refuses ("duplicating model outputs"). Our own handoff prompts are model-authored and land in a forked child as its user turn, which is where a third of one user's blocks came from; one agent even diagnosed itself: "my phrasing about 'dumping verbatim' tripped it". P_EXTRACTION_ASK = re.compile( r"\bverbatim\b|\bword[- ]for[- ]word\b|complete\s+dump|\bdump\s+(?:of|everything|all|your)|" @@ -115,7 +142,7 @@ def neutralize_provider_refusal(text: str) -> str: # matches; replacing it would delete the user's work and tell nobody, which is a worse bug than # the one this guard exists for. A relayed refusal is always wrapped in a provider ENVELOPE, and # prose about policy never is, so require both before anything is thrown away. - if not P_PROVIDER_ENVELOPE.search(text): + if not P_PROVIDER_ENVELOPE.search(text) and not opens_with_provider_refusal(text): return text if is_content_policy_block(text) or "unable to respond to this request" in text.lower(): return ("That agent could not answer this request and returned no usable result. " diff --git a/backend/apps/agents/manager/streaming/provider_error_speech.py b/backend/apps/agents/manager/streaming/provider_error_speech.py index 320060c4..929c75fc 100644 --- a/backend/apps/agents/manager/streaming/provider_error_speech.py +++ b/backend/apps/agents/manager/streaming/provider_error_speech.py @@ -32,7 +32,7 @@ from typing import Optional from pydantic import BaseModel, ConfigDict from typeguard import typechecked -from backend.apps.agents.core.error_classify import is_content_policy_block +from backend.apps.agents.core.error_classify import is_content_policy_block, opens_with_provider_refusal # Written by the CLI in front of anything upstream failed with. P_ENVELOPE_PREFIX = "api error:" @@ -103,6 +103,10 @@ def looks_like_provider_envelope(text: str) -> bool: return False if stripped[: len(P_ENVELOPE_PREFIX)].lower() == P_ENVELOPE_PREFIX: return True + # The CLI prints the policy filter's verdict as plain prose with no stamp at all, so the shape + # check has to know it or a refusal stands as the model's own answer (ENG-411). + if opens_with_provider_refusal(stripped): + return True # A router stamp is only an envelope when it opens the message; quoted mid-prose it is speech. m = P_ROUTER_STAMP.search(stripped) return bool(m and m.start() <= 80 and stripped.lower().startswith(("request ", "[", "error"))) diff --git a/backend/tests/test_cli_policy_refusal.py b/backend/tests/test_cli_policy_refusal.py new file mode 100644 index 00000000..39cc47d3 --- /dev/null +++ b/backend/tests/test_cli_policy_refusal.py @@ -0,0 +1,71 @@ +"""The CLI hands the filter's verdict back as prose, and it used to be stored as the model's words. + +Production 1.7.9, 2026-08-26: sub-agents came home with +"Claude Code is unable to respond to this request, which appears to violate our Usage Policy" as +their ANSWER. There is no "API Error:" prefix and no status code in it, so every envelope check in +the codebase said "this is the agent talking" and the refusal became transcript content: the parent +then carried policy-violation language into every later request (the exact class CLAUDE.md forbids). + +Two separate misses, both sealed here: the wording ("Usage Policy", not "Acceptable Use Policy") and +the shape (prose, not an envelope). +""" + +import re + +from backend.apps.agents.core.error_classify import ( + is_content_policy_block, neutralize_provider_refusal, opens_with_provider_refusal, +) +from backend.apps.agents.manager.streaming.provider_error_speech import ( + POLICY, classify_provider_error, looks_like_provider_envelope, +) + +REAL = ("Claude Code is unable to respond to this request, which appears to violate our Usage Policy " + "(https://www.anthropic.com/legal/aup). Please double press esc to edit your last message or " + "start a new session for a fresh start.") + + +def test_the_clis_own_wording_is_recognised_without_the_url(): + # It only ever matched via "legal/aup" in the URL, so a truncated preview read as a clean turn. + assert is_content_policy_block("appears to violate our Usage Policy.") + assert is_content_policy_block("violates our Acceptable Use Policy") + assert is_content_policy_block("reverse engineering or duplicating model outputs") + + +def test_prose_with_no_envelope_is_still_the_provider_talking(): + assert "API Error" not in REAL and not re.search(r"\b4\d\d\b", REAL) + assert opens_with_provider_refusal(REAL) + assert looks_like_provider_envelope(REAL) + c = classify_provider_error(REAL) + assert c is not None and c.kind == POLICY + + +def test_the_refusal_never_travels_home_as_a_delegated_answer(): + out = neutralize_provider_refusal(REAL) + assert out != REAL + assert "Usage Policy" not in out and "violate" not in out + + +def test_a_real_answer_that_discusses_policy_keeps_every_word(): + # The innocent case: this is what an agent asked to summarise a terms page actually returns. + innocent = ("Their Acceptable Use Policy forbids reverse engineering. Section 3 also bans " + "duplicating model outputs, and accounts may be suspended for it.") + assert neutralize_provider_refusal(innocent) == innocent + assert classify_provider_error(innocent) is None + + +def test_the_refusal_must_OPEN_the_message_to_count(): + # A long real answer that quotes the refusal late is work, not a refusal; eating it would be + # silent work loss, which is worse than the bug this guard exists for. + embedded = ("Here is what I found across the three pages you asked about, with the quotas and " + "the pricing table reproduced below in full detail for each tier of the plan. " + "One page did say it is unable to respond to this request, which appears to " + "violate our Usage Policy, so I skipped it.") + assert not opens_with_provider_refusal(embedded) + assert neutralize_provider_refusal(embedded) == embedded + + +def test_the_model_refusing_on_its_own_is_not_scored_as_a_policy_block(): + # No policy wording at all, so it stays the agent's own speech. + own = "I'm unable to respond to this request because the file you named does not exist." + assert not opens_with_provider_refusal(own) + assert classify_provider_error(own) is None diff --git a/backend/tests/test_overcorrection_audit.py b/backend/tests/test_overcorrection_audit.py index bdc7fc24..d0fc4e7e 100644 --- a/backend/tests/test_overcorrection_audit.py +++ b/backend/tests/test_overcorrection_audit.py @@ -204,3 +204,19 @@ def test_settling_never_stomps_a_live_turn(): body = src[i:i + 800] assert "not p_task.done()" in body, "a live task must veto the settle" assert 'status != "running"' in body, "and an already-terminal session is left alone" + + +# -------------------------------------------- the CLI-prose policy refusal (ENG-411, 2026-08-26) + +def test_a_terms_summary_is_not_mistaken_for_the_filter_refusing(): + """The guard reads bare prose with no envelope, so the innocent case is an agent that was ASKED + about a usage policy. Eating that reply is silent work loss, a worse row than the bug.""" + from backend.apps.agents.core.error_classify import neutralize_provider_refusal + from backend.apps.agents.manager.streaming.provider_error_speech import classify_provider_error + for innocent in ( + "Their Usage Policy bans reverse engineering and duplicating model outputs, per section 3.", + "I'm unable to respond to this request because the file you named does not exist.", + "Summary: the Acceptable Use Policy has four prohibited-use categories, listed below.", + ): + assert neutralize_provider_refusal(innocent) == innocent + assert classify_provider_error(innocent) is None