Skip to content

Commit 1f15282

Browse files
committed
fix(safety): multi-block aggregation must let NEEDS_HUMAN_REVIEW outweigh ALLOW
_scan_code_input's old aggregation only promoted a block to worst when it was DENY, or when it shared the same decision as worst with higher risk. So [safe_python (ALLOW), bash 'sleep 100 &' (NEEDS_HUMAN_REVIEW)] kept worst=ALLOW — a real bypass. Rewrite the comparison to rank by (decision_rank, risk_rank) where DENY > NEEDS_HUMAN_REVIEW > ALLOW, so any review/deny block outweighs an allow block. Add two regression tests requested in review: (1) test_scan_code_input_multi_block_aggregates_safe_plus_review — multi-block [safe_python, 'sleep 100 &'] with block_on_review=True must return NEEDS_HUMAN_REVIEW and _should_block_report=True; (2) test_filter_block_on_review_pure_medium_returns_needs_review — ToolSafetyFilter with block_on_review=True and pure MEDIUM 'sleep 100 &' must return TOOL_SAFETY_NEEDS_REVIEW.
1 parent 65ffdbe commit 1f15282

3 files changed

Lines changed: 56 additions & 5 deletions

File tree

tests/tool_safety/test_tool_filter.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,23 @@ def test_filter_block_on_review(tmp_path: Path):
9090
assert rsp.rsp.get("success") is False
9191

9292

93+
def test_filter_block_on_review_pure_medium_returns_needs_review(tmp_path: Path):
94+
"""block_on_review=True + pure MEDIUM signal must return TOOL_SAFETY_NEEDS_REVIEW.
95+
96+
'sleep 100 &' is a non-network background process → MEDIUM →
97+
NEEDS_HUMAN_REVIEW under the default policy (deny=HIGH, review=MEDIUM).
98+
With block_on_review=True the filter must block and emit the review error
99+
code (not DENY, since the decision is not DENY).
100+
"""
101+
flt = _make_filter(tmp_path, block_on_review=True)
102+
rsp = FilterResult()
103+
req = {"command": "sleep 100 &"}
104+
asyncio.run(flt._before(None, req, rsp)) # pylint: disable=protected-access
105+
assert rsp.is_continue is False
106+
assert rsp.rsp["error"] == "TOOL_SAFETY_NEEDS_REVIEW"
107+
assert rsp.rsp.get("success") is False
108+
109+
93110
def test_filter_extracts_code_blocks(tmp_path: Path):
94111
flt = _make_filter(tmp_path)
95112
rsp = FilterResult()

tests/tool_safety/test_wrapper_language.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,29 @@ def test_blocked_true_when_block_on_review_and_review_decision():
9393
report = SafetyScanner(policy).scan(ScanInput(script="sleep 1 &", language="bash"))
9494
assert report.decision.value == "needs_human_review"
9595
assert report.blocked is True
96+
97+
98+
def test_scan_code_input_multi_block_aggregates_safe_plus_review():
99+
"""Multi-block aggregation: safe Python + MEDIUM bash must yield NEEDS_HUMAN_REVIEW.
100+
101+
With block_on_review=True, _scan_code_input must return the worst report
102+
(needs_human_review from the bash block, not allow from the safe python
103+
block), and _should_block_report must return True.
104+
"""
105+
from trpc_agent_sdk.safety._wrapper import _should_block_report
106+
from trpc_agent_sdk.safety import Decision
107+
108+
policy = PolicyConfig(
109+
deny_risk_level=RiskLevel.CRITICAL,
110+
review_risk_level=RiskLevel.MEDIUM,
111+
block_on_review=True,
112+
)
113+
scanner = SafetyScanner(policy)
114+
inp = _Input(code_blocks=[
115+
_Block("x = 1\nprint(x)", language="python"),
116+
_Block("sleep 100 &", language="bash"),
117+
])
118+
report = _scan_code_input(scanner, inp)
119+
assert report is not None
120+
assert report.decision == Decision.NEEDS_HUMAN_REVIEW
121+
assert _should_block_report(report, block_on_review=True) is True

trpc_agent_sdk/safety/_wrapper.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,14 @@ def _normalize_block_language(raw_lang: str | None, code: str) -> str:
6363

6464
def _scan_code_input(scanner: SafetyScanner, input_data):
6565
"""Scan code / code_blocks with per-block language; return worst report."""
66-
order = {
66+
# Decision severity: DENY > NEEDS_HUMAN_REVIEW > ALLOW. A MEDIUM bash block
67+
# must outweigh a safe ALLOW python block when aggregating multi-block input.
68+
decision_rank = {
69+
Decision.ALLOW: 0,
70+
Decision.NEEDS_HUMAN_REVIEW: 1,
71+
Decision.DENY: 2,
72+
}
73+
risk_rank = {
6774
RiskLevel.NONE: 0,
6875
RiskLevel.LOW: 1,
6976
RiskLevel.MEDIUM: 2,
@@ -91,11 +98,12 @@ def _scan_code_input(scanner: SafetyScanner, input_data):
9198
report = scanner.scan(ScanInput(script=code, language=lang, tool_name="code_executor"))
9299
if worst is None:
93100
worst = report
94-
elif report.decision == Decision.DENY and worst.decision != Decision.DENY:
101+
continue
102+
# Pick the worse of (worst, report) by (decision_rank, risk_rank).
103+
worst_key = (decision_rank.get(worst.decision, 0), risk_rank.get(worst.risk_level, 0))
104+
report_key = (decision_rank.get(report.decision, 0), risk_rank.get(report.risk_level, 0))
105+
if report_key > worst_key:
95106
worst = report
96-
elif report.decision == worst.decision:
97-
if order.get(report.risk_level, 0) > order.get(worst.risk_level, 0):
98-
worst = report
99107
return worst
100108

101109

0 commit comments

Comments
 (0)