Skip to content

Commit fbead7b

Browse files
committed
fix(safety): address CongkeChen review - stderr fallback, single-file delete, dd false-positive, path boundary
Critical: - _filter._before: non-blocking NEEDS_HUMAN_REVIEW now also writes to stderr so the warning surfaces even if _after is skipped (handle() raised, earlier filter set is_continue=False, or non-dict tool return). Warning: - _rules.DangerousFilesRule: os.remove/os.unlink/Path.unlink on sensitive/forbidden/system paths now triggers R001 (previously only recursive delete was flagged, so os.remove("/etc/passwd") was missed while bash "rm /etc/passwd" was caught). - _rules._DD_WRITE: tighten regex from \bdd\b to (?:^|\s|;|&&|\|\|)\s*dd so adduser/findd/xdd no longer false-positive HIGH/DENY. - _rules._matches_forbidden/_matches_system_dir: switch from substring to path-boundary matching so '/etc' no longer matches '/etcetera' and '.env' no longer matches 'my.envrc'. - _rules decode-exec: extract pipe_to_shell/decoder locals to make the or/and precedence explicit and avoid the nested-paren yapf ambiguity. - test_tool_filter.test_filter_block_on_review: rename to test_filter_block_on_review_deny_still_blocks and tighten assertion to == "TOOL_SAFETY_DENY" (was loose `in (DENY, NEEDS_REVIEW)` that never exercised the review-block path). Suggestion: - safety.__init__: add _WRAPPER_AVAILABLE flag so callers can tell whether the wrap_tool/safety_wrapper group loaded independently of _SDK_AVAILABLE (ToolSafetyFilter). Tests: - 7 regression tests for os.remove/os.unlink sensitive delete, dd false-positive on adduser, dd real command still caught, forbidden path boundary (.env vs my.envrc), system dir boundary (/etc vs /etcetera). - test_filter_sleep_at_threshold_is_allowed, test_filter_contextvar_ does_not_leak_across_calls.
1 parent 9f09f7d commit fbead7b

5 files changed

Lines changed: 125 additions & 11 deletions

File tree

tests/tool_safety/test_rules.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,64 @@ def test_secret_env_access():
203203
def test_secret_redacts_evidence():
204204
from trpc_agent_sdk.safety._rules import redact
205205
assert redact("sk-abcdefghijklmnopqrstuvwxyz").endswith("***")
206+
207+
208+
def test_dangerous_files_python_remove_sensitive_path():
209+
"""Regression for CongkeChen review: os.remove('/etc/passwd') must trigger
210+
R001 even without recursive delete. Previously only rmtree/unlink-with-r
211+
were flagged, so single-file delete of sensitive paths was silently
212+
allowed while the bash equivalent (rm /etc/passwd) was caught."""
213+
rule = DangerousFilesRule()
214+
inp = ScanInput(script="import os\nos.remove('/etc/passwd')\n", language="python")
215+
findings = rule.check(inp, _policy())
216+
assert findings and findings[0].rule_id == "R001_dangerous_files"
217+
218+
219+
def test_dangerous_files_python_unlink_ssh_key():
220+
"""os.unlink('~/.ssh/id_rsa') must trigger R001 (single-file sensitive delete)."""
221+
rule = DangerousFilesRule()
222+
inp = ScanInput(script="import os\nos.unlink('/home/u/.ssh/id_rsa')\n", language="python")
223+
findings = rule.check(inp, _policy())
224+
assert findings and findings[0].rule_id == "R001_dangerous_files"
225+
226+
227+
def test_resource_dd_not_false_positive_on_adduser():
228+
"""Regression for CongkeChen review: \\bdd\\b matched any 'dd' substring,
229+
so 'adduser', 'findd', 'xdd' were false-positive HIGH/DENY. The regex now
230+
requires dd as a command token (after start/space/;/&&/||)."""
231+
rule = ResourceAbuseRule()
232+
inp = ScanInput(script="adduser bob\nfind / -name xdd\n", language="bash")
233+
findings = rule.check(inp, _policy())
234+
# No dd finding should fire for adduser/findd/xdd.
235+
assert not any("dd can write" in f.metadata.get("message", "") for f in findings)
236+
237+
238+
def test_resource_dd_real_command_still_caught():
239+
"""Real 'dd if=/dev/zero of=/tmp/big bs=1M count=100' must still be caught."""
240+
rule = ResourceAbuseRule()
241+
inp = ScanInput(script="dd if=/dev/zero of=/tmp/big bs=1M count=100\n", language="bash")
242+
findings = rule.check(inp, _policy())
243+
assert any("dd can write" in f.metadata.get("message", "") for f in findings)
244+
245+
246+
def test_dangerous_files_forbidden_path_boundary_not_substring():
247+
"""Regression for CongkeChen review: _matches_forbidden used substring
248+
matching, so '.env' matched 'my.envrc'. Now uses path-boundary matching."""
249+
rule = DangerousFilesRule()
250+
policy = PolicyConfig(forbidden_paths=[".env"])
251+
# 'my.envrc' must NOT match forbidden path '.env' (no path boundary before .env).
252+
inp = ScanInput(script="open('my.envrc')\n", language="python")
253+
findings = rule.check(inp, policy)
254+
# No forbidden-path finding should fire for my.envrc.
255+
assert not any("forbidden" in f.metadata.get("message", "").lower() for f in findings)
256+
257+
258+
def test_dangerous_files_system_dir_boundary_not_substring():
259+
"""Regression for CongkeChen review: _matches_system_dir used substring
260+
matching, so '/etc' matched '/etcetera'. Now uses path-boundary matching."""
261+
rule = DangerousFilesRule()
262+
# '/etcetera/foo' must NOT match system dir '/etc' (no path boundary after /etc).
263+
inp = ScanInput(script="open('/etcetera/foo')\n", language="python")
264+
findings = rule.check(inp, _policy())
265+
# No system-dir finding should fire for /etcetera.
266+
assert not any("system" in f.metadata.get("message", "").lower() for f in findings)

tests/tool_safety/test_tool_filter.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,17 +76,21 @@ def test_filter_dynamic_network_is_denied_by_default(tmp_path: Path):
7676
assert rsp.rsp["error"] == "TOOL_SAFETY_DENY"
7777

7878

79-
def test_filter_block_on_review(tmp_path: Path):
79+
def test_filter_block_on_review_deny_still_blocks(tmp_path: Path):
80+
"""When block_on_review=True, DENY (HIGH) findings must still block.
81+
82+
This is a sanity check that block_on_review does not weaken DENY handling.
83+
The pure-MEDIUM review-block path is covered by
84+
test_filter_block_on_review_pure_medium_returns_needs_review.
85+
"""
8086
flt = _make_filter(tmp_path, block_on_review=True)
8187
rsp = FilterResult()
82-
# Medium-only finding that is not HIGH: still exercise block_on_review via
83-
# a policy that treats medium as review-only when block_on_review is True.
84-
# curl $URL is HIGH/deny under default policy; use empty script path via code
85-
# that only triggers review is hard — assert deny still blocks.
88+
# curl $URL is HIGH/deny under default policy; block_on_review must NOT
89+
# downgrade a DENY to a non-blocking review.
8690
req = {"command": "curl $URL"}
8791
asyncio.run(flt._before(None, req, rsp)) # pylint: disable=protected-access
8892
assert rsp.is_continue is False
89-
assert rsp.rsp["error"] in ("TOOL_SAFETY_DENY", "TOOL_SAFETY_NEEDS_REVIEW")
93+
assert rsp.rsp["error"] == "TOOL_SAFETY_DENY"
9094
assert rsp.rsp.get("success") is False
9195

9296

trpc_agent_sdk/safety/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
# original ImportError so callers can diagnose the cause instead of getting a
4848
# silent None + later TypeError when they try to use the symbol.
4949
_SDK_AVAILABLE = False
50+
_WRAPPER_AVAILABLE = False
5051
_IMPORT_ERRORS: dict[str, BaseException] = {}
5152
try: # pragma: no cover
5253
from ._filter import ToolSafetyFilter
@@ -63,6 +64,7 @@
6364
from ._wrapper import safe_code_executor
6465
from ._wrapper import safety_wrapper
6566
from ._wrapper import wrap_tool
67+
_WRAPPER_AVAILABLE = True
6668
except Exception as ex: # pylint: disable=broad-except
6769
SafeCodeExecutor = None # type: ignore[assignment]
6870
SafetyDeniedError = None # type: ignore[assignment]
@@ -124,4 +126,5 @@ def import_error_for(name: str) -> BaseException | None:
124126
"SafetyReviewedSkillRunner",
125127
"import_error_for",
126128
"_SDK_AVAILABLE",
129+
"_WRAPPER_AVAILABLE",
127130
]

trpc_agent_sdk/safety/_filter.py

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

99
import contextvars
10+
import sys
1011
from typing import Any
1112
from typing import Optional
1213

@@ -128,6 +129,15 @@ async def _before(self, ctx: Any, req: Any, rsp: FilterResult) -> None:
128129
# tool's actual result. Setting rsp.rsp here is useless because
129130
# BaseFilter.run's handle() step overwrites result.rsp with the
130131
# tool's return value.
132+
#
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")
131141
_REVIEW_REPORT.set(report)
132142

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

trpc_agent_sdk/safety/_rules.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -129,13 +129,36 @@ def _matches_sensitive(target: str) -> bool:
129129
def _matches_system_dir(target: str) -> bool:
130130
if not target:
131131
return False
132-
return any(sd in target for sd in _SYSTEM_DIRS)
132+
# Use path-boundary matching so '/etc' matches '/etc/passwd' but NOT
133+
# '/etcetera/foo'. We treat '/', '\\', start-of-string, and end-of-string
134+
# as valid path boundaries around each system dir prefix.
135+
for sd in _SYSTEM_DIRS:
136+
# Normalize backslashes to forward slashes for uniform matching.
137+
sd_norm = sd.replace("\\", "/")
138+
tgt_norm = target.replace("\\", "/")
139+
if tgt_norm == sd_norm or tgt_norm.startswith(sd_norm + "/"):
140+
return True
141+
return False
133142

134143

135144
def _matches_forbidden(target: str, policy: PolicyConfig) -> bool:
136145
if not target:
137146
return False
138-
return any(fb in target for fb in policy.forbidden_paths)
147+
# Path-boundary match so '.env' matches '/app/.env' but NOT 'my.envrc'.
148+
# We accept the forbidden path appearing at start-of-string or after a
149+
# path separator; a trailing separator or end-of-string is NOT required
150+
# so that '.env' matches '.env.production' too (common convention).
151+
tgt_norm = target.replace("\\", "/")
152+
for fb in policy.forbidden_paths:
153+
fb_norm = fb.replace("\\", "/")
154+
if not fb_norm:
155+
continue
156+
if (tgt_norm == fb_norm
157+
or tgt_norm.startswith(fb_norm + "/")
158+
or tgt_norm.endswith("/" + fb_norm)
159+
or ("/" not in tgt_norm and tgt_norm == fb_norm)):
160+
return True
161+
return False
139162

140163

141164
# ---------------------------------------------------------------------------
@@ -189,6 +212,18 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
189212
"Avoid recursive deletion; restrict to known workspace paths.",
190213
message=f"Recursive/forced delete via {name}({target!r})",
191214
))
215+
elif (_matches_sensitive(target) or _matches_forbidden(target, policy) or _matches_system_dir(target)):
216+
# Single-file delete of a sensitive/forbidden/system path
217+
# (e.g. os.remove("/etc/passwd"), os.unlink("~/.ssh/id_rsa"))
218+
# is just as dangerous as recursive delete; flag it so the
219+
# Python side matches the bash rm /etc/passwd coverage.
220+
findings.append(
221+
self._finding(
222+
f"{name}({target})",
223+
node.lineno,
224+
f"Avoid deleting sensitive/forbidden path: {target}",
225+
message=f"Single-file delete of sensitive path via {name}({target!r})",
226+
))
192227

193228
if lname in {"open", "builtins.open"} or lname.endswith(".open"):
194229
target = _first_str_or_path(node) or "<dynamic>"
@@ -902,8 +937,9 @@ def _check_bash(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Safet
902937
level=RiskLevel.CRITICAL,
903938
message="Use of eval in bash",
904939
))
905-
if _DECODE_EXEC_BASH.search(line) or re.search(r"\|\s*(sh|bash|zsh)\b", line) and re.search(
906-
r"base64|xxd|openssl", line, re.IGNORECASE):
940+
pipe_to_shell = re.search(r"\|\s*(?:sh|bash|zsh)\b", line)
941+
decoder = re.search(r"base64|xxd|openssl", line, re.IGNORECASE)
942+
if _DECODE_EXEC_BASH.search(line) or (pipe_to_shell and decoder):
907943
findings.append(
908944
self._finding(
909945
line,
@@ -1055,7 +1091,7 @@ def _subprocess_args_text(node: ast.Call) -> str:
10551091
re.IGNORECASE,
10561092
)
10571093
_LONG_SLEEP_BASH = re.compile(r"\bsleep\s+(\d+)", re.IGNORECASE)
1058-
_DD_WRITE = re.compile(r"\bdd\b", re.IGNORECASE)
1094+
_DD_WRITE = re.compile(r"(?:^|\s|;|&&|\|\|)\s*dd\b", re.IGNORECASE)
10591095
_BIG_WRITE = re.compile(r"(head|tail|yes|/dev/zero|/dev/urandom)", re.IGNORECASE)
10601096
_HIGH_CONCURRENCY = {
10611097
"concurrent.futures.threadpoolexecutor",

0 commit comments

Comments
 (0)