Skip to content

Commit 35750b8

Browse files
committed
fix(safety): apply same multi-block aggregation fix to UnsafeLocalCodeExecutor
AI Code Review critical pointed out TWO copies of the worst-report aggregation logic: _wrapper._scan_code_input (fixed in 1f15282) AND _unsafe_local_code_executor.py:130-149 (still had the bug). The executor copy also only promoted a block to worst on DENY or same-decision-higher-risk, so [safe_python (ALLOW), bash 'sleep 100 &' (NEEDS_HUMAN_REVIEW)] kept worst=ALLOW — same fail-open bypass. Apply the same (decision_rank, risk_rank) tuple comparison here. Also fix AI Code Review suggestion: executor returned stderr='TOOL_SAFETY_DENY: ...' for BOTH deny and review-blocks, inconsistent with ToolSafetyFilter which distinguishes TOOL_SAFETY_DENY vs TOOL_SAFETY_NEEDS_REVIEW. Now picks the label based on report.decision so audits/logs don't mislabel review-blocks as deny. Also document in safety_wrapper docstring that only keyword args are scanned (AI Code Review suggestion: positional args bypass _extract_script; documenting this is the lightweight fix since signature inspection is brittle for variadic callables). Add two regression tests (run on CI where docker is installed): test_unsafe_local_code_executor_multi_block_aggregates_review and test_unsafe_local_code_executor_review_label_differs_from_deny.
1 parent 1f15282 commit 35750b8

3 files changed

Lines changed: 70 additions & 6 deletions

File tree

tests/tool_safety/test_opt_in.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,43 @@ def test_unsafe_local_code_executor_safety_fields():
151151
assert ex._safety_scanner is not None
152152
result = asyncio.run(ex.execute_code(None, CodeExecutionInput(code="import os\nos.system('id')")))
153153
assert "TOOL_SAFETY_DENY" in result.output
154+
155+
156+
def test_unsafe_local_code_executor_multi_block_aggregates_review():
157+
"""Regression for AI Code Review critical: multi-block [safe_python, bash
158+
'sleep 100 &'] must NOT keep worst=ALLOW. With block_on_review=True the
159+
executor must block and emit TOOL_SAFETY_NEEDS_REVIEW."""
160+
try:
161+
from trpc_agent_sdk.code_executors.local._unsafe_local_code_executor import (
162+
UnsafeLocalCodeExecutor, )
163+
from trpc_agent_sdk.code_executors import CodeExecutionInput
164+
from trpc_agent_sdk.code_executors import CodeBlock
165+
except Exception as ex: # pylint: disable=broad-except
166+
pytest.skip(f"code executor not importable: {ex}")
167+
168+
ex = UnsafeLocalCodeExecutor(enable_safety_guard=True, safety_block_on_review=True)
169+
inp = CodeExecutionInput(code_blocks=[
170+
CodeBlock(language="python", code="x = 1\nprint(x)"),
171+
CodeBlock(language="bash", code="sleep 100 &"),
172+
])
173+
result = asyncio.run(ex.execute_code(None, inp))
174+
# MUST be blocked (not run) and labeled as NEEDS_REVIEW, not DENY.
175+
assert "TOOL_SAFETY_NEEDS_REVIEW" in result.output
176+
assert "TOOL_SAFETY_DENY" not in result.output
177+
178+
179+
def test_unsafe_local_code_executor_review_label_differs_from_deny():
180+
"""When block_on_review=True and decision is NEEDS_HUMAN_REVIEW, stderr
181+
must say TOOL_SAFETY_NEEDS_REVIEW, not TOOL_SAFETY_DENY (consistency with
182+
ToolSafetyFilter). Single-block 'sleep 100 &' triggers MEDIUM/review."""
183+
try:
184+
from trpc_agent_sdk.code_executors.local._unsafe_local_code_executor import (
185+
UnsafeLocalCodeExecutor, )
186+
from trpc_agent_sdk.code_executors import CodeExecutionInput
187+
except Exception as ex: # pylint: disable=broad-except
188+
pytest.skip(f"code executor not importable: {ex}")
189+
190+
ex = UnsafeLocalCodeExecutor(enable_safety_guard=True, safety_block_on_review=True)
191+
result = asyncio.run(ex.execute_code(None, CodeExecutionInput(code="sleep 100 &")))
192+
assert "TOOL_SAFETY_NEEDS_REVIEW" in result.output
193+
assert "TOOL_SAFETY_DENY" not in result.output

trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,14 @@ async def execute_code(self, invocation_context: InvocationContext,
121121
RiskLevel.HIGH: 3,
122122
RiskLevel.CRITICAL: 4,
123123
}
124+
# Decision severity: DENY > NEEDS_HUMAN_REVIEW > ALLOW. A MEDIUM bash
125+
# block must outweigh a safe ALLOW python block when aggregating
126+
# multi-block input (matches _wrapper._scan_code_input).
127+
_DECISION_RANK = {
128+
Decision.ALLOW: 0,
129+
Decision.NEEDS_HUMAN_REVIEW: 1,
130+
Decision.DENY: 2,
131+
}
124132
code_blocks = list(input_data.code_blocks or [])
125133
if not code_blocks and input_data.code:
126134
# Prefer content inference over hardcoding python.
@@ -142,11 +150,13 @@ async def execute_code(self, invocation_context: InvocationContext,
142150
report = self._safety_scanner.scan(ScanInput(script=code, language=lang, tool_name="code_executor"))
143151
if worst is None:
144152
worst = report
145-
elif report.decision == Decision.DENY and worst.decision != Decision.DENY:
153+
continue
154+
# Pick the worse of (worst, report) by (decision_rank, risk_rank)
155+
# so NEEDS_HUMAN_REVIEW outweighs ALLOW even when risks differ.
156+
worst_key = (_DECISION_RANK.get(worst.decision, 0), _ORDER.get(worst.risk_level, 0))
157+
report_key = (_DECISION_RANK.get(report.decision, 0), _ORDER.get(report.risk_level, 0))
158+
if report_key > worst_key:
146159
worst = report
147-
elif report.decision == worst.decision:
148-
if _ORDER.get(report.risk_level, 0) > _ORDER.get(worst.risk_level, 0):
149-
worst = report
150160

151161
report = worst
152162
should_block = False
@@ -156,7 +166,11 @@ async def execute_code(self, invocation_context: InvocationContext,
156166
if self._safety_audit is not None:
157167
self._safety_audit.log(report, intercepted=should_block)
158168
if should_block and report is not None:
159-
return create_code_execution_result(stderr=f"TOOL_SAFETY_DENY: {report.rule_ids}")
169+
# Distinguish DENY vs NEEDS_HUMAN_REVIEW in stderr so audits and
170+
# logs don't mislabel a review-block as a deny (matches
171+
# ToolSafetyFilter's error_code selection).
172+
code_label = ("TOOL_SAFETY_DENY" if report.decision == Decision.DENY else "TOOL_SAFETY_NEEDS_REVIEW")
173+
return create_code_execution_result(stderr=f"{code_label}: {report.rule_ids}")
160174

161175
output_parts = []
162176
error_parts = []

trpc_agent_sdk/safety/_wrapper.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,17 @@ def safety_wrapper(
198198
audit_path=None,
199199
raise_on_deny=True,
200200
):
201-
"""Decorator: scan the *script_arg* of a function before it runs."""
201+
"""Decorator: scan the *script_arg* of a function before it runs.
202+
203+
.. note::
204+
Only keyword arguments are scanned. Positional arguments are not
205+
mapped to *script_arg* (doing so reliably would require inspecting
206+
the wrapped function's signature, which is brittle for *args/**kwargs
207+
variadic callables). Callers MUST pass the script as a keyword
208+
argument, e.g. ``run(script="rm -rf /")`` rather than
209+
``run("rm -rf /")``. A positional argument that is itself a dict
210+
containing *script_arg* is also accepted as a legacy convenience.
211+
"""
202212
if policy is None:
203213
policy = PolicyConfig()
204214
_scanner = SafetyScanner(policy=policy)

0 commit comments

Comments
 (0)