Skip to content

Commit 5f9e7b1

Browse files
committed
fix(safety): address CongkeChen review - bash path boundary, audit cross-process note, retry side effect doc
CI fix: - _wrapper._deny_code_result fallback stub: yapf requires a blank line between class declaration and first method (E301). Added blank lines after `class _Outcome:` and `class _CodeExecutionResult:`. Warning: - _rules.DangerousFilesRule._check_bash: bash side system-dir and forbidden-path matching used substring (`sd in line` / `fb in line`), inconsistent with the Python side's path-boundary matching. So `cat /etcetera/foo` was false-positive CRITICAL → DENY (matched /etc), and `cat my.envrc` was false-positive → DENY (matched .env). Now both reuse _matches_system_dir(line) and _matches_forbidden(line, policy) so bash and Python sides agree. - _audit._AUDIT_LOCK: add cross-process note explaining the lock is process-local and long JSONL lines (>PIPE_BUF, ~4096 bytes on Linux) may interleave/truncate across worker processes. Documents the three mitigations (per-worker path, single writer, fcntl.flock). - _unsafe_local_code_executor.execute_code: document the OUTCOME_FAILED → error_retry_attempts side effect. Safety blocks map to OUTCOME_FAILED (non-empty stderr), which _code_execution_processor treats as an execution error and may retry up to 2 times. Each retry is still intercepted (safe), but wastes a scan/LLM round-trip. Comment notes a future refinement could use a dedicated marker to distinguish "safety block" from "real execution error". Tests: - test_dangerous_files_bash_system_dir_boundary_not_substring: assert `cat /etcetera/foo` does NOT trigger R001 (bash side, mirrors the existing Python-side regression test). - test_dangerous_files_bash_forbidden_path_boundary_not_substring: assert `cat my.envrc` does NOT trigger forbidden-path R001 with policy forbidden_paths=[".env"]. - test_resource_long_sleep_above_threshold_is_high: assert sleep 301 (max=300) is flagged HIGH. - test_resource_sleep_at_threshold_is_allowed_bash: assert sleep 300 (== max) is NOT flagged (strict > boundary).
1 parent d231f71 commit 5f9e7b1

5 files changed

Lines changed: 78 additions & 2 deletions

File tree

tests/tool_safety/test_rules.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,49 @@ def test_strict_command_allowlist_empty_list_is_fail_closed():
279279
findings = rule.check(inp, policy)
280280
allow_list_findings = [f for f in findings if "allow-list" in f.metadata.get("message", "")]
281281
assert allow_list_findings, "strict_command_allowlist=True with empty allowed_commands must flag rm (fail-closed)"
282+
283+
284+
def test_dangerous_files_bash_system_dir_boundary_not_substring():
285+
"""Regression for CongkeChen review: bash side used `sd in line` substring
286+
matching, so '/etc' matched 'cat /etcetera/foo' (false-positive CRITICAL →
287+
DENY). Now uses _matches_system_dir for path-boundary consistency with the
288+
Python side."""
289+
rule = DangerousFilesRule()
290+
# '/etcetera/foo' must NOT match system dir '/etc' (no path boundary).
291+
inp = ScanInput(script="cat /etcetera/foo\n", language="bash")
292+
findings = rule.check(inp, _policy())
293+
assert not any("system directory" in f.metadata.get("message", "") for f in findings)
294+
295+
296+
def test_dangerous_files_bash_forbidden_path_boundary_not_substring():
297+
"""Regression for CongkeChen review: bash side used `fb in line` substring
298+
matching, so '.env' matched 'cat my.envrc' (false-positive → DENY). Now
299+
uses _matches_forbidden for path-boundary consistency with the Python
300+
side."""
301+
rule = DangerousFilesRule()
302+
policy = PolicyConfig(forbidden_paths=[".env"])
303+
# 'my.envrc' must NOT match forbidden path '.env' (no path boundary).
304+
inp = ScanInput(script="cat my.envrc\n", language="bash")
305+
findings = rule.check(inp, policy)
306+
assert not any("forbidden" in f.metadata.get("message", "").lower() for f in findings)
307+
308+
309+
def test_resource_long_sleep_above_threshold_is_high():
310+
"""sleep N where N > max_timeout_seconds must be flagged HIGH.
311+
Threshold is > (strict), so max+1 triggers and max does not."""
312+
from trpc_agent_sdk.safety._rules import ResourceAbuseRule
313+
rule = ResourceAbuseRule()
314+
policy = PolicyConfig(max_timeout_seconds=300)
315+
inp = ScanInput(script="sleep 301\n", language="bash")
316+
findings = rule.check(inp, policy)
317+
assert any("Long sleep" in f.metadata.get("message", "") for f in findings)
318+
319+
320+
def test_resource_sleep_at_threshold_is_allowed_bash():
321+
"""sleep N where N == max_timeout_seconds must NOT be flagged (boundary)."""
322+
from trpc_agent_sdk.safety._rules import ResourceAbuseRule
323+
rule = ResourceAbuseRule()
324+
policy = PolicyConfig(max_timeout_seconds=300)
325+
inp = ScanInput(script="sleep 300\n", language="bash")
326+
findings = rule.check(inp, policy)
327+
assert not any("Long sleep" in f.metadata.get("message", "") for f in findings)

trpc_agent_sdk/code_executors/local/_unsafe_local_code_executor.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,16 @@ async def execute_code(self, invocation_context: InvocationContext,
127127
# Distinguish DENY vs NEEDS_HUMAN_REVIEW in stderr so audits and
128128
# logs don't mislabel a review-block as a deny (matches
129129
# ToolSafetyFilter's error_code selection).
130+
#
131+
# Side effect: create_code_execution_result maps non-empty
132+
# stderr to OUTCOME_FAILED, and _code_execution_processor
133+
# treats outcome != OUTCOME_OK as an execution error, which
134+
# may trigger up to error_retry_attempts (default 2) retries
135+
# of the same blocked code. Each retry is still intercepted
136+
# (so this is safe), but it wastes a scan/LLM round-trip per
137+
# retry. This is accepted for now; a future refinement could
138+
# use a dedicated outcome/marker to distinguish "safety block"
139+
# from "real execution error" so the agent does not retry.
130140
code_label = ("TOOL_SAFETY_DENY" if report.decision == Decision.DENY else "TOOL_SAFETY_NEEDS_REVIEW")
131141
return create_code_execution_result(stderr=f"{code_label}: {report.rule_ids}")
132142
# Non-blocking NEEDS_HUMAN_REVIEW: do NOT block execution, but

trpc_agent_sdk/safety/_audit.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,16 @@
1616
from ._types import SafetyReport
1717

1818
# Process-local lock so concurrent threads do not interleave long JSONL lines.
19+
#
20+
# Cross-process note: this lock does NOT coordinate across OS processes. If
21+
# multiple worker processes write to the same audit_path simultaneously, long
22+
# JSONL lines (above PIPE_BUF, typically 4096 bytes on Linux) may interleave
23+
# or truncate because O_APPEND atomicity is only guaranteed up to PIPE_BUF.
24+
# For multi-process deployments, either (a) give each worker a distinct
25+
# audit_path, (b) funnel audit writes through a single writer process, or
26+
# (c) use fcntl.flock for cross-process exclusion. The current single-process
27+
# design matches the typical Tool/SafetyFilter usage where one agent process
28+
# owns the audit log.
1929
_AUDIT_LOCK = threading.Lock()
2030

2131

trpc_agent_sdk/safety/_rules.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,11 @@ def _check_bash(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Safet
313313
))
314314
break
315315
for sd in _SYSTEM_DIRS:
316-
if sd in line and (">" in line or "rm " in line or "chmod" in line or "chown" in line):
316+
# Path-boundary match (consistent with _matches_system_dir on
317+
# the Python side) so '/etc' matches '/etc/passwd' but NOT
318+
# '/etcetera/foo'. Without this, safe scripts touching
319+
# /etcetera would be false-positive CRITICAL → DENY.
320+
if _matches_system_dir(line) and (">" in line or "rm " in line or "chmod" in line or "chown" in line):
317321
findings.append(
318322
self._finding(
319323
line,
@@ -323,7 +327,11 @@ def _check_bash(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Safet
323327
))
324328
break
325329
for fb in policy.forbidden_paths:
326-
if fb in line:
330+
# Path-boundary match (consistent with _matches_forbidden on
331+
# the Python side) so '.env' matches '/app/.env' but NOT
332+
# 'my.envrc'. Without this, safe scripts touching my.envrc
333+
# would be false-positive → DENY.
334+
if _matches_forbidden(line, policy):
327335
findings.append(
328336
self._finding(
329337
line,

trpc_agent_sdk/safety/_wrapper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,12 @@ def _deny_code_result(rule_ids: list[str]):
6868
)
6969

7070
class _Outcome:
71+
7172
def __init__(self, name: str):
7273
self.name = name
7374

7475
class _CodeExecutionResult:
76+
7577
def __init__(self, *, output: str, outcome_name: str):
7678
self.output = output
7779
self.outcome = _Outcome(outcome_name)

0 commit comments

Comments
 (0)