Skip to content

Commit 7b61895

Browse files
committed
feat: add tool safety guard
1 parent 73655ab commit 7b61895

14 files changed

Lines changed: 1933 additions & 1 deletion

trpc_agent_sdk/tools/_base_tool.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,11 @@ async def run_async(self, *, tool_context: InvocationContext, args: dict[str, An
177177
after_tool_callback = getattr(tool_context.agent, "after_tool_callback", None)
178178
# Import here to avoid circular import
179179
from trpc_agent_sdk.agents import ToolCallbackFilter
180-
extra_filters = [ToolCallbackFilter(before_tool_callback, after_tool_callback)]
180+
from trpc_agent_sdk.tools.safety.filter import ToolSafetyFilter
181+
extra_filters = [
182+
ToolSafetyFilter(),
183+
ToolCallbackFilter(before_tool_callback, after_tool_callback),
184+
]
181185

182186
token = set_tool_var(self)
183187
handler = partial(self._run_async_impl, tool_context=tool_context, args=args)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
"""Tool script safety guard framework."""
7+
8+
from .audit import DEFAULT_AUDIT_LOG_FILE
9+
from .audit import SafetyAuditLogger
10+
from .audit import build_audit_record
11+
from .audit import risk_level
12+
from .bash_scanner import AptInstallRule
13+
from .bash_scanner import BackgroundExecutionRule
14+
from .bash_scanner import BashScanner
15+
from .bash_scanner import CurlRule
16+
from .bash_scanner import ForkBombRule
17+
from .bash_scanner import LongSleepRule
18+
from .bash_scanner import NpmInstallRule
19+
from .bash_scanner import PipInstallRule
20+
from .bash_scanner import RmRfRule
21+
from .bash_scanner import ShellPipeRule
22+
from .bash_scanner import SudoRule
23+
from .bash_scanner import WgetRule
24+
from .bash_scanner import create_bash_rules
25+
from .checker import Rule
26+
from .checker import SafetyChecker
27+
from .decision import DecisionEngine
28+
from .filter import ToolSafetyFilter
29+
from .models import Finding
30+
from .models import SafetyDecision
31+
from .models import SafetyResult
32+
from .models import SafetySeverity
33+
from .models import ToolExecutionRequest
34+
from .policy import DEFAULT_POLICY_FILE
35+
from .policy import PolicyLoader
36+
from .policy import SAFETY_POLICY_ENV
37+
from .policy import SafetyPolicy
38+
from .python_scanner import EnvFileReadRule
39+
from .python_scanner import OsSystemRule
40+
from .python_scanner import PythonScanner
41+
from .python_scanner import RequestsGetPostRule
42+
from .python_scanner import ShutilRmtreeRule
43+
from .python_scanner import SocketConnectRule
44+
from .python_scanner import SshPathReadRule
45+
from .python_scanner import SubprocessPopenRule
46+
from .python_scanner import SubprocessRunRule
47+
from .python_scanner import create_python_rules
48+
from .report import DEFAULT_REPORT_FILE
49+
from .report import SafetyReportWriter
50+
from .report import build_report
51+
from .telemetry import record_safety_attributes
52+
from .wrapper import SafetyExecutionWrapper
53+
from .wrapper import SafetyViolationError
54+
55+
__all__ = [
56+
"DEFAULT_AUDIT_LOG_FILE",
57+
"SafetyAuditLogger",
58+
"build_audit_record",
59+
"risk_level",
60+
"AptInstallRule",
61+
"BackgroundExecutionRule",
62+
"BashScanner",
63+
"CurlRule",
64+
"ForkBombRule",
65+
"LongSleepRule",
66+
"NpmInstallRule",
67+
"PipInstallRule",
68+
"RmRfRule",
69+
"ShellPipeRule",
70+
"SudoRule",
71+
"WgetRule",
72+
"create_bash_rules",
73+
"Rule",
74+
"SafetyChecker",
75+
"DecisionEngine",
76+
"ToolSafetyFilter",
77+
"Finding",
78+
"SafetyDecision",
79+
"SafetyResult",
80+
"SafetySeverity",
81+
"ToolExecutionRequest",
82+
"DEFAULT_POLICY_FILE",
83+
"PolicyLoader",
84+
"SAFETY_POLICY_ENV",
85+
"SafetyPolicy",
86+
"EnvFileReadRule",
87+
"OsSystemRule",
88+
"PythonScanner",
89+
"RequestsGetPostRule",
90+
"ShutilRmtreeRule",
91+
"SocketConnectRule",
92+
"SshPathReadRule",
93+
"SubprocessPopenRule",
94+
"SubprocessRunRule",
95+
"create_python_rules",
96+
"DEFAULT_REPORT_FILE",
97+
"SafetyReportWriter",
98+
"build_report",
99+
"record_safety_attributes",
100+
"SafetyExecutionWrapper",
101+
"SafetyViolationError",
102+
]
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
"""JSONL audit logging for tool safety checks."""
7+
8+
from __future__ import annotations
9+
10+
import json
11+
import time
12+
from datetime import datetime
13+
from datetime import timezone
14+
from pathlib import Path
15+
from typing import Any
16+
from typing import Optional
17+
18+
from .models import Finding
19+
from .models import SafetyDecision
20+
from .models import SafetyResult
21+
from .models import SafetySeverity
22+
from .models import ToolExecutionRequest
23+
24+
DEFAULT_AUDIT_LOG_FILE = Path("tool_safety_audit.jsonl")
25+
_SENSITIVE_KEYS = {"api_key", "authorization", "cookie", "password", "secret", "token"}
26+
_SCRIPT_KEYS = {"bash_code", "cmd", "code", "command", "python_code", "script"}
27+
_SEVERITY_RANK = {
28+
SafetySeverity.INFO: 0,
29+
SafetySeverity.LOW: 1,
30+
SafetySeverity.MEDIUM: 2,
31+
SafetySeverity.HIGH: 3,
32+
SafetySeverity.CRITICAL: 4,
33+
}
34+
35+
36+
class SafetyAuditLogger:
37+
"""Append tool safety audit events as JSON Lines."""
38+
39+
def __init__(self, path: str | Path = DEFAULT_AUDIT_LOG_FILE):
40+
self._path = Path(path)
41+
42+
@property
43+
def path(self) -> Path:
44+
"""Return the audit log path."""
45+
return self._path
46+
47+
def write(self, result: SafetyResult, latency_ms: float) -> None:
48+
"""Write one audit record."""
49+
record = build_audit_record(result, latency_ms)
50+
with self._path.open("a", encoding="utf-8") as fp:
51+
fp.write(json.dumps(record, ensure_ascii=False, sort_keys=True))
52+
fp.write("\n")
53+
54+
55+
def build_audit_record(result: SafetyResult, latency_ms: float) -> dict[str, Any]:
56+
"""Build one ELK/Grafana-friendly audit record."""
57+
request = result.request or ToolExecutionRequest()
58+
current_risk_level = risk_level(result.findings)
59+
rule_ids = [finding.rule_id for finding in result.findings]
60+
return {
61+
"timestamp": _utc_now(),
62+
"tool_name": request.tool_name,
63+
"decision": result.decision.value,
64+
"risk_level": current_risk_level.value,
65+
"rule_id": ",".join(rule_ids),
66+
"rule_ids": rule_ids,
67+
"latency": round(latency_ms, 3),
68+
"latency_ms": round(latency_ms, 3),
69+
"blocked": result.decision != SafetyDecision.ALLOW,
70+
"desensitized": True,
71+
"agent_name": request.agent_name,
72+
"invocation_id": request.invocation_id,
73+
"function_call_id": request.function_call_id,
74+
"language": request.language,
75+
"finding_count": len(result.findings),
76+
"findings": [_finding_record(finding) for finding in result.findings],
77+
"request": _request_record(request),
78+
}
79+
80+
81+
def risk_level(findings: list[Finding]) -> SafetySeverity:
82+
"""Return the highest severity represented by a list of findings."""
83+
if not findings:
84+
return SafetySeverity.LOW
85+
return max((finding.severity for finding in findings), key=lambda severity: _SEVERITY_RANK[severity])
86+
87+
88+
def _finding_record(finding: Finding) -> dict[str, Any]:
89+
return {
90+
"rule_id": finding.rule_id,
91+
"severity": finding.severity.value,
92+
"target": _desensitize_value(finding.target),
93+
"message": finding.message,
94+
"metadata": _desensitize_value(finding.metadata),
95+
}
96+
97+
98+
def _request_record(request: ToolExecutionRequest) -> dict[str, Any]:
99+
return {
100+
"args": _desensitize_args(request.args),
101+
"metadata": _desensitize_value(request.metadata),
102+
"script_present": bool(request.script),
103+
"script_length": len(request.script or ""),
104+
}
105+
106+
107+
def _desensitize_args(args: dict[str, Any]) -> dict[str, Any]:
108+
return {
109+
str(key): _desensitize_arg_value(str(key), value)
110+
for key, value in args.items()
111+
}
112+
113+
114+
def _desensitize_arg_value(key: str, value: Any) -> Any:
115+
lowered_key = key.lower()
116+
if lowered_key in _SCRIPT_KEYS:
117+
return _desensitize_script_value(value)
118+
if _is_sensitive_key(lowered_key):
119+
return "***"
120+
return _desensitize_value(value)
121+
122+
123+
def _desensitize_script_value(value: Any) -> dict[str, Any]:
124+
text = value if isinstance(value, str) else ""
125+
return {
126+
"redacted": True,
127+
"length": len(text),
128+
}
129+
130+
131+
def _desensitize_value(value: Any) -> Any:
132+
if isinstance(value, dict):
133+
return {
134+
str(key): "***" if _is_sensitive_key(str(key)) else _desensitize_value(item)
135+
for key, item in value.items()
136+
}
137+
if isinstance(value, list):
138+
return [_desensitize_value(item) for item in value]
139+
if isinstance(value, tuple):
140+
return [_desensitize_value(item) for item in value]
141+
if isinstance(value, str):
142+
return _desensitize_string(value)
143+
return value
144+
145+
146+
def _is_sensitive_key(key: str) -> bool:
147+
lowered = key.lower()
148+
return any(sensitive_key in lowered for sensitive_key in _SENSITIVE_KEYS)
149+
150+
151+
def _desensitize_string(value: str) -> str:
152+
if len(value) <= 256:
153+
return value
154+
return f"{value[:128]}...<truncated:{len(value)}>"
155+
156+
157+
def _utc_now() -> str:
158+
return datetime.now(timezone.utc).isoformat()
159+
160+
161+
def monotonic_ms(start: Optional[float] = None) -> float:
162+
"""Return monotonic milliseconds since start, or current monotonic milliseconds."""
163+
now = time.monotonic() * 1000
164+
if start is None:
165+
return now
166+
return now - start

0 commit comments

Comments
 (0)