Skip to content

Commit dbb0c00

Browse files
committed
feat(tool_safety): add safety_wrapper decorator and Skill runner wrapper
1 parent a8bf890 commit dbb0c00

7 files changed

Lines changed: 624 additions & 7 deletions

File tree

examples/tool_safety/README.md

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,66 @@ policy = PolicyConfig.from_yaml("...")
139139
tool = wrap_tool(existing_tool, policy, audit_path="audit.jsonl")
140140
```
141141

142-
### 方式 3:独立 CLI 扫描
142+
`wrap_tool` 同样适用于 Skill 执行链路。SDK 的 Skill 能力
143+
(`SkillRunTool`、`SkillExecTool`、`WorkspaceExecTool` 等)本身都是
144+
`BaseTool` 子类,其执行参数中包含 `command` 字段,会被 `ToolSafetyFilter`
145+
的参数提取器(`_SCRIPT_ARG_KEYS` 首位即 `"command"`)自动捕获。因此给
146+
Skill tool 挂 filter 即可在 skill 脚本执行前完成静态扫描:
143147

144-
见 [快速开始](#快速开始),适合 CI 流水线或人工预检。
148+
```python
149+
from trpc_agent_sdk.skills.tools import SkillRunTool, SkillExecTool
150+
151+
skill_run = SkillRunTool(...)
152+
wrapped_skill = wrap_tool(skill_run, policy, audit_path="audit.jsonl")
153+
```
154+
155+
也可在 Runner 层面统一挂 filter,使所有 tool(含 Skill tool、BashTool、
156+
CodeExecutor 暴露的 tool)共享同一道安全检查。
157+
158+
### 方式 3:装饰器包装任意函数
159+
160+
`@safety_wrapper` 装饰器在函数执行前扫描其 `script_arg` 参数,DENY 时抛
161+
`SafetyDeniedError`。同步/异步函数均支持,适合包装自定义执行入口:
162+
163+
```python
164+
from examples.tool_safety.safety import safety_wrapper, SafetyDeniedError, PolicyConfig
165+
166+
policy = PolicyConfig.from_yaml("...")
167+
168+
@safety_wrapper(tool_name="my_runner", script_arg="code", policy=policy,
169+
audit_path="audit.jsonl")
170+
async def execute(*, tool_context, args):
171+
code = args["code"]
172+
return await run_code(code)
173+
174+
# DENY 时抛 SafetyDeniedError(report 附在异常上),ALLOW 时正常执行。
175+
```
176+
177+
### 方式 4:Skill runner 专用 wrapper
178+
179+
`SafetyReviewedSkillRunner` 专为 Skill 执行路径设计,包装任何带
180+
`run_async(tool_context=, args=)` 的 runner,DENY 时返回阻塞响应而不调用
181+
内部 runner:
182+
183+
```python
184+
from examples.tool_safety.safety import SafetyReviewedSkillRunner, PolicyConfig
185+
186+
policy = PolicyConfig.from_yaml("...")
187+
safe_skill = SafetyReviewedSkillRunner(
188+
my_skill_runner, policy, audit_path="audit.jsonl",
189+
tool_name="skill_run", block_review=False, # True 时 needs_review 也阻塞
190+
)
191+
result = await safe_skill.run(tool_context, args)
192+
# DENY → {"success": False, "error": "SKILL_BLOCKED", "safety": {...}}
193+
# ALLOW → 委托给 my_skill_runner
194+
```
195+
196+
### 方式 5:独立 CLI 扫描
197+
198+
见 [快速开始](#快速开始),适合 CI 流水线或人工预检。CLI 退出码:
199+
- `0` = 全部 allow
200+
- `1` = 存在 deny(高危)
201+
- `2` = 无 deny 但有 needs_review(可用作 CI gate)
145202

146203
接入点说明:SDK 的 `BaseTool.run_async` 在调用 `_run_async_impl` 前会先跑 `_run_filters`,
147204
本 Filter 正是挂在这个前置位置,因此能在执行前拦截高危脚本。
@@ -209,7 +266,7 @@ pytest tests/tool_safety/ -v
209266
| 4. 500 行脚本 ≤1s | `test_performance.py` | ✅ <1s |
210267
| 5. 报告含 decision/risk_level/rule_id/evidence/recommendation | `test_scanner.py::_assert_report_well_formed` | ✅ |
211268
| 6. 改策略不改代码即生效 | `test_policy.py::test_hot_reload_*` | ✅ |
212-
| 7. Filter 执行前拒绝高危 + 写审计 | `test_tool_filter.py` | ✅ 4 case |
269+
| 7. Filter/wrapper 执行前拒绝高危 + 写审计 | `test_tool_filter.py`(4 case)+ `test_wrapper.py`(9 case) | ✅ 13 case |
213270
| 8. 文档说明与沙箱等关系 | 本 README 末节 | ✅ |
214271

215272
### 12 条样本
@@ -259,6 +316,8 @@ pytest tests/tool_safety/ -v
259316

260317
## 扩展新规则
261318

319+
### 方式 A:实现 SafetyRule 子类(内置规则推荐)
320+
262321
1. 在 [`safety/rules/`](safety/rules/) 下新建文件,实现 `SafetyRule` 子类:
263322

264323
```python
@@ -284,6 +343,43 @@ class MyRule(SafetyRule):
284343

285344
4. 在 `tests/tool_safety/test_rules.py` 添加单测。
286345

346+
### 方式 B:运行时注册自定义规则(插件/用户代码推荐)
347+
348+
无需改源码,在运行时调用 `register_custom_rule()` 即可让所有新建的
349+
`SafetyScanner` 自动包含该规则:
350+
351+
```python
352+
from examples.tool_safety.safety import register_custom_rule, SafetyScanner, PolicyConfig
353+
from examples.tool_safety.safety.rules.base import SafetyRule
354+
from examples.tool_safety.safety.types import RiskLevel, SafetyFinding
355+
356+
class CompanyPolicyRule(SafetyRule):
357+
rule_id = "CUSTOM_company_policy"
358+
rule_name = "Company-specific banned API"
359+
risk_type = "custom"
360+
default_level = RiskLevel.HIGH
361+
languages = ("python",)
362+
363+
def check(self, scan_input, policy):
364+
if "internal_unstable_api" in scan_input.script:
365+
return [SafetyFinding(
366+
rule_id=self.rule_id, rule_name=self.rule_name,
367+
risk_type=self.risk_type, risk_level=self.default_level,
368+
evidence="internal_unstable_api", line=1,
369+
recommendation="Use the stable API instead.",
370+
)]
371+
return []
372+
373+
# 注册后,所有新建的 SafetyScanner 都会包含这条规则。
374+
register_custom_rule(CompanyPolicyRule())
375+
376+
scanner = SafetyScanner(policy=PolicyConfig())
377+
assert "CUSTOM_company_policy" in [r.rule_id for r in scanner.rules]
378+
```
379+
380+
运行时注册适合:插件系统、用户私有规则、不希望改框架源码的集成场景。
381+
已注册的规则同样受 `disabled_rules` 策略控制。
382+
287383
---
288384

289385
## Relationship with Sandbox / Filter / Telemetry / CodeExecutor

examples/tool_safety/safety/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from .rules.base import SafetyRule
2828
from .scanner import SCANNER_VERSION
2929
from .scanner import SafetyScanner
30+
from .scanner import register_custom_rule
3031
from .types import Decision
3132
from .types import max_risk_level
3233
from .types import RiskLevel
@@ -48,9 +49,15 @@
4849
try: # pragma: no cover
4950
from .wrapper import SafeCodeExecutor
5051
from .wrapper import wrap_tool
52+
from .wrapper import safety_wrapper
53+
from .wrapper import SafetyDeniedError
54+
from .wrapper import SafetyReviewedSkillRunner
5155
except Exception: # pylint: disable=broad-except
5256
SafeCodeExecutor = None # type: ignore[assignment]
5357
wrap_tool = None # type: ignore[assignment]
58+
safety_wrapper = None # type: ignore[assignment]
59+
SafetyDeniedError = None # type: ignore[assignment]
60+
SafetyReviewedSkillRunner = None # type: ignore[assignment]
5461

5562
__all__ = [
5663
"AuditLogger",
@@ -60,6 +67,7 @@
6067
"SafetyRule",
6168
"SCANNER_VERSION",
6269
"SafetyScanner",
70+
"register_custom_rule",
6371
"ToolSafetyFilter",
6472
"Decision",
6573
"max_risk_level",
@@ -69,5 +77,8 @@
6977
"ScanInput",
7078
"SafeCodeExecutor",
7179
"wrap_tool",
80+
"safety_wrapper",
81+
"SafetyDeniedError",
82+
"SafetyReviewedSkillRunner",
7283
"_SDK_AVAILABLE",
7384
]

examples/tool_safety/safety/scanner.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@
3131

3232
SCANNER_VERSION = "1.0.0"
3333

34+
# Module-level custom rule registry. Rules registered here are included in
35+
# every new SafetyScanner that does not pass an explicit *rules* argument.
36+
_custom_rules: list[SafetyRule] = []
37+
38+
39+
def register_custom_rule(rule: SafetyRule) -> None:
40+
"""Register a custom rule to be included in all new scanners by default.
41+
42+
Existing SafetyScanner instances are unaffected; only scanners created
43+
after registration will include the new rule.
44+
"""
45+
_custom_rules.append(rule)
46+
3447

3548
class SafetyScanner:
3649
"""Runs registered rules against a script and produces a SafetyReport."""
@@ -41,7 +54,7 @@ def __init__(
4154
rules: Optional[list[SafetyRule]] = None,
4255
):
4356
self.policy = policy
44-
self.rules = rules if rules is not None else default_rules()
57+
self.rules = rules if rules is not None else default_rules() + list(_custom_rules)
4558

4659
def scan(self, scan_input: ScanInput) -> SafetyReport:
4760
"""Scan *scan_input* and return a structured SafetyReport."""

examples/tool_safety/safety/wrapper.py

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,23 @@
66
"""Wrapper helpers showing how to attach the safety filter to existing tools
77
and code executors without modifying SDK internals.
88
9-
Two shapes are provided:
9+
Four shapes are provided:
1010
- wrap_tool: returns a new tool with the safety filter prepended.
11-
- wrap_executor: returns a new BaseCodeExecutor subclass whose execute_code
11+
- SafeCodeExecutor: returns a new BaseCodeExecutor subclass whose execute_code
1212
runs the scanner before delegating to the wrapped executor.
13+
- safety_wrapper: decorator that scans a function's script argument before
14+
the function body runs (sync or async).
15+
- SafetyReviewedSkillRunner: wraps a skill runner callable with pre-execution
16+
safety scanning, covering the Skill execution path.
1317
"""
1418
from __future__ import annotations
1519

20+
import asyncio
21+
import functools
1622
from typing import Any
1723
from typing import Optional
1824

25+
from .audit import AuditLogger
1926
from .policy import PolicyConfig
2027
from .scanner import SafetyScanner
2128
from .tool_filter import ToolSafetyFilter
@@ -59,3 +66,150 @@ async def execute_code(self, invocation_context, input_data):
5966
return await inner.execute_code(invocation_context, input_data)
6067

6168
return _SafeCodeExecutor()
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# Exception
73+
# ---------------------------------------------------------------------------
74+
75+
76+
class SafetyDeniedError(RuntimeError):
77+
"""Raised when a safety wrapper blocks a script (decision == DENY)."""
78+
79+
def __init__(self, report):
80+
self.report = report
81+
rule_ids = report.rule_ids if report.rule_ids else ["unknown"]
82+
super().__init__(f"script denied by rule(s) {rule_ids}")
83+
84+
85+
# ---------------------------------------------------------------------------
86+
# Decorator
87+
# ---------------------------------------------------------------------------
88+
89+
90+
def safety_wrapper(tool_name="unknown", *, script_arg="script",
91+
policy=None, audit_path=None, raise_on_deny=True):
92+
"""Decorator: scan the *script_arg* of a function before it runs.
93+
94+
Works on both sync and async functions. When the scan decision is DENY
95+
and *raise_on_deny* is True, raises :class:`SafetyDeniedError`.
96+
97+
The decorated function's keyword argument named *script_arg* (or a key
98+
inside a dict positional argument) is scanned before the body runs.
99+
100+
Example::
101+
102+
@safety_wrapper(tool_name="runner", script_arg="code")
103+
async def execute(*, tool_context, args):
104+
...
105+
106+
Args:
107+
tool_name: Name used in audit/report.
108+
script_arg: Name of the kwarg (or key in a dict positional arg)
109+
that holds the script text.
110+
policy: Optional PolicyConfig; uses defaults when None.
111+
audit_path: Optional path for JSONL audit log.
112+
raise_on_deny: Raise SafetyDeniedError on DENY (default True).
113+
"""
114+
if policy is None:
115+
policy = PolicyConfig()
116+
_scanner = SafetyScanner(policy=policy)
117+
_audit = AuditLogger(audit_path)
118+
119+
def _extract_script(args, kwargs):
120+
script = kwargs.get(script_arg)
121+
if script is None:
122+
for arg in args:
123+
if isinstance(arg, dict) and script_arg in arg:
124+
return arg[script_arg]
125+
return script
126+
127+
def _guard(args, kwargs):
128+
script = _extract_script(args, kwargs)
129+
if not script or not isinstance(script, str):
130+
return
131+
report = _scanner.scan(ScanInput(script=script, tool_name=tool_name))
132+
_audit.log(report, intercepted=report.blocked)
133+
if report.decision == Decision.DENY and raise_on_deny:
134+
raise SafetyDeniedError(report)
135+
136+
def decorator(func):
137+
@functools.wraps(func)
138+
async def async_wrapper(*args, **kwargs):
139+
_guard(args, kwargs)
140+
return await func(*args, **kwargs)
141+
142+
@functools.wraps(func)
143+
def sync_wrapper(*args, **kwargs):
144+
_guard(args, kwargs)
145+
return func(*args, **kwargs)
146+
147+
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
148+
149+
return decorator
150+
151+
152+
# ---------------------------------------------------------------------------
153+
# Skill runner wrapper
154+
# ---------------------------------------------------------------------------
155+
156+
157+
class SafetyReviewedSkillRunner:
158+
"""Wrap a skill runner callable with pre-execution safety scanning.
159+
160+
Covers the Skill execution path: works with any callable that accepts
161+
``(tool_context, args)`` or has a ``run_async(tool_context=, args=)``
162+
method. When the scan decision is DENY (or NEEDS_HUMAN_REVIEW when
163+
*block_review* is True), execution is skipped and a blocked-response
164+
dict is returned.
165+
166+
Example::
167+
168+
safe_skill = SafetyReviewedSkillRunner(
169+
my_skill_runner, policy=policy, tool_name="skill_run",
170+
)
171+
result = await safe_skill.run(tool_context, args)
172+
"""
173+
174+
def __init__(self, runner, policy, *, audit_path=None,
175+
block_review=False, tool_name="skill_run"):
176+
self._runner = runner
177+
self._scanner = SafetyScanner(policy=policy)
178+
self._audit = AuditLogger(audit_path)
179+
self._block_review = block_review
180+
self._tool_name = tool_name
181+
182+
async def run(self, tool_context, args):
183+
"""Scan skill args and delegate to the wrapped runner when allowed."""
184+
script = self._extract_script(args)
185+
if script:
186+
report = self._scanner.scan(
187+
ScanInput(script=script, tool_name=self._tool_name)
188+
)
189+
self._audit.log(report, intercepted=report.blocked)
190+
if report.decision == Decision.DENY:
191+
return {"success": False, "error": "SKILL_BLOCKED",
192+
"safety": report.to_dict()}
193+
if report.decision == Decision.NEEDS_HUMAN_REVIEW and self._block_review:
194+
return {"success": False, "error": "SKILL_NEEDS_REVIEW",
195+
"safety": report.to_dict()}
196+
197+
# Delegate to the wrapped runner.
198+
if hasattr(self._runner, "run_async"):
199+
result = self._runner.run_async(tool_context=tool_context, args=args)
200+
else:
201+
result = self._runner(tool_context, args)
202+
if hasattr(result, "__await__"):
203+
return await result
204+
return result
205+
206+
@staticmethod
207+
def _extract_script(args):
208+
"""Pull script content from common skill arg shapes."""
209+
if not isinstance(args, dict):
210+
return None
211+
for key in ("script", "code", "command", "cmd"):
212+
val = args.get(key)
213+
if isinstance(val, str):
214+
return val
215+
return None

examples/tool_safety/tool_safety_check.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,12 @@ def main(argv: Optional[list[str]] = None) -> int:
114114
print(f"\nSummary: {len(all_reports)} scanned | "
115115
f"{allowed} allow | {denied} deny | {review} needs_review")
116116

117-
return 1 if any_denied else 0
117+
# Exit codes: 0=clean, 1=denied, 2=needs_review (usable as CI gate).
118+
if any_denied:
119+
return 1
120+
if review > 0:
121+
return 2
122+
return 0
118123

119124

120125
def _infer_language(path: Path) -> str:

0 commit comments

Comments
 (0)