Skip to content

Commit 50a6ec0

Browse files
Violet2314claude
andcommitted
fix(safety): stop importlib file-load of code_executors (CI ImportError)
SafeCodeExecutor no longer loads code_executors modules via importlib.spec_from_file_location (breaks relative imports / package context). Deny results use a minimal local object with .output/.outcome so CI without docker still runs wrapper tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 68961d5 commit 50a6ec0

2 files changed

Lines changed: 69 additions & 69 deletions

File tree

tests/tool_safety/test_wrapper.py

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -79,36 +79,36 @@ def test_wrap_tool_allows_safe_bash(tmp_path: Path):
7979
assert rsp.is_continue is True
8080

8181

82-
def _try_import_code_executors():
83-
try:
84-
from trpc_agent_sdk.code_executors import CodeExecutionInput
85-
from trpc_agent_sdk.code_executors import create_code_execution_result
86-
return CodeExecutionInput, create_code_execution_result
87-
except Exception: # pylint: disable=broad-except
88-
return None, None
82+
class _SimpleCodeInput:
83+
"""Minimal stand-in for CodeExecutionInput (no code_executors import)."""
84+
85+
def __init__(self, code: str = "", code_blocks=None, language: str = "python"):
86+
self.code = code
87+
self.code_blocks = code_blocks or []
88+
self.language = language
8989

9090

9191
class _FakeInnerExecutor:
9292

93-
def __init__(self, create_fn: Any) -> None:
94-
self._create_fn = create_fn
93+
def __init__(self) -> None:
9594
self.calls: list[Any] = []
9695

9796
async def execute_code(self, invocation_context, input_data):
9897
self.calls.append(input_data)
99-
return self._create_fn(stdout="ok")
10098

99+
class _Ok:
100+
output = "ok"
101+
outcome = type("O", (), {"name": "OUTCOME_OK"})()
102+
103+
return _Ok()
101104

102-
def test_safe_code_executor_blocks_dangerous_python(tmp_path: Path):
103-
CodeExecutionInput, create_fn = _try_import_code_executors()
104-
if CodeExecutionInput is None:
105-
pytest.skip("trpc_agent_sdk.code_executors not importable")
106105

107-
inner = _FakeInnerExecutor(create_fn)
106+
def test_safe_code_executor_blocks_dangerous_python(tmp_path: Path):
107+
inner = _FakeInnerExecutor()
108108
safe = SafeCodeExecutor(inner, _policy(tmp_path), audit_path=str(tmp_path / "audit.jsonl"))
109109

110110
code = "import os\nos.system('rm -rf /')"
111-
inp = CodeExecutionInput(code=code)
111+
inp = _SimpleCodeInput(code=code)
112112
result = asyncio.run(safe.execute_code(None, inp))
113113

114114
assert inner.calls == []
@@ -123,15 +123,11 @@ def test_safe_code_executor_blocks_dangerous_python(tmp_path: Path):
123123

124124

125125
def test_safe_code_executor_allows_safe_python(tmp_path: Path):
126-
CodeExecutionInput, create_fn = _try_import_code_executors()
127-
if CodeExecutionInput is None:
128-
pytest.skip("trpc_agent_sdk.code_executors not importable")
129-
130-
inner = _FakeInnerExecutor(create_fn)
126+
inner = _FakeInnerExecutor()
131127
safe = SafeCodeExecutor(inner, _policy(tmp_path))
132128

133129
code = "print('hello world')"
134-
inp = CodeExecutionInput(code=code)
130+
inp = _SimpleCodeInput(code=code)
135131
result = asyncio.run(safe.execute_code(None, inp))
136132

137133
assert len(inner.calls) == 1

trpc_agent_sdk/safety/_wrapper.py

Lines changed: 51 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88

99
import asyncio
1010
import functools
11+
from typing import Any
1112
from typing import Optional
1213

1314
from ._audit import AuditLogger
1415
from ._filter import ToolSafetyFilter
1516
from ._policy import PolicyConfig
1617
from ._scanner import SafetyScanner
1718
from ._types import Decision
19+
from ._types import RiskLevel
1820
from ._types import ScanInput
1921

2022

@@ -25,22 +27,25 @@ def wrap_tool(tool, policy: PolicyConfig, *, audit_path: Optional[str] = None):
2527
return tool
2628

2729

28-
def _load_create_code_execution_result():
29-
"""Load create_code_execution_result without importing code_executors package.
30+
class _Outcome:
31+
"""Minimal stand-in for trpc_agent_sdk.types.Outcome used in deny results."""
3032

31-
``import trpc_agent_sdk.code_executors`` pulls optional docker deps via
32-
package ``__init__``. Load the leaf module by file path instead.
33-
"""
34-
import importlib.util
35-
from pathlib import Path
33+
def __init__(self, name: str):
34+
self.name = name
35+
36+
37+
class _CodeExecutionResult:
38+
"""Minimal CodeExecutionResult-compatible object (no code_executors import)."""
39+
40+
def __init__(self, *, output: str, outcome_name: str):
41+
self.output = output
42+
self.outcome = _Outcome(outcome_name)
3643

37-
path = Path(__file__).resolve().parents[1] / "code_executors" / "_types.py"
38-
spec = importlib.util.spec_from_file_location("_safety_code_exec_types", path)
39-
if spec is None or spec.loader is None:
40-
raise ImportError(f"cannot load code executor types from {path}")
41-
mod = importlib.util.module_from_spec(spec)
42-
spec.loader.exec_module(mod)
43-
return mod.create_code_execution_result
44+
45+
def _deny_code_result(rule_ids: list[str]) -> _CodeExecutionResult:
46+
"""Build a failed code-execution result without importing code_executors."""
47+
msg = f"Code execution error:\nTOOL_SAFETY_DENY: {rule_ids}\n"
48+
return _CodeExecutionResult(output=msg, outcome_name="OUTCOME_FAILED")
4449

4550

4651
def _normalize_block_language(raw_lang: str | None, code: str) -> str:
@@ -59,8 +64,6 @@ def _normalize_block_language(raw_lang: str | None, code: str) -> str:
5964

6065
def _scan_code_input(scanner: SafetyScanner, input_data, *, block_on_review: bool):
6166
"""Scan code / code_blocks with per-block language; return worst report."""
62-
from ._types import RiskLevel
63-
6467
order = {
6568
RiskLevel.NONE: 0,
6669
RiskLevel.LOW: 1,
@@ -77,7 +80,15 @@ def _scan_code_input(scanner: SafetyScanner, input_data, *, block_on_review: boo
7780
worst = None
7881
for block in blocks:
7982
code = getattr(block, "code", None) or ""
80-
lang = _normalize_block_language(getattr(block, "language", None), code)
83+
declared = (getattr(block, "language", None) or "").strip().lower()
84+
# Mislabeled bash-as-python must still hit bash rules via content check.
85+
if declared in ("sh", "shell", "bash"):
86+
lang = "bash"
87+
elif declared in ("python", ) or "py" in declared:
88+
inferred = _normalize_block_language("", code)
89+
lang = "bash" if inferred == "bash" else "python"
90+
else:
91+
lang = _normalize_block_language(declared, code)
8192
report = scanner.scan(ScanInput(script=code, language=lang, tool_name="code_executor"))
8293
if worst is None:
8394
worst = report
@@ -98,71 +109,64 @@ def _should_block_report(report, block_on_review: bool) -> bool:
98109
class SafetyGuardedCodeExecutor:
99110
"""Code-executor wrapper that scans code before delegating.
100111
101-
Prefer this over the factory-style :func:`safe_code_executor` when you need
102-
an explicit class. Constructed lazily so optional docker deps are only
103-
imported when used.
112+
Does **not** import ``trpc_agent_sdk.code_executors`` (avoids optional docker).
113+
Deny results use a minimal object with ``.output`` / ``.outcome.name``.
104114
"""
105115

106-
def __init__(self, inner, policy: PolicyConfig, *, audit_path: Optional[str] = None, block_on_review: bool = False):
116+
def __init__(
117+
self,
118+
inner,
119+
policy: PolicyConfig,
120+
*,
121+
audit_path: Optional[str] = None,
122+
block_on_review: bool = False,
123+
):
107124
self._inner = inner
108125
self._scanner = SafetyScanner(policy=policy)
109126
self._audit = AuditLogger(audit_path)
110127
self._block_on_review = block_on_review or policy.block_on_review
111128

112129
async def execute_code(self, invocation_context, input_data):
113-
create_code_execution_result = _load_create_code_execution_result()
114-
115130
report = _scan_code_input(self._scanner, input_data, block_on_review=self._block_on_review)
116131
should_block = _should_block_report(report, self._block_on_review)
117132
if report is not None:
118133
self._audit.log(report, intercepted=should_block)
119134
if should_block and report is not None:
120-
return create_code_execution_result(stderr=f"TOOL_SAFETY_DENY: {report.rule_ids}")
135+
return _deny_code_result(report.rule_ids)
121136
return await self._inner.execute_code(invocation_context, input_data)
122137

123138

124-
def safe_code_executor(inner, policy: PolicyConfig, *, audit_path: Optional[str] = None, block_on_review: bool = False):
139+
def safe_code_executor(
140+
inner,
141+
policy: PolicyConfig,
142+
*,
143+
audit_path: Optional[str] = None,
144+
block_on_review: bool = False,
145+
):
125146
"""Create a code-executor wrapper that scans code before delegating.
126147
127-
Returns an instance of a dynamically built ``BaseCodeExecutor`` subclass so
128-
it remains type-compatible with the executor hierarchy.
148+
Returns a simple object with ``execute_code`` (does not subclass
149+
BaseCodeExecutor, to avoid importing optional code_executors deps).
129150
"""
130-
import importlib.util
131-
from pathlib import Path
132-
133-
base_path = Path(__file__).resolve().parents[1] / "code_executors" / "_base_code_executor.py"
134-
types_path = Path(__file__).resolve().parents[1] / "code_executors" / "_types.py"
135-
base_spec = importlib.util.spec_from_file_location("_safety_base_code_executor", base_path)
136-
types_spec = importlib.util.spec_from_file_location("_safety_code_exec_types", types_path)
137-
if base_spec is None or base_spec.loader is None or types_spec is None or types_spec.loader is None:
138-
raise ImportError("cannot load code executor helpers without package init")
139-
base_mod = importlib.util.module_from_spec(base_spec)
140-
types_mod = importlib.util.module_from_spec(types_spec)
141-
base_spec.loader.exec_module(base_mod)
142-
types_spec.loader.exec_module(types_mod)
143-
BaseCodeExecutor = base_mod.BaseCodeExecutor
144-
create_code_execution_result = types_mod.create_code_execution_result
145-
146151
scanner = SafetyScanner(policy=policy)
147152
audit = AuditLogger(audit_path)
148153
block_review = block_on_review or policy.block_on_review
149154

150-
class _SafeCodeExecutor(BaseCodeExecutor):
155+
class _SafeCodeExecutor:
151156

152157
async def execute_code(self, invocation_context, input_data):
153158
report = _scan_code_input(scanner, input_data, block_on_review=block_review)
154159
should_block = _should_block_report(report, block_review)
155160
if report is not None:
156161
audit.log(report, intercepted=should_block)
157162
if should_block and report is not None:
158-
return create_code_execution_result(stderr=f"TOOL_SAFETY_DENY: {report.rule_ids}")
163+
return _deny_code_result(report.rule_ids)
159164
return await inner.execute_code(invocation_context, input_data)
160165

161166
return _SafeCodeExecutor()
162167

163168

164-
# Backwards-compatible factory alias (returns a BaseCodeExecutor instance).
165-
# Prefer SafetyGuardedCodeExecutor for an explicit class, or safe_code_executor().
169+
# Backwards-compatible factory alias (returns an object with execute_code).
166170
SafeCodeExecutor = safe_code_executor
167171

168172

0 commit comments

Comments
 (0)