Skip to content

Commit ae084b2

Browse files
Violet2314claude
andcommitted
fix(safety): pathlib write/rmdir, open dynamic mode, wrapper intercept
Critical (CongkeChen review): - R001: detect Path.write_text/write_bytes/mkdir/touch/rename/chmod and Path.rmdir/unlink receivers; treat non-constant open() mode as write (fail-closed) and include system_dir on open read branch - safety_wrapper: should_block always intercepts and audits intercepted=True; raise_on_deny only controls raise vs deny-dict return (no silent execute) Also: BashTool raises import_error_for when ToolSafetyFilter is None; _deny_code_result stub Outcome gains .value for contract parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e7da56d commit ae084b2

6 files changed

Lines changed: 173 additions & 25 deletions

File tree

tests/tool_safety/test_review_p0_p1.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,3 +218,25 @@ def run(script: str):
218218
assert False, "expected SafetyDeniedError for review under block_on_review"
219219
except SafetyDeniedError:
220220
pass
221+
222+
223+
def test_safety_wrapper_raise_on_deny_false_blocks_without_raise():
224+
"""raise_on_deny=False must still prevent function execution on DENY."""
225+
from trpc_agent_sdk.safety import safety_wrapper
226+
227+
executed = []
228+
229+
@safety_wrapper(script_arg="script", policy=PolicyConfig(), raise_on_deny=False)
230+
def run(script: str):
231+
executed.append(script)
232+
return "ran"
233+
234+
result = run(script="rm -rf /")
235+
assert executed == []
236+
assert result["success"] is False
237+
assert result["error"] == "TOOL_SAFETY_DENY"
238+
239+
# Safe script still runs.
240+
result2 = run(script="echo hi")
241+
assert executed == ["echo hi"]
242+
assert result2 == "ran"

tests/tool_safety/test_rules.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,3 +452,28 @@ def test_dependency_executable_context_still_flagged():
452452
_policy(),
453453
)
454454
assert os_findings
455+
456+
457+
def test_dangerous_files_pathlib_write_system_dir():
458+
"""Path('/usr/bin/x').write_text must DENY (system-dir write, not just
459+
sensitive-name path construction)."""
460+
rule = DangerousFilesRule()
461+
for script in (
462+
"from pathlib import Path\nPath('/usr/bin/evil').write_text('x')\n",
463+
"from pathlib import Path\nPath('/usr/bin/evil').write_bytes(b'x')\n",
464+
"from pathlib import Path\nPath('/usr/local').rmdir()\n",
465+
):
466+
findings = rule.check(ScanInput(script=script, language="python"), _policy())
467+
assert any("R001" in f.rule_id for f in findings), f"missed: {script!r}"
468+
469+
470+
def test_dangerous_files_open_dynamic_mode_system_dir():
471+
"""open('/usr/bin/x', mode_var) must be treated as write (fail-closed)."""
472+
rule = DangerousFilesRule()
473+
for script in (
474+
"mode='w'\nopen('/usr/bin/x', mode)\n",
475+
"mode=mode_var\nopen('/usr/bin/x', mode)\n",
476+
"open('/usr/bin/x', mode='a')\n",
477+
):
478+
findings = rule.check(ScanInput(script=script, language="python"), _policy())
479+
assert any("R001" in f.rule_id for f in findings), f"missed: {script!r}"

tests/tool_safety/test_wrapper.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -265,13 +265,16 @@ def test_skill_runner_block_review_overrides_policy_records_intercepted(tmp_path
265265
assert rec["intercepted"] is True
266266

267267

268-
def test_safety_wrapper_raise_on_deny_false_records_intercepted_false(tmp_path: Path):
269-
"""When raise_on_deny=False, a DENY hit must not be recorded as intercepted.
270-
271-
Regression for CongkeChen's review: intercepted=report.blocked used
272-
policy.block_on_review, so a DENY with raise_on_deny=False was falsely
273-
recorded as intercepted=True. The actual interception is raise_on_deny.
268+
def test_safety_wrapper_raise_on_deny_false_still_blocks_and_audits(tmp_path: Path):
269+
"""raise_on_deny=False must still intercept DENY (not run the function).
270+
271+
CongkeChen review: intercepted = should_block and raise_on_deny made
272+
raise_on_deny=False silently execute DENY scripts and audit
273+
intercepted=False. Correct semantics:
274+
- should_block always intercepts (function not called, audit True)
275+
- raise_on_deny only controls raise vs return deny dict
274276
"""
277+
executed = []
275278

276279
@safety_wrapper(
277280
tool_name="denied_tool",
@@ -280,14 +283,18 @@ def test_safety_wrapper_raise_on_deny_false_records_intercepted_false(tmp_path:
280283
raise_on_deny=False,
281284
)
282285
async def run_tool(*, script):
286+
executed.append(script)
283287
return "ran"
284288

285289
result = asyncio.run(run_tool(script="rm -rf /"))
286290

287-
assert result == "ran"
291+
assert executed == [], "DENY must not run the wrapped function"
292+
assert isinstance(result, dict)
293+
assert result.get("success") is False
294+
assert result.get("error") == "TOOL_SAFETY_DENY"
288295

289296
audit_path = tmp_path / "audit.jsonl"
290297
assert audit_path.exists()
291298
rec = json.loads(audit_path.read_text(encoding="utf-8").strip().splitlines()[-1])
292299
assert rec["decision"] == "deny"
293-
assert rec["intercepted"] is False
300+
assert rec["intercepted"] is True

trpc_agent_sdk/safety/_rules.py

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -238,10 +238,13 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
238238
"os.unlink",
239239
"pathlib.path.unlink",
240240
"pathlib.Path.unlink",
241-
} or lname.endswith(".unlink") or lname.endswith(".rmtree"):
241+
} or lname.endswith(".unlink") or lname.endswith(".rmtree") or lname.endswith(".rmdir"):
242242
arg = node.args[0] if node.args else None
243-
target = get_string_literal(arg) or path_expr_text(arg) if arg else "<dynamic>"
244-
target = target or "<dynamic>"
243+
if arg is not None:
244+
target = get_string_literal(arg) or path_expr_text(arg) or "<dynamic>"
245+
else:
246+
# Path(...).unlink() / Path(...).rmdir() — path is the receiver.
247+
target = _path_target_from_attr(node, aliases, path_vars) or "<dynamic>"
245248
if "rmtree" in lname or _is_recursive_delete(name, node):
246249
findings.append(
247250
self._finding(
@@ -269,17 +272,20 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
269272
var = node.args[0].id
270273
if var in path_vars:
271274
target = path_vars[var]
275+
# Non-constant mode is treated as potentially writable (fail-closed)
276+
# so open("/usr/bin/x", mode_var) cannot bypass system-dir writes.
272277
if _is_write_open(node):
273278
if _matches_sensitive(target) or _matches_forbidden(target, policy) or _matches_system_dir(target):
274279
findings.append(
275280
self._finding(
276-
f"open({target!r}, 'w')",
281+
f"open({target!r}, write)",
277282
node.lineno,
278283
"Do not write to system or credential paths.",
279284
message=f"Write to sensitive path {target!r}",
280285
))
281286
else:
282-
if _matches_sensitive(target) or _matches_forbidden(target, policy):
287+
if (_matches_sensitive(target) or _matches_forbidden(target, policy)
288+
or _matches_system_dir(target)):
283289
findings.append(
284290
self._finding(
285291
f"{name}({target!r})",
@@ -290,8 +296,8 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
290296

291297
# pathlib Path.read_text / read_bytes with sensitive target
292298
if lname.endswith(".read_text") or lname.endswith(".read_bytes"):
293-
target = _path_from_attr_call(node, aliases)
294-
if _matches_sensitive(target) or _matches_forbidden(target, policy):
299+
target = _path_target_from_attr(node, aliases, path_vars)
300+
if (_matches_sensitive(target) or _matches_forbidden(target, policy) or _matches_system_dir(target)):
295301
findings.append(
296302
self._finding(
297303
f"{name}(...)",
@@ -300,6 +306,29 @@ def _check_python(self, scan_input: ScanInput, policy: PolicyConfig) -> list[Saf
300306
message=f"Read sensitive file via pathlib {target!r}",
301307
))
302308

309+
# pathlib write / mkdir / touch / rename on sensitive, forbidden, or system
310+
# paths. Missing write_* left Path("/usr/bin/x").write_text(...) as
311+
# ALLOW (fail-open) because only path-construction of sensitive names
312+
# was covered, not system-dir writes.
313+
if (lname.endswith(".write_text") or lname.endswith(".write_bytes") or lname.endswith(".mkdir")
314+
or lname.endswith(".touch") or lname.endswith(".replace") or lname.endswith(".rename")
315+
or lname.endswith(".chmod")):
316+
target = _path_target_from_attr(node, aliases, path_vars)
317+
if target and (_matches_sensitive(target) or _matches_forbidden(target, policy)
318+
or _matches_system_dir(target)):
319+
if lname.endswith(".write_text") or lname.endswith(".write_bytes"):
320+
msg = f"Write to sensitive path via pathlib {target!r}"
321+
rec = "Do not write to system or credential paths."
322+
else:
323+
msg = f"Mutate sensitive path via pathlib {name}({target!r})"
324+
rec = "Do not mutate system or credential paths via pathlib."
325+
findings.append(self._finding(
326+
f"{name}(...)",
327+
node.lineno,
328+
rec,
329+
message=msg,
330+
))
331+
303332
# Path(...).joinpath(...).read_text pattern: also inspect path construction
304333
if "pathlib" in lname or lname.endswith("path") or lname.endswith("joinpath") or lname in {
305334
"os.path.join",
@@ -391,16 +420,30 @@ def _is_recursive_delete(name: str, node: ast.Call) -> bool:
391420

392421

393422
def _is_write_open(node: ast.Call) -> bool:
423+
"""True when open() may write.
424+
425+
Constant modes containing w/a/x/+ are writes. A present but non-constant
426+
mode (Name/Call/…) is treated as write (fail-closed) so
427+
``open("/usr/bin/x", mode_var)`` cannot bypass system-dir checks.
428+
Missing mode defaults to read-only.
429+
"""
394430
mode_val = None
431+
mode_dynamic = False
395432
for kw in node.keywords:
396433
if kw.arg == "mode":
397434
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
398435
mode_val = kw.value.value
436+
else:
437+
mode_dynamic = True
399438
break
400-
if mode_val is None and len(node.args) >= 2:
439+
if mode_val is None and not mode_dynamic and len(node.args) >= 2:
401440
arg = node.args[1]
402441
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
403442
mode_val = arg.value
443+
else:
444+
mode_dynamic = True
445+
if mode_dynamic:
446+
return True
404447
if not mode_val:
405448
return False
406449
return any(m in mode_val for m in ("w", "a", "x", "+"))
@@ -424,6 +467,21 @@ def _path_from_attr_call(node: ast.Call, aliases: dict[str, str]) -> str:
424467
return ""
425468

426469

470+
def _path_target_from_attr(
471+
node: ast.Call,
472+
aliases: dict[str, str],
473+
path_vars: dict[str, str],
474+
) -> str:
475+
"""Path string for Path(...).method() / p.method() calls."""
476+
target = _path_from_attr_call(node, aliases)
477+
func = node.func
478+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
479+
var = func.value.id
480+
if var in path_vars:
481+
target = path_vars[var] or target
482+
return target or ""
483+
484+
427485
def _collect_sensitive_path_vars(
428486
tree: ast.AST,
429487
aliases: dict[str, str],

trpc_agent_sdk/safety/_wrapper.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ class _Outcome:
7474

7575
def __init__(self, name: str):
7676
self.name = name
77+
# Match real Outcome enum contract (downstream reads .value).
78+
self.value = name
7779

7880
class _CodeExecutionResult:
7981

@@ -251,6 +253,15 @@ def safety_wrapper(
251253
scanned for simple callables. A positional dict containing *script_arg*
252254
is also accepted. Variadic ``*args`` callables without a named parameter
253255
still require a keyword.
256+
257+
Blocking semantics match ToolSafetyFilter / SafetyGuardedCodeExecutor:
258+
259+
- ``should_block`` (DENY, or NEEDS_HUMAN_REVIEW with block_on_review) always
260+
prevents the wrapped function from running and records
261+
``intercepted=True`` in the audit log.
262+
- ``raise_on_deny`` only controls whether a :class:`SafetyDeniedError` is
263+
raised (True) or a deny dict is returned (False). It must **not** allow
264+
the wrapped function to run when the decision is block.
254265
"""
255266
import inspect
256267

@@ -279,28 +290,43 @@ def _extract_script(func, args, kwargs):
279290
return None
280291

281292
def _guard(func, args, kwargs):
293+
"""Return (blocked, report_or_none). blocked=True means do not call func."""
282294
script = _extract_script(func, args, kwargs)
283295
if not script or not isinstance(script, str):
284-
return
296+
return False, None
285297
report = _scanner.scan(ScanInput(script=script, tool_name=tool_name))
286-
# Honor block_on_review (policy or explicit) so decorator semantics
287-
# match ToolSafetyFilter / SafetyGuardedCodeExecutor.
288298
should_block = _should_block_report(report, _block_review)
289-
intercepted = should_block and raise_on_deny
290-
_audit.log(report, intercepted=intercepted)
291-
if intercepted:
292-
raise SafetyDeniedError(report)
299+
# Audit reflects real interception (should_block), not whether we raise.
300+
_audit.log(report, intercepted=should_block)
301+
if should_block:
302+
if raise_on_deny:
303+
raise SafetyDeniedError(report)
304+
return True, report
305+
return False, report
306+
307+
def _deny_payload(report):
308+
return {
309+
"success": False,
310+
"error": ("TOOL_SAFETY_DENY" if report.decision == Decision.DENY else "TOOL_SAFETY_NEEDS_REVIEW"),
311+
"decision": report.decision.value,
312+
"risk_level": report.risk_level.value,
313+
"rule_ids": list(report.rule_ids),
314+
}
293315

294316
def decorator(func):
295317

296318
@functools.wraps(func)
297319
async def async_wrapper(*args, **kwargs):
298-
_guard(func, args, kwargs)
320+
blocked, report = _guard(func, args, kwargs)
321+
if blocked:
322+
return _deny_payload(report)
299323
return await func(*args, **kwargs)
300324

301325
@functools.wraps(func)
302326
def sync_wrapper(*args, **kwargs):
303-
_guard(func, args, kwargs)
327+
blocked, report = _guard(func, args, kwargs)
328+
if blocked:
329+
return _deny_payload(report)
304330
return func(*args, **kwargs)
305331

306332
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper

trpc_agent_sdk/tools/file_tools/_bash_tool.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,19 @@ def _attach_safety_guard(
6868
block_on_review: bool,
6969
) -> None:
7070
"""Attach ToolSafetyFilter when enable_safety_guard is True."""
71+
from trpc_agent_sdk import safety as safety_pkg
7172
from trpc_agent_sdk.safety import PolicyConfig
7273
from trpc_agent_sdk.safety import ToolSafetyFilter
7374

75+
# When optional filter deps fail to import, ToolSafetyFilter is None and
76+
# import_error_for holds the original ImportError. Raise that instead of
77+
# a confusing TypeError: NoneType is not callable at construction time.
78+
if ToolSafetyFilter is None:
79+
err = safety_pkg.import_error_for("ToolSafetyFilter")
80+
if err is not None:
81+
raise err
82+
raise ImportError("ToolSafetyFilter is unavailable")
83+
7484
if policy_path:
7585
policy = PolicyConfig.from_yaml(policy_path)
7686
else:

0 commit comments

Comments
 (0)