Skip to content

Commit 0761054

Browse files
committed
fix(safety): non-blocking review must not flip OUTCOME_FAILED; lazy get_tool_var
Warning: - _unsafe_local_code_executor.execute_code: non-blocking NEEDS_HUMAN_REVIEW previously appended the warning to error_parts, which became stderr, which made create_code_execution_result set outcome=OUTCOME_FAILED. _code_execution_processor then treated it as an execution error and retried the already-allowed code up to error_retry_attempts (default 2) times. This also was inconsistent with ToolSafetyFilter._after (which keeps success=True and only merges safety_warning). Now the warning goes through the SDK logger (matching _filter._before), stderr stays empty, outcome stays OUTCOME_OK, and the agent does not retry. The audit log still records the NEEDS_HUMAN_REVIEW decision. Removed the now-unused _pending_review_warning variable. Suggestion: - _filter.ToolSafetyFilter.__init__: the comment claimed "never pull trpc_agent_sdk.tools package", but `from trpc_agent_sdk.tools. _context_var import get_tool_var` actually executes trpc_agent_sdk/tools/__init__.py, which imports mcp_tool / file_tools / webfetch_tool and their optional deps (mcp, anthropic). Replaced the eager import with a sys.modules lookup: we only attach get_tool_var when the host application has already loaded trpc_agent_sdk.tools._context_var, so the safety package never triggers the heavy tools/__init__.py itself. When tools are loaded, we reuse the exact same ContextVar instance so tool names resolve correctly. Tests: - test_unsafe_local_code_executor: add TestUnsafeLocalCodeExecutorSafetyGuardNonBlocking with test_nonblocking_review_keeps_outcome_ok — asserts that enable_safety_guard=True + block_on_review=False + NEEDS_HUMAN_REVIEW script keeps outcome=OUTCOME_OK and executes exactly once (no retry). - Clean up two pre-existing unused imports (AsyncMock, create_code_execution_result) in the same test file so flake8 stays clean on changed files.
1 parent 5f9e7b1 commit 0761054

3 files changed

Lines changed: 72 additions & 25 deletions

File tree

tests/code_executors/local/test_unsafe_local_code_executor.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@
88
import shutil
99
import tempfile
1010
from pathlib import Path
11-
from unittest.mock import AsyncMock
1211
from unittest.mock import Mock
1312
from unittest.mock import patch
1413

1514
import pytest
1615
from trpc_agent_sdk.code_executors._types import CodeBlock
1716
from trpc_agent_sdk.code_executors._types import CodeBlockDelimiter
1817
from trpc_agent_sdk.code_executors._types import CodeExecutionInput
19-
from trpc_agent_sdk.code_executors._types import create_code_execution_result
2018
from trpc_agent_sdk.code_executors.local import UnsafeLocalCodeExecutor
2119
from trpc_agent_sdk.context import InvocationContext
2220
from trpc_agent_sdk.types import Outcome
@@ -439,3 +437,49 @@ async def test_error_with_empty_stderr(self, mock_exec):
439437
inp = CodeExecutionInput(code_blocks=[CodeBlock(language="python", code="bad")])
440438
result = await executor.execute_code(self.mock_ctx, inp)
441439
assert result.outcome == Outcome.OUTCOME_FAILED
440+
441+
442+
class TestUnsafeLocalCodeExecutorSafetyGuardNonBlocking:
443+
"""Tests for non-blocking NEEDS_HUMAN_REVIEW path in safety guard.
444+
445+
Regression for CongkeChen review: previously the review warning was
446+
appended to stderr, which made create_code_execution_result set
447+
outcome=OUTCOME_FAILED, triggering _code_execution_processor retries
448+
of already-allowed code. Now the warning goes through logging and
449+
outcome stays OUTCOME_OK.
450+
"""
451+
452+
def setup_method(self):
453+
self.tmp_dir = tempfile.mkdtemp(prefix="safety_nonblocking_")
454+
self.mock_ctx = Mock(spec=InvocationContext)
455+
456+
def teardown_method(self):
457+
shutil.rmtree(self.tmp_dir, ignore_errors=True)
458+
459+
@patch('trpc_agent_sdk.code_executors.local._unsafe_local_code_executor.async_execute_command')
460+
@pytest.mark.asyncio
461+
async def test_nonblocking_review_keeps_outcome_ok(self, mock_exec):
462+
"""enable_safety_guard=True, block_on_review=False + NEEDS_HUMAN_REVIEW
463+
script must keep outcome=OUTCOME_OK so the agent does not retry.
464+
465+
'sleep 100 &' is a non-network background process → MEDIUM →
466+
NEEDS_HUMAN_REVIEW under default policy. With block_on_review=False
467+
the code is allowed to run; the warning must NOT leak into stderr
468+
(which would flip outcome to OUTCOME_FAILED).
469+
"""
470+
mock_exec.return_value = CommandExecResult(stdout="done", stderr="", exit_code=0, is_timeout=False)
471+
audit_path = os.path.join(self.tmp_dir, "audit.jsonl")
472+
executor = UnsafeLocalCodeExecutor(
473+
enable_safety_guard=True,
474+
safety_audit_path=audit_path,
475+
safety_block_on_review=False,
476+
)
477+
inp = CodeExecutionInput(code_blocks=[CodeBlock(language="bash", code="sleep 100 &")])
478+
result = await executor.execute_code(self.mock_ctx, inp)
479+
# outcome must remain OK so _code_execution_processor does not retry.
480+
assert result.outcome == Outcome.OUTCOME_OK, (
481+
f"non-blocking review must not flip outcome to FAILED (would trigger retry); got {result.outcome}")
482+
# The code should have executed exactly once (no retry).
483+
assert mock_exec.call_count == 1
484+
# Audit log records the NEEDS_HUMAN_REVIEW decision.
485+
assert os.path.exists(audit_path)

trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,6 @@ async def execute_code(self, invocation_context: InvocationContext,
105105
Returns:
106106
CodeExecutionResult with combined output
107107
"""
108-
# Initialize at the top so the variable always has a defined value
109-
# regardless of which branch (scanner on/off, report None/not) runs.
110-
# Previously it was only assigned inside the `if self._safety_scanner`
111-
# branch and the `else`, making later `if _pending_review_warning`
112-
# depend on branch ordering — fragile for refactors.
113-
_pending_review_warning = None
114108
if self._safety_scanner is not None:
115109
from trpc_agent_sdk.safety import Decision
116110
from trpc_agent_sdk.safety._wrapper import _scan_code_input
@@ -139,15 +133,20 @@ async def execute_code(self, invocation_context: InvocationContext,
139133
# from "real execution error" so the agent does not retry.
140134
code_label = ("TOOL_SAFETY_DENY" if report.decision == Decision.DENY else "TOOL_SAFETY_NEEDS_REVIEW")
141135
return create_code_execution_result(stderr=f"{code_label}: {report.rule_ids}")
142-
# Non-blocking NEEDS_HUMAN_REVIEW: do NOT block execution, but
143-
# surface the warning so the caller is not silently unaware
144-
# (matches _filter._after's merge semantics). Stash the warning
145-
# and prepend it to stderr after execution completes below.
146-
# Use logging (not sys.stderr.write) to avoid polluting captured
147-
# stderr streams in CI / subprocess stdout/stderr separation.
136+
# Non-blocking NEEDS_HUMAN_REVIEW: do NOT block execution. Surface
137+
# the warning via the SDK logger (matches ToolSafetyFilter._before)
138+
# instead of appending to stderr. Writing to stderr would make
139+
# create_code_execution_result set outcome=OUTCOME_FAILED, which
140+
# _code_execution_processor treats as an execution error and
141+
# retries via error_retry_attempts — re-running already-allowed
142+
# code. Logging keeps outcome=OUTCOME_OK so the agent does not
143+
# retry, while operators still see the warning and the audit log
144+
# records the decision.
148145
if (report is not None and report.decision == Decision.NEEDS_HUMAN_REVIEW and not should_block):
149-
_pending_review_warning = (f"TOOL_SAFETY_NEEDS_REVIEW: {list(report.rule_ids)} "
150-
f"(risk={report.risk_level.value})")
146+
import logging
147+
logging.getLogger("trpc_agent_sdk.safety").warning(
148+
"TOOL_SAFETY_NEEDS_REVIEW: %s (risk=%s)",
149+
list(report.rule_ids), report.risk_level.value)
151150

152151
output_parts = []
153152
error_parts = []
@@ -172,8 +171,6 @@ async def execute_code(self, invocation_context: InvocationContext,
172171
if should_cleanup:
173172
shutil.rmtree(work_dir, ignore_errors=True)
174173

175-
if _pending_review_warning:
176-
error_parts.append(_pending_review_warning)
177174
return create_code_execution_result(stdout="\n".join(output_parts) if output_parts else "",
178175
stderr="\n".join(error_parts) if error_parts else "")
179176

trpc_agent_sdk/safety/_filter.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,20 @@ def __init__(
6666
self.audit = AuditLogger(audit_path)
6767
self._configured_tool_name = tool_name
6868
self._block_on_review = (policy.block_on_review if block_on_review is None else block_on_review)
69-
# Prefer lightweight context-var import; never pull trpc_agent_sdk.tools
70-
# package (heavy optional deps like anthropic) on every scan.
69+
# Resolve the tool name from the async context var set by the SDK tool
70+
# runner, so audit records show "Bash" instead of the generic configured
71+
# name. We only attach get_tool_var when the host application has
72+
# already imported trpc_agent_sdk.tools._context_var — importing it
73+
# ourselves would execute trpc_agent_sdk/tools/__init__.py, which pulls
74+
# mcp_tool / file_tools / webfetch_tool and their optional deps (mcp,
75+
# anthropic, ...). Checking sys.modules keeps the safety package
76+
# lightweight when tools are not in use; when they are, we reuse the
77+
# exact same ContextVar instance so names resolve correctly.
78+
import sys
7179
self._get_tool_var = None
72-
try:
73-
from trpc_agent_sdk.tools._context_var import get_tool_var
74-
self._get_tool_var = get_tool_var
75-
except Exception: # pylint: disable=broad-except
76-
self._get_tool_var = None
80+
ctx_mod = sys.modules.get("trpc_agent_sdk.tools._context_var")
81+
if ctx_mod is not None:
82+
self._get_tool_var = getattr(ctx_mod, "get_tool_var", None)
7783

7884
async def _before(self, ctx: Any, req: Any, rsp: FilterResult) -> None:
7985
"""Scan the tool args; block execution when decision is DENY."""

0 commit comments

Comments
 (0)