Skip to content

Commit d231f71

Browse files
committed
fix(safety): address CongkeChen review - real CodeExecutionResult, fail-closed allowlist, dict blocks, logging
CI fix: - _rules._matches_forbidden: yapf wanted the multi-or condition on one line (it fits in 120 chars). Reformatted to match yapf output. Critical: - _wrapper._deny_code_result: replace custom _Outcome/_CodeExecutionResult stub objects with real trpc_agent_sdk.types.CodeExecutionResult + Outcome.OUTCOME_FAILED. The stub's _Outcome only had .name, so downstream pipeline code (Part.from_code_execution_result, _openai_model) that reads .outcome.value would AttributeError. trpc_agent_sdk.types pulls google.genai.types (always installed with SDK core), NOT docker, so the import stays dep-light. A fallback stub is kept only for the impossible case where the SDK core itself is broken, and logs an error so the misconfiguration is diagnosable. Warning: - _rules.ProcessRule._check_bash: strict_command_allowlist=True with empty allowed_commands used to skip the whole allow-list check (fail-open), letting rm/chmod slip through. Now every non-builtin command is flagged HIGH (fail-closed), matching the semantic of "strict allow-list". - _wrapper._scan_code_input: support dict-form code_blocks (with "code"/ "language" keys) in addition to object-form. Previously getattr on a dict returned None/empty, silently skipping real code in dict blocks. - _unsafe_local_code_executor.execute_code: move _pending_review_warning initialization to the top of the function so it always has a defined value regardless of which branch runs. Previously it was only assigned inside the if/else branches, making later `if _pending_review_warning` depend on branch ordering (fragile for refactors). - _filter._before + _unsafe_local_code_executor: replace sys.stderr.write with logging module (_logger.warning). Direct stderr writes pollute captured stderr streams in CI / subprocess stdout-stderr separation, and per-call stderr writes could be noisy. The audit log already records the decision; the log line is the in-band fallback for operators. Tests: - test_wrapper: assert against real Outcome.OUTCOME_FAILED / OUTCOME_OK (with .value access) so the test fails if a stub object is ever reintroduced. _FakeInnerExecutor now returns a real CodeExecutionResult too. - test_rules: add test_strict_command_allowlist_empty_list_is_fail_closed to lock in the fail-closed behavior for empty allowed_commands.
1 parent fbead7b commit d231f71

6 files changed

Lines changed: 136 additions & 47 deletions

File tree

tests/tool_safety/test_rules.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,3 +264,18 @@ def test_dangerous_files_system_dir_boundary_not_substring():
264264
findings = rule.check(inp, _policy())
265265
# No system-dir finding should fire for /etcetera.
266266
assert not any("system" in f.metadata.get("message", "").lower() for f in findings)
267+
268+
269+
def test_strict_command_allowlist_empty_list_is_fail_closed():
270+
"""Regression for CongkeChen review: strict_command_allowlist=True with
271+
empty allowed_commands used to skip the whole allow-list check (fail-open),
272+
letting rm/chmod through. Now every non-builtin command is flagged HIGH."""
273+
from trpc_agent_sdk.safety._rules import ProcessRule
274+
# Use ProcessRule's _check_bash path (DangerousFilesRule also runs but
275+
# ProcessRule carries the allow-list enforcement).
276+
rule = ProcessRule()
277+
policy = PolicyConfig(strict_command_allowlist=True, allowed_commands=[])
278+
inp = ScanInput(script="rm /tmp/x\n", language="bash")
279+
findings = rule.check(inp, policy)
280+
allow_list_findings = [f for f in findings if "allow-list" in f.metadata.get("message", "")]
281+
assert allow_list_findings, "strict_command_allowlist=True with empty allowed_commands must flag rm (fail-closed)"

tests/tool_safety/test_wrapper.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@
2626
except Exception: # pylint: disable=broad-except
2727
FilterResult = None # type: ignore[assignment]
2828

29+
try:
30+
from trpc_agent_sdk.types import Outcome
31+
_REAL_OUTCOME_AVAILABLE = True
32+
except Exception: # pylint: disable=broad-except
33+
Outcome = None # type: ignore[assignment]
34+
_REAL_OUTCOME_AVAILABLE = False
35+
2936
pytestmark = pytest.mark.skipif(
3037
not _SDK_AVAILABLE or FilterResult is None,
3138
reason="tRPC-Agent SDK core (abc.FilterResult) not importable",
@@ -95,12 +102,12 @@ def __init__(self) -> None:
95102

96103
async def execute_code(self, invocation_context, input_data):
97104
self.calls.append(input_data)
98-
99-
class _Ok:
100-
output = "ok"
101-
outcome = type("O", (), {"name": "OUTCOME_OK"})()
102-
103-
return _Ok()
105+
# Return a real CodeExecutionResult so the test exercises the same
106+
# types downstream pipeline code (Part.from_code_execution_result,
107+
# _openai_model) will see, instead of a stub that hides attr errors.
108+
from trpc_agent_sdk.types import CodeExecutionResult
109+
from trpc_agent_sdk.types import Outcome
110+
return CodeExecutionResult(outcome=Outcome.OUTCOME_OK, output="ok")
104111

105112

106113
def test_safe_code_executor_blocks_dangerous_python(tmp_path: Path):
@@ -113,7 +120,14 @@ def test_safe_code_executor_blocks_dangerous_python(tmp_path: Path):
113120

114121
assert inner.calls == []
115122
assert "TOOL_SAFETY_DENY" in result.output
116-
assert result.outcome.name == "OUTCOME_FAILED"
123+
# Real Outcome enum: .value / .name both available. Assert against the
124+
# enum member directly so the test fails if a stub object is ever
125+
# reintroduced (stubs lack .value and would fail equality).
126+
if _REAL_OUTCOME_AVAILABLE:
127+
assert result.outcome == Outcome.OUTCOME_FAILED
128+
assert result.outcome.value is not None # stub would crash here
129+
else: # pragma: no cover - fallback when SDK types unavailable
130+
assert result.outcome.name == "OUTCOME_FAILED"
117131

118132
audit_path = tmp_path / "audit.jsonl"
119133
assert audit_path.exists()
@@ -132,7 +146,10 @@ def test_safe_code_executor_allows_safe_python(tmp_path: Path):
132146

133147
assert len(inner.calls) == 1
134148
assert "ok" in result.output
135-
assert result.outcome.name == "OUTCOME_OK"
149+
if _REAL_OUTCOME_AVAILABLE:
150+
assert result.outcome == Outcome.OUTCOME_OK
151+
else: # pragma: no cover - fallback when SDK types unavailable
152+
assert result.outcome.name == "OUTCOME_OK"
136153

137154

138155
def test_safety_wrapper_blocks_dangerous_script(tmp_path: Path):

trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ 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
108114
if self._safety_scanner is not None:
109115
from trpc_agent_sdk.safety import Decision
110116
from trpc_agent_sdk.safety._wrapper import _scan_code_input
@@ -125,14 +131,13 @@ async def execute_code(self, invocation_context: InvocationContext,
125131
return create_code_execution_result(stderr=f"{code_label}: {report.rule_ids}")
126132
# Non-blocking NEEDS_HUMAN_REVIEW: do NOT block execution, but
127133
# surface the warning so the caller is not silently unaware
128-
# (matches _filter._after's merge semantics). We stash the warning
134+
# (matches _filter._after's merge semantics). Stash the warning
129135
# and prepend it to stderr after execution completes below.
130-
_pending_review_warning = None
136+
# Use logging (not sys.stderr.write) to avoid polluting captured
137+
# stderr streams in CI / subprocess stdout/stderr separation.
131138
if (report is not None and report.decision == Decision.NEEDS_HUMAN_REVIEW and not should_block):
132139
_pending_review_warning = (f"TOOL_SAFETY_NEEDS_REVIEW: {list(report.rule_ids)} "
133140
f"(risk={report.risk_level.value})")
134-
else:
135-
_pending_review_warning = None
136141

137142
output_parts = []
138143
error_parts = []

trpc_agent_sdk/safety/_filter.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from __future__ import annotations
88

99
import contextvars
10-
import sys
10+
import logging
1111
from typing import Any
1212
from typing import Optional
1313

@@ -21,6 +21,8 @@
2121
from ._types import Decision
2222
from ._types import ScanInput
2323

24+
_logger = logging.getLogger("trpc_agent_sdk.safety")
25+
2426
_SCRIPT_ARG_KEYS = ("command", "script", "code", "cmd", "bash", "shell_command")
2527
_LANGUAGE_ARG_KEYS = ("language", "lang")
2628
_WORKDIR_ARG_KEYS = ("cwd", "workdir", "working_dir")
@@ -130,14 +132,15 @@ async def _before(self, ctx: Any, req: Any, rsp: FilterResult) -> None:
130132
# BaseFilter.run's handle() step overwrites result.rsp with the
131133
# tool's return value.
132134
#
133-
# Belt-and-suspenders: also write to stderr so that if _after is
134-
# skipped (handle() raised, earlier filter set is_continue=False,
135-
# or the tool returned a non-dict that _after cannot annotate),
136-
# the warning is still surfaced to the caller instead of being
137-
# silently lost. The audit log already records the decision; this
138-
# stderr line is the in-band fallback for human operators.
139-
sys.stderr.write(f"TOOL_SAFETY_NEEDS_REVIEW: {list(report.rule_ids)} "
140-
f"(risk={report.risk_level.value})\n")
135+
# Belt-and-suspenders: also log via the SDK logger so that if
136+
# _after is skipped (handle() raised, earlier filter set
137+
# is_continue=False, or the tool returned a non-dict that _after
138+
# cannot annotate), the warning is still surfaced to operators
139+
# instead of being silently lost. The audit log already records
140+
# the decision; this log line is the in-band fallback. Using the
141+
# logging module (not sys.stderr.write) avoids polluting captured
142+
# stderr streams in CI / subprocess scenarios.
143+
_logger.warning("TOOL_SAFETY_NEEDS_REVIEW: %s (risk=%s)", list(report.rule_ids), report.risk_level.value)
141144
_REVIEW_REPORT.set(report)
142145

143146
async def _after(self, ctx: Any, req: Any, rsp: FilterResult) -> None:

trpc_agent_sdk/safety/_rules.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,7 @@ def _matches_forbidden(target: str, policy: PolicyConfig) -> bool:
153153
fb_norm = fb.replace("\\", "/")
154154
if not fb_norm:
155155
continue
156-
if (tgt_norm == fb_norm
157-
or tgt_norm.startswith(fb_norm + "/")
158-
or tgt_norm.endswith("/" + fb_norm)
156+
if (tgt_norm == fb_norm or tgt_norm.startswith(fb_norm + "/") or tgt_norm.endswith("/" + fb_norm)
159157
or ("/" not in tgt_norm and tgt_norm == fb_norm)):
160158
return True
161159
return False
@@ -848,9 +846,12 @@ def _check_bash(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Safet
848846
continue
849847
cmd = tokens[0].split("/")[-1]
850848

851-
if policy.strict_command_allowlist and policy.allowed_commands:
852-
# Skip env assignments; reuse PolicyConfig.is_command_allowed
853-
# so Windows basenames (\) stay consistent with policy helpers.
849+
if policy.strict_command_allowlist:
850+
# Fail-closed: when strict mode is on but allowed_commands is
851+
# empty, every non-builtin command is unknown and must be
852+
# flagged HIGH. The previous `and policy.allowed_commands`
853+
# guard turned strict mode into a no-op for empty allow-lists,
854+
# letting rm/chmod slip through.
854855
check_cmd = cmd
855856
if "=" in check_cmd and len(tokens) > 1:
856857
check_cmd = tokens[1].split("/")[-1].split("\\")[-1]

trpc_agent_sdk/safety/_wrapper.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

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

1314
from ._audit import AuditLogger
@@ -19,6 +20,23 @@
1920
from ._types import decision_rank
2021
from ._types import risk_order
2122

23+
# Use the real CodeExecutionResult / Outcome from trpc_agent_sdk.types so deny
24+
# results flow through the Agent pipeline (Part.from_code_execution_result,
25+
# _openai_model._post_process_code_execution_result) without AttributeError on
26+
# .outcome.value. trpc_agent_sdk.types pulls google.genai.types (always
27+
# installed with the SDK core), NOT docker, so this stays dep-light.
28+
try: # pragma: no cover
29+
from trpc_agent_sdk.types import CodeExecutionResult
30+
from trpc_agent_sdk.types import Outcome
31+
_REAL_RESULT_TYPES_AVAILABLE = True
32+
except Exception as ex: # pylint: disable=broad-except
33+
CodeExecutionResult = None # type: ignore[assignment]
34+
Outcome = None # type: ignore[assignment]
35+
_REAL_RESULT_TYPES_AVAILABLE = False
36+
_RESULT_IMPORT_ERROR = ex
37+
38+
_logger = logging.getLogger("trpc_agent_sdk.safety")
39+
2240

2341
def wrap_tool(tool, policy: PolicyConfig, *, audit_path: Optional[str] = None):
2442
"""Return *tool* with a :class:`ToolSafetyFilter` prepended to its filters."""
@@ -27,24 +45,37 @@ def wrap_tool(tool, policy: PolicyConfig, *, audit_path: Optional[str] = None):
2745
return tool
2846

2947

30-
class _Outcome:
31-
"""Minimal stand-in for trpc_agent_sdk.types.Outcome used in deny results."""
32-
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)
43-
48+
def _deny_code_result(rule_ids: list[str]):
49+
"""Build a failed CodeExecutionResult using the real SDK types.
4450
45-
def _deny_code_result(rule_ids: list[str]) -> _CodeExecutionResult:
46-
"""Build a failed code-execution result without importing code_executors."""
51+
Falls back to a minimal stand-in only if ``trpc_agent_sdk.types`` cannot be
52+
imported (e.g. google-genai missing), which would itself break the whole
53+
SDK. The real ``Outcome.OUTCOME_FAILED`` is an enum with ``.value`` and
54+
``.name``, so downstream code (Part.from_code_execution_result,
55+
_openai_model) that reads ``.outcome.value`` works correctly.
56+
"""
4757
msg = f"Code execution error:\nTOOL_SAFETY_DENY: {rule_ids}\n"
58+
if _REAL_RESULT_TYPES_AVAILABLE:
59+
return CodeExecutionResult(outcome=Outcome.OUTCOME_FAILED, output=msg)
60+
# Last-resort fallback (SDK core itself is broken). Emit a clear error so
61+
# the misconfiguration is diagnosable instead of silently producing a
62+
# stub object that crashes downstream pipeline code.
63+
_logger.error(
64+
"trpc_agent_sdk.types import failed; cannot build real "
65+
"CodeExecutionResult. Deny result will use a minimal stub. "
66+
"Original error: %r",
67+
_RESULT_IMPORT_ERROR,
68+
)
69+
70+
class _Outcome:
71+
def __init__(self, name: str):
72+
self.name = name
73+
74+
class _CodeExecutionResult:
75+
def __init__(self, *, output: str, outcome_name: str):
76+
self.output = output
77+
self.outcome = _Outcome(outcome_name)
78+
4879
return _CodeExecutionResult(output=msg, outcome_name="OUTCOME_FAILED")
4980

5081

@@ -63,20 +94,37 @@ def _normalize_block_language(raw_lang: str | None, code: str) -> str:
6394

6495

6596
def _scan_code_input(scanner: SafetyScanner, input_data):
66-
"""Scan code / code_blocks with per-block language; return worst report."""
97+
"""Scan code / code_blocks with per-block language; return worst report.
98+
99+
``code_blocks`` elements may be either objects (with ``.code`` /
100+
``.language`` attributes, e.g. ``CodeBlock``) or dicts (with ``code`` /
101+
``language`` keys, as produced by some tool adapters). Both forms are
102+
supported so the scanner does not silently skip dict blocks (which would
103+
leave real code unscanned).
104+
"""
67105
# Use shared ranking from _types so this aggregation logic cannot drift
68106
# from the executor's copy. Decision severity: DENY > NEEDS_HUMAN_REVIEW >
69107
# ALLOW. A MEDIUM bash block must outweigh a safe ALLOW python block.
70108
blocks = list(getattr(input_data, "code_blocks", None) or [])
71109
top_code = getattr(input_data, "code", None) or ""
72110
if not blocks and top_code:
73111
top_lang = getattr(input_data, "language", None) or ""
74-
blocks = [type("B", (), {"code": top_code, "language": top_lang})()]
112+
blocks = [{"code": top_code, "language": top_lang}]
113+
114+
def _block_code(block) -> str:
115+
if isinstance(block, dict):
116+
return str(block.get("code", "") or "")
117+
return str(getattr(block, "code", None) or "")
118+
119+
def _block_lang(block) -> str:
120+
if isinstance(block, dict):
121+
return str(block.get("language", "") or "").strip().lower()
122+
return str(getattr(block, "language", None) or "").strip().lower()
75123

76124
worst = None
77125
for block in blocks:
78-
code = getattr(block, "code", None) or ""
79-
declared = (getattr(block, "language", None) or "").strip().lower()
126+
code = _block_code(block)
127+
declared = _block_lang(block)
80128
# Mislabeled bash-as-python must still hit bash rules via content check.
81129
if declared in ("sh", "shell", "bash"):
82130
lang = "bash"

0 commit comments

Comments
 (0)