Skip to content

Commit 474ae58

Browse files
committed
fix(safety): merge review warning in _after; fix intercepted audit
CongkeChen review #3: 1. _filter.py: NEEDS_HUMAN_REVIEW warning was lost because BaseFilter.run overwrites result.rsp with the tool's return value in handle(). Stash the report in a ContextVar during _before and merge safety_warning/safety_risk_level/safety_rule_ids in _after so the caller sees them. 2. _wrapper.py: intercepted=report.blocked used policy.block_on_review, so when safety_wrapper(raise_on_deny=False) or SafetyReviewedSkillRunner(block_review=True) overrode the policy, the audit falsely recorded intercepted. Now compute intercepted from the actual control flow. 3. _wrapper.py: drop unused block_on_review param of _scan_code_input. Regression tests: test_run_filters_needs_review_merges_warning_into_result, test_skill_runner_block_review_overrides_policy_records_intercepted, test_safety_wrapper_raise_on_deny_false_records_intercepted_false
1 parent f1cba9b commit 474ae58

5 files changed

Lines changed: 151 additions & 19 deletions

File tree

tests/tool_safety/test_filter_chain.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,32 @@ async def _run():
7676
assert result["success"] is True
7777

7878

79+
def test_run_filters_needs_review_merges_warning_into_result():
80+
"""Non-blocking review must merge safety_warning into the tool result.
81+
82+
Regression for CongkeChen's review comment: setting rsp.rsp in _before is
83+
useless because BaseFilter.run overwrites result.rsp with the tool's return
84+
value in the handle() step. _after must merge the warning fields so the
85+
caller actually sees them.
86+
"""
87+
# A bash command that triggers NEEDS_HUMAN_REVIEW (MEDIUM): backgrounding a
88+
# non-network process. Network backgrounding is HIGH/DENY, but plain
89+
# background is MEDIUM/review under the default policy.
90+
flt = ToolSafetyFilter(_policy(block_on_review=False))
91+
92+
async def _handle():
93+
return {"success": True, "stdout": "ran"}
94+
95+
async def _run():
96+
return await run_filters(None, {"command": "sleep 100 &"}, [flt], _handle)
97+
98+
result = asyncio.run(_run())
99+
assert result["success"] is True
100+
assert result.get("safety_warning") == "TOOL_SAFETY_NEEDS_REVIEW"
101+
assert "safety_risk_level" in result
102+
assert "safety_rule_ids" in result
103+
104+
79105
def test_base_filter_run_deny_keeps_error_none():
80106
"""Direct BaseFilter.run must keep error=None and is_continue=False on deny."""
81107
flt = ToolSafetyFilter(_policy())

tests/tool_safety/test_wrapper.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,4 +212,65 @@ def test_skill_runner_allows_safe_command(tmp_path: Path):
212212
result = asyncio.run(safe.run(None, args))
213213

214214
assert result["success"] is True
215-
assert runner.calls == 1
215+
assert runner.calls == 1
216+
217+
218+
def test_skill_runner_block_review_overrides_policy_records_intercepted(tmp_path: Path):
219+
"""When block_review=True overrides policy.block_on_review=False, audit
220+
must record intercepted=True for a NEEDS_HUMAN_REVIEW hit.
221+
222+
Regression for CongkeChen's review: intercepted=report.blocked used
223+
policy.block_on_review, so when the runner blocked review via the
224+
block_review parameter, the audit falsely showed intercepted=False.
225+
"""
226+
runner = _FakeSkillRunner()
227+
# policy.block_on_review=False (default); block_review=True overrides it.
228+
safe = SafetyReviewedSkillRunner(
229+
runner,
230+
PolicyConfig(),
231+
audit_path=str(tmp_path / "audit.jsonl"),
232+
block_review=True,
233+
tool_name="skill_run",
234+
)
235+
236+
# sleep 100 & triggers NEEDS_HUMAN_REVIEW (MEDIUM): background non-network process.
237+
args = {"command": "sleep 100 &"}
238+
result = asyncio.run(safe.run(None, args))
239+
240+
assert result["success"] is False
241+
assert result["error"] == "SKILL_NEEDS_REVIEW"
242+
assert runner.calls == 0
243+
244+
audit_path = tmp_path / "audit.jsonl"
245+
assert audit_path.exists()
246+
rec = json.loads(audit_path.read_text(encoding="utf-8").strip().splitlines()[-1])
247+
assert rec["decision"] == "needs_human_review"
248+
assert rec["intercepted"] is True
249+
250+
251+
def test_safety_wrapper_raise_on_deny_false_records_intercepted_false(tmp_path: Path):
252+
"""When raise_on_deny=False, a DENY hit must not be recorded as intercepted.
253+
254+
Regression for CongkeChen's review: intercepted=report.blocked used
255+
policy.block_on_review, so a DENY with raise_on_deny=False was falsely
256+
recorded as intercepted=True. The actual interception is raise_on_deny.
257+
"""
258+
259+
@safety_wrapper(
260+
tool_name="denied_tool",
261+
policy=PolicyConfig(),
262+
audit_path=str(tmp_path / "audit.jsonl"),
263+
raise_on_deny=False,
264+
)
265+
async def run_tool(*, script):
266+
return "ran"
267+
268+
result = asyncio.run(run_tool(script="rm -rf /"))
269+
270+
assert result == "ran"
271+
272+
audit_path = tmp_path / "audit.jsonl"
273+
assert audit_path.exists()
274+
rec = json.loads(audit_path.read_text(encoding="utf-8").strip().splitlines()[-1])
275+
assert rec["decision"] == "deny"
276+
assert rec["intercepted"] is False

tests/tool_safety/test_wrapper_language.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ async def execute_code(self, invocation_context, input_data):
4444
def test_scan_code_input_bash_block_denies_rm():
4545
scanner = SafetyScanner(PolicyConfig())
4646
inp = _Input(code_blocks=[_Block("rm -rf /", language="bash")])
47-
report = _scan_code_input(scanner, inp, block_on_review=False)
47+
report = _scan_code_input(scanner, inp)
4848
assert report is not None
4949
assert report.decision.value == "deny"
5050
assert "R001_dangerous_files" in report.rule_ids
@@ -53,7 +53,7 @@ def test_scan_code_input_bash_block_denies_rm():
5353
def test_scan_code_input_infers_bash_when_language_empty():
5454
scanner = SafetyScanner(PolicyConfig())
5555
inp = _Input(code_blocks=[_Block("curl https://evil.example.com", language="")])
56-
report = _scan_code_input(scanner, inp, block_on_review=False)
56+
report = _scan_code_input(scanner, inp)
5757
assert report is not None
5858
assert report.decision.value == "deny"
5959

@@ -73,8 +73,8 @@ def test_mislabeled_python_bash_payload_still_denied():
7373
"""language='python' but content is bash rm -rf must still deny."""
7474
scanner = SafetyScanner(PolicyConfig())
7575
inp = _Input(code_blocks=[_Block("rm -rf /", language="python")])
76-
report = _scan_code_input(scanner, inp, block_on_review=False)
77-
# Content inference on empty/mislabel path: force-check via normalize.
76+
report = _scan_code_input(scanner, inp)
77+
assert report.decision.value == "deny"
7878
from trpc_agent_sdk.safety._ast_utils import normalize_language
7979
from trpc_agent_sdk.safety import ScanInput as SI
8080
lang = normalize_language(SI(script="rm -rf /", language=""))

trpc_agent_sdk/safety/_filter.py

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""ToolSafetyFilter: pre-execution safety filter for Tool / Skill scripts."""
77
from __future__ import annotations
88

9+
import contextvars
910
from typing import Any
1011
from typing import Optional
1112

@@ -17,12 +18,21 @@
1718
from ._policy import PolicyConfig
1819
from ._scanner import SafetyScanner
1920
from ._types import Decision
21+
from ._types import SafetyReport
2022
from ._types import ScanInput
2123

2224
_SCRIPT_ARG_KEYS = ("command", "script", "code", "cmd", "bash", "shell_command")
2325
_LANGUAGE_ARG_KEYS = ("language", "lang")
2426
_WORKDIR_ARG_KEYS = ("cwd", "workdir", "working_dir")
2527

28+
# ContextVar carries the non-blocking review report from _before to _after.
29+
# We cannot stash it on the FilterResult because BaseFilter.run may replace
30+
# the whole result object with the one returned by handle(). ContextVar is
31+
# async-safe: each task sees its own value across awaits.
32+
_REVIEW_REPORT: contextvars.ContextVar[Optional[SafetyReport]] = contextvars.ContextVar(
33+
"_safety_review_report", default=None
34+
)
35+
2636

2737
class ToolSafetyFilter(BaseFilter):
2838
"""Pre-execution safety filter for Tool / Skill / CodeExecutor scripts.
@@ -31,6 +41,13 @@ class ToolSafetyFilter(BaseFilter):
3141
``policy.block_on_review``), the filter sets ``is_continue=False`` so the
3242
tool's ``_run_async_impl`` is never called. An audit record is always
3343
written, and OpenTelemetry span attributes are set on the current span.
44+
45+
For non-blocking NEEDS_HUMAN_REVIEW (``block_on_review=False``), the tool
46+
is allowed to run, and the warning is merged into the tool's result dict
47+
(keys ``safety_warning`` / ``safety_risk_level`` / ``safety_rule_ids``) in
48+
``_after`` so the caller actually sees it. This is necessary because
49+
``BaseFilter.run`` overwrites ``result.rsp`` with the tool's return value
50+
after ``_before`` returns.
3451
"""
3552

3653
def __init__(
@@ -105,14 +122,34 @@ async def _before(self, ctx: Any, req: Any, rsp: FilterResult) -> None:
105122
return
106123

107124
if report.decision == Decision.NEEDS_HUMAN_REVIEW:
108-
# Non-blocking review: annotate rsp.rsp with a warning field. Do NOT
109-
# put a string into rsp.error (would crash run_filters). Full details
110-
# remain in audit / OTel.
111-
if not isinstance(rsp.rsp, dict):
112-
rsp.rsp = {}
113-
rsp.rsp["safety_warning"] = "TOOL_SAFETY_NEEDS_REVIEW"
114-
rsp.rsp["safety_risk_level"] = report.risk_level.value
115-
rsp.rsp["safety_rule_ids"] = list(report.rule_ids)
125+
# Non-blocking review: stash the report via ContextVar so _after
126+
# (run after the tool executes) can merge the warning into the
127+
# tool's actual result. Setting rsp.rsp here is useless because
128+
# BaseFilter.run's handle() step overwrites result.rsp with the
129+
# tool's return value.
130+
_REVIEW_REPORT.set(report)
131+
132+
async def _after(self, ctx: Any, req: Any, rsp: FilterResult) -> None:
133+
"""Merge non-blocking safety review warnings into the tool result.
134+
135+
``_before`` cannot attach the warning to ``rsp.rsp`` because
136+
``BaseFilter.run`` overwrites ``result.rsp`` with the tool's return
137+
value in the handle() step. We instead stash the report in a
138+
ContextVar during ``_before`` and merge the warning fields here,
139+
after the tool has run, so the caller actually sees them.
140+
"""
141+
report = _REVIEW_REPORT.get()
142+
if report is None:
143+
return
144+
# Clear the stash so it does not leak to the next call in this context.
145+
_REVIEW_REPORT.set(None)
146+
if not isinstance(rsp.rsp, dict):
147+
# Wrap non-dict results so we can attach warning fields without
148+
# losing the original payload.
149+
rsp.rsp = {"result": rsp.rsp}
150+
rsp.rsp["safety_warning"] = "TOOL_SAFETY_NEEDS_REVIEW"
151+
rsp.rsp["safety_risk_level"] = report.risk_level.value
152+
rsp.rsp["safety_rule_ids"] = list(report.rule_ids)
116153

117154
def _resolve_tool_name(self, ctx: Any) -> str:
118155
get_tool_var = self._get_tool_var

trpc_agent_sdk/safety/_wrapper.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def _normalize_block_language(raw_lang: str | None, code: str) -> str:
6161
return normalize_language(ScanInput(script=code or "", language=""))
6262

6363

64-
def _scan_code_input(scanner: SafetyScanner, input_data, *, block_on_review: bool):
64+
def _scan_code_input(scanner: SafetyScanner, input_data):
6565
"""Scan code / code_blocks with per-block language; return worst report."""
6666
order = {
6767
RiskLevel.NONE: 0,
@@ -126,7 +126,7 @@ def __init__(
126126
self._block_on_review = block_on_review or policy.block_on_review
127127

128128
async def execute_code(self, invocation_context, input_data):
129-
report = _scan_code_input(self._scanner, input_data, block_on_review=self._block_on_review)
129+
report = _scan_code_input(self._scanner, input_data)
130130
should_block = _should_block_report(report, self._block_on_review)
131131
if report is not None:
132132
self._audit.log(report, intercepted=should_block)
@@ -154,7 +154,7 @@ def safe_code_executor(
154154
class _SafeCodeExecutor:
155155

156156
async def execute_code(self, invocation_context, input_data):
157-
report = _scan_code_input(scanner, input_data, block_on_review=block_review)
157+
report = _scan_code_input(scanner, input_data)
158158
should_block = _should_block_report(report, block_review)
159159
if report is not None:
160160
audit.log(report, intercepted=should_block)
@@ -209,8 +209,12 @@ def _guard(args, kwargs):
209209
if not script or not isinstance(script, str):
210210
return
211211
report = _scanner.scan(ScanInput(script=script, tool_name=tool_name))
212-
_audit.log(report, intercepted=report.blocked)
213-
if report.decision == Decision.DENY and raise_on_deny:
212+
# intercepted must reflect the actual interception below, not
213+
# report.blocked (which uses policy.block_on_review). safety_wrapper
214+
# only blocks on DENY when raise_on_deny=True; review is never blocked.
215+
intercepted = report.decision == Decision.DENY and raise_on_deny
216+
_audit.log(report, intercepted=intercepted)
217+
if intercepted:
214218
raise SafetyDeniedError(report)
215219

216220
def decorator(func):
@@ -253,7 +257,11 @@ async def run(self, tool_context, args):
253257
script = self._extract_script(args)
254258
if script:
255259
report = self._scanner.scan(ScanInput(script=script, tool_name=self._tool_name))
256-
self._audit.log(report, intercepted=report.blocked)
260+
# intercepted must reflect the actual interception below, not
261+
# report.blocked (which uses policy.block_on_review). Reuse
262+
# _should_block_report so audit matches the real control flow.
263+
intercepted = _should_block_report(report, self._block_review)
264+
self._audit.log(report, intercepted=intercepted)
257265
if report.decision == Decision.DENY:
258266
return {
259267
"success": False,

0 commit comments

Comments
 (0)