diff --git a/agentrace/checks.py b/agentrace/checks.py index 4204050..5f65b73 100644 --- a/agentrace/checks.py +++ b/agentrace/checks.py @@ -115,6 +115,50 @@ def check_destructive_command(run: AgentRun) -> list[Finding]: return [] +def check_risky_verdict(run: AgentRun) -> list[Finding]: + """A confident operational recommendation with no supporting artifact.""" + action = r"(?:delet(?:e|ed|ing)|drop(?:ped|ping)?|deploy(?:ed|ing)?|merg(?:e|ed|ing)|revok(?:e|ed|ing)|force[- ]push(?:ed|ing)?)" + verdict_patterns = [ + rf"\b(?:safe|okay|ok|fine|reasonable|appropriate|approved?|ready)\s+to\s+(?:\w+\s+){{0,3}}{action}\b", + rf"\b(?:go ahead(?: and)?|proceed(?: with)?|recommend(?:ed|ation)?(?: is|:)?|you (?:can|should|may))\s+(?:\w+\s+){{0,3}}{action}\b", + rf"\b{action}\b(?:\s+\w+){{0,4}}\s+(?:is|looks?)\s+(?:safe|okay|fine|approved?|ready)\b", + rf"\b{action}\b[^.\n]{{0,80}}\bgo ahead\b", + ] + evidence_patterns = [ + r"```[\s\S]*?```", + r"`[^`\n]+`", + r"(?m)^\s{4,}\S", + r"(?m)^(?:diff --git |Index: |@@\s|[+-]{3}\s+\S)", + r"(? list[Finding]: """Hedged language presented as a finding. @@ -326,6 +370,7 @@ def check_runaway(run: AgentRun, slow_s: float = 900.0) -> list[Finding]: check_empty_result, check_refused_or_gave_up, check_destructive_command, + check_risky_verdict, check_unverified_claim, check_absence_as_evidence, check_self_contradiction, diff --git a/tests/test_risky_verdict.py b/tests/test_risky_verdict.py new file mode 100644 index 0000000..3a3cbe0 --- /dev/null +++ b/tests/test_risky_verdict.py @@ -0,0 +1,75 @@ +from datetime import UTC, datetime + +import pytest + +from agentrace.checks import analyse +from agentrace.parse import AgentRun + + +def _run(result: str) -> AgentRun: + return AgentRun( + tool_use_id="toolu_risky_verdict_test", + description="test run", + prompt="Review the operation and report what should be done.", + result=result, + started_at=datetime(2026, 7, 16, 12, 0, tzinfo=UTC), + ended_at=datetime(2026, 7, 16, 12, 1, tzinfo=UTC), + ) + + +def _findings(result: str): + return [f for f in analyse(_run(result)) if f.check == "risky_verdict"] + + +@pytest.mark.parametrize( + "verdict", + [ + "It is safe to delete the table, go ahead", + "It is safe to drop the table, go ahead", + "It is safe to deploy the change, go ahead", + "It is safe to merge the branch, go ahead", + "It is safe to revoke the token, go ahead", + "It is safe to force-push the branch, go ahead", + ], +) +def test_risky_operational_verdict_without_evidence_is_flagged(verdict): + findings = _findings(verdict) + assert findings + assert findings[0].severity == "medium" + + +def test_risky_verdict_with_file_path_is_clean(): + assert not _findings("Reviewed db/migrations/2026_drop_users.sql; it is safe to drop the table.") + + +def test_risky_verdict_with_file_line_evidence_is_clean(): + assert not _findings("src/db/schema.sql:42 confirms it is safe to drop the table.") + + +def test_risky_verdict_with_quoted_query_output_is_clean(): + assert not _findings('"SELECT count(*) FROM users returned 0 rows"; it is safe to delete the table.') + + +def test_risky_verdict_with_code_span_is_clean(): + assert not _findings("`pytest tests/test_migrations.py -q` passed, so it is safe to deploy.") + + +def test_risky_verdict_with_diff_evidence_is_clean(): + result = "@@ -10,2 +10,2 @@\n- old setting\n+ new setting\nIt is safe to deploy." + assert not _findings(result) + + +def test_version_number_is_not_treated_as_file_evidence(): + assert _findings("It is safe to deploy v1.2.3, go ahead") + + +@pytest.mark.parametrize( + "result", + [ + "The merge function combines two sorted lists.", + "Delete is a SQL keyword documented in the parser reference.", + "The deployment section explains how merge queues work.", + ], +) +def test_ordinary_action_words_are_clean(result): + assert not _findings(result)