Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions agentrace/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<![\w/])(?:\.{1,2}/|/[^\s]+|[\w.-]+/[\w./-]+)(?::\d+)?\b",
r"(?<![\w/])(?:[\w-]+\.)+[A-Za-z][A-Za-z0-9_-]{0,7}\b",
r"(?<![\w/])(?:[\w.-]+/)*[\w-]+\.[A-Za-z][A-Za-z0-9_-]{0,7}:\d+\b",
r"[\"“'](?=[^\"\n“”']{0,240}\b(?:SELECT|INSERT|UPDATE|DELETE\s+FROM|DROP\s+TABLE|pytest|npm\s+test|make\s+test|passed|failed|rows?|exit\s+code|HTTP\s+\d{3})\b)[^\"\n“”']+[\"”']",
]
verdict = None
for pattern in verdict_patterns:
match = re.search(pattern, run.result, re.IGNORECASE)
if match:
verdict = match
break
if not verdict:
return []

preceding = run.result[max(0, verdict.start() - 40) : verdict.start()]
if re.search(r"\b(?:do not|don't|cannot|can't|should not|shouldn't|never|avoid|not)\b", preceding, re.IGNORECASE):
return []
if any(re.search(pattern, run.result, re.IGNORECASE) for pattern in evidence_patterns):
return []

return [
Finding(
"risky_verdict",
"medium",
"Risky operational verdict has no supporting file, code, command, or test evidence.",
_context(run.result, verdict.start()),
)
]


def check_unverified_claim(run: AgentRun) -> list[Finding]:
"""Hedged language presented as a finding.

Expand Down Expand Up @@ -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,
Expand Down
75 changes: 75 additions & 0 deletions tests/test_risky_verdict.py
Original file line number Diff line number Diff line change
@@ -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)
Loading