relaysecurity-dev 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {relaysecurity_dev-0.1.0 → relaysecurity_dev-0.2.0}/PKG-INFO +2 -2
- {relaysecurity_dev-0.1.0 → relaysecurity_dev-0.2.0}/pyproject.toml +2 -2
- relaysecurity_dev-0.2.0/src/relaysecurity_dev/__init__.py +4 -0
- relaysecurity_dev-0.2.0/src/relaysecurity_dev/policy.py +190 -0
- relaysecurity_dev-0.2.0/src/relaysecurity_dev/sandbox.py +154 -0
- relaysecurity_dev-0.1.0/src/relaysecurity_dev/__init__.py +0 -3
- {relaysecurity_dev-0.1.0 → relaysecurity_dev-0.2.0}/.gitignore +0 -0
- {relaysecurity_dev-0.1.0 → relaysecurity_dev-0.2.0}/README.md +0 -0
- {relaysecurity_dev-0.1.0 → relaysecurity_dev-0.2.0}/examples/langgraph_guard.py +0 -0
- {relaysecurity_dev-0.1.0 → relaysecurity_dev-0.2.0}/src/relaysecurity_dev/client.py +0 -0
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: relaysecurity-dev
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python SDK for checking AI agent tool calls with Relay.
|
|
5
5
|
Project-URL: Homepage, https://relay-security-lemon.vercel.app
|
|
6
6
|
Project-URL: Repository, https://github.com/aniiketvarshney/Relay-Security
|
|
7
7
|
Author: Relay Security
|
|
8
|
-
License
|
|
8
|
+
License: Apache-2.0
|
|
9
9
|
Keywords: agents,ai,guardrails,relay,security
|
|
10
10
|
Requires-Python: >=3.9
|
|
11
11
|
Requires-Dist: requests>=2.31.0
|
|
@@ -4,11 +4,11 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "relaysecurity-dev"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Python SDK for checking AI agent tool calls with Relay."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
11
|
-
license = "
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
12
|
authors = [
|
|
13
13
|
{ name = "Relay Security" }
|
|
14
14
|
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
API_KEY_PREFIX = "relay_sk_"
|
|
9
|
+
|
|
10
|
+
TOOL_ALIASES = {
|
|
11
|
+
"github_delete_repo": [
|
|
12
|
+
"github_delete_repo",
|
|
13
|
+
"github.deleteRepo",
|
|
14
|
+
"github.repos.delete",
|
|
15
|
+
"github_delete_repository",
|
|
16
|
+
"delete_repository",
|
|
17
|
+
"delete_repo",
|
|
18
|
+
],
|
|
19
|
+
"shell_rm_rf": [
|
|
20
|
+
"shell_rm_rf",
|
|
21
|
+
"shell.rm_rf",
|
|
22
|
+
"shell.rm",
|
|
23
|
+
"terminal_rm_rf",
|
|
24
|
+
"delete_files_recursive",
|
|
25
|
+
"shell_command",
|
|
26
|
+
"terminal_command",
|
|
27
|
+
"bash_command",
|
|
28
|
+
"execute_shell",
|
|
29
|
+
],
|
|
30
|
+
"deploy_production": [
|
|
31
|
+
"deploy_production",
|
|
32
|
+
"deploy.production",
|
|
33
|
+
"vercel_promote_production",
|
|
34
|
+
"production_deploy",
|
|
35
|
+
],
|
|
36
|
+
"database_drop_table": [
|
|
37
|
+
"database_drop_table",
|
|
38
|
+
"db_drop_table",
|
|
39
|
+
"sql_drop_table",
|
|
40
|
+
"database.dropTable",
|
|
41
|
+
],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
SHELL_TOOL_HINTS = {
|
|
45
|
+
"shell_command",
|
|
46
|
+
"terminal_command",
|
|
47
|
+
"bash_command",
|
|
48
|
+
"execute_shell",
|
|
49
|
+
"shell_rm_rf",
|
|
50
|
+
"shell.rm_rf",
|
|
51
|
+
"shell.rm",
|
|
52
|
+
"terminal_rm_rf",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
SHELL_PATTERNS = [
|
|
56
|
+
re.compile(r"\brm\s+-rf\b", re.IGNORECASE),
|
|
57
|
+
re.compile(r"\bdel\s+\/s\s+\/q\b", re.IGNORECASE),
|
|
58
|
+
re.compile(r"\bformat\s+[a-z]:", re.IGNORECASE),
|
|
59
|
+
re.compile(r"\bmkfs(?:\.\w+)?\b", re.IGNORECASE),
|
|
60
|
+
re.compile(r"\bdd\s+if=", re.IGNORECASE),
|
|
61
|
+
re.compile(r"\bchmod\s+-R\b", re.IGNORECASE),
|
|
62
|
+
re.compile(r"\bchown\s+-R\b", re.IGNORECASE),
|
|
63
|
+
re.compile(r"\bInvoke-WebRequest\b", re.IGNORECASE),
|
|
64
|
+
re.compile(r"\biwr\b", re.IGNORECASE),
|
|
65
|
+
re.compile(r"\bStart-BitsTransfer\b", re.IGNORECASE),
|
|
66
|
+
re.compile(r"\bcurl\b", re.IGNORECASE),
|
|
67
|
+
re.compile(r"\bwget\b", re.IGNORECASE),
|
|
68
|
+
re.compile(r"\bnc\b", re.IGNORECASE),
|
|
69
|
+
re.compile(r"\bnetcat\b", re.IGNORECASE),
|
|
70
|
+
re.compile(r"\bscp\b", re.IGNORECASE),
|
|
71
|
+
re.compile(r"\bsftp\b", re.IGNORECASE),
|
|
72
|
+
re.compile(r"\brsync\b", re.IGNORECASE),
|
|
73
|
+
re.compile(r"\bbase64\b", re.IGNORECASE),
|
|
74
|
+
re.compile(r"\bopenssl\s+enc\b", re.IGNORECASE),
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
PIPE_TO_SHELL_PATTERN = re.compile(
|
|
78
|
+
r"\|\s*(bash|sh|zsh|pwsh|powershell)\b",
|
|
79
|
+
re.IGNORECASE,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_allowlist(value: str | None) -> list[str]:
|
|
84
|
+
if not value:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
return [
|
|
88
|
+
item.strip().lower().removeprefix("www.")
|
|
89
|
+
for item in re.split(r"[,\s]+", value)
|
|
90
|
+
if item.strip()
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def normalize_host(host: str) -> str:
|
|
95
|
+
return host.lower().removeprefix("www.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def extract_urls(text: str) -> list[str]:
|
|
99
|
+
return re.findall(r"https?://[^\s'\"`<>\\()]+", text, flags=re.IGNORECASE)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def is_host_allowed(host: str, allowlist: list[str]) -> bool:
|
|
103
|
+
normalized_host = normalize_host(host)
|
|
104
|
+
for allowed in allowlist:
|
|
105
|
+
normalized_allowed = normalize_host(allowed)
|
|
106
|
+
if (
|
|
107
|
+
normalized_host == normalized_allowed
|
|
108
|
+
or normalized_host.endswith(f".{normalized_allowed}")
|
|
109
|
+
):
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def expand_tool_aliases(tool: str) -> list[str]:
|
|
115
|
+
normalized = tool.strip()
|
|
116
|
+
aliases = {normalized}
|
|
117
|
+
|
|
118
|
+
for values in TOOL_ALIASES.values():
|
|
119
|
+
if normalized in values:
|
|
120
|
+
aliases.update(values)
|
|
121
|
+
|
|
122
|
+
return list(aliases)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_shell_tool(tool_name: str) -> bool:
|
|
126
|
+
return tool_name.strip() in SHELL_TOOL_HINTS
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_shell_command(arguments: dict[str, object] | None) -> str:
|
|
130
|
+
if not isinstance(arguments, dict):
|
|
131
|
+
return ""
|
|
132
|
+
|
|
133
|
+
for key in ("command", "cmd", "shell", "script", "bash", "powershell", "sh", "args"):
|
|
134
|
+
value = arguments.get(key)
|
|
135
|
+
if isinstance(value, str) and value.strip():
|
|
136
|
+
return value.strip()
|
|
137
|
+
|
|
138
|
+
return ""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class ShellInspectionResult:
|
|
143
|
+
blocked: bool
|
|
144
|
+
reason: str | None = None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def inspect_shell_command(
|
|
148
|
+
command: str | None,
|
|
149
|
+
allowlist: list[str] | None = None,
|
|
150
|
+
) -> ShellInspectionResult:
|
|
151
|
+
normalized = (command or "").strip()
|
|
152
|
+
if not normalized:
|
|
153
|
+
return ShellInspectionResult(blocked=False)
|
|
154
|
+
|
|
155
|
+
for pattern in SHELL_PATTERNS:
|
|
156
|
+
if pattern.search(normalized):
|
|
157
|
+
return ShellInspectionResult(
|
|
158
|
+
blocked=True,
|
|
159
|
+
reason="Blocked risky shell command.",
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if PIPE_TO_SHELL_PATTERN.search(normalized):
|
|
163
|
+
return ShellInspectionResult(
|
|
164
|
+
blocked=True,
|
|
165
|
+
reason="Blocked shell pipeline execution.",
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
allowed_hosts = allowlist or parse_allowlist(
|
|
169
|
+
os.getenv("RELAY_EGRESS_ALLOWLIST") or os.getenv("EGRESS_ALLOWLIST")
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
for raw_url in extract_urls(normalized):
|
|
173
|
+
host = urlparse(raw_url).hostname or ""
|
|
174
|
+
if host and not is_host_allowed(host, allowed_hosts):
|
|
175
|
+
return ShellInspectionResult(
|
|
176
|
+
blocked=True,
|
|
177
|
+
reason=f"Blocked external domain: {host}",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if not allowed_hosts and re.search(
|
|
181
|
+
r"(curl|wget|Invoke-WebRequest|iwr|Start-BitsTransfer)",
|
|
182
|
+
normalized,
|
|
183
|
+
flags=re.IGNORECASE,
|
|
184
|
+
):
|
|
185
|
+
return ShellInspectionResult(
|
|
186
|
+
blocked=True,
|
|
187
|
+
reason="Blocked shell networking command.",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return ShellInspectionResult(blocked=False)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .client import Relay, RelayPolicyError
|
|
10
|
+
from .policy import (
|
|
11
|
+
extract_shell_command,
|
|
12
|
+
inspect_shell_command,
|
|
13
|
+
parse_allowlist,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class DockerSandboxResult:
|
|
19
|
+
stdout: str
|
|
20
|
+
stderr: str
|
|
21
|
+
returncode: int
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _docker_error_message(details: str) -> str:
|
|
25
|
+
lower_details = details.lower()
|
|
26
|
+
|
|
27
|
+
if "no such file or directory" in lower_details or "file not found" in lower_details:
|
|
28
|
+
return (
|
|
29
|
+
"Docker was not found. Install Docker Desktop, open it once, "
|
|
30
|
+
"then run `docker run --rm hello-world`."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if "wsl" in lower_details or "windows subsystem for linux" in lower_details:
|
|
34
|
+
return (
|
|
35
|
+
"Docker needs WSL to run Linux containers on Windows. Run "
|
|
36
|
+
"`wsl --install`, restart your PC, open Docker Desktop, then retry."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if (
|
|
40
|
+
"docker desktop is unable to start" in lower_details
|
|
41
|
+
or "cannot connect to the docker daemon" in lower_details
|
|
42
|
+
or "daemon is not running" in lower_details
|
|
43
|
+
or "error during connect" in lower_details
|
|
44
|
+
or "//./pipe/docker" in lower_details
|
|
45
|
+
):
|
|
46
|
+
return (
|
|
47
|
+
"Docker is installed, but the Docker engine is not running. "
|
|
48
|
+
"Open Docker Desktop and wait until it says 'Engine running', then retry."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
if (
|
|
52
|
+
"permission denied" in lower_details and "docker" in lower_details
|
|
53
|
+
) or (
|
|
54
|
+
"access is denied" in lower_details and ".docker" in lower_details
|
|
55
|
+
) or "trying to connect to the docker api" in lower_details:
|
|
56
|
+
return (
|
|
57
|
+
"Docker is running, but this terminal cannot access Docker. "
|
|
58
|
+
"Open a normal PowerShell/Terminal as your user, make sure Docker "
|
|
59
|
+
"Desktop is running, then retry."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
return details.strip() or "Docker sandbox failed."
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class DockerSandbox:
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
relay: Relay | None = None,
|
|
69
|
+
*,
|
|
70
|
+
image: str = "ubuntu:24.04",
|
|
71
|
+
workdir: str | None = None,
|
|
72
|
+
egress_allowlist: list[str] | str | None = None,
|
|
73
|
+
tool_name: str = "shell_command",
|
|
74
|
+
) -> None:
|
|
75
|
+
self.relay = relay or Relay()
|
|
76
|
+
self.image = image
|
|
77
|
+
self.workdir = workdir or os.getcwd()
|
|
78
|
+
self.tool_name = tool_name
|
|
79
|
+
if isinstance(egress_allowlist, str):
|
|
80
|
+
self.egress_allowlist = parse_allowlist(egress_allowlist)
|
|
81
|
+
else:
|
|
82
|
+
self.egress_allowlist = egress_allowlist or parse_allowlist(
|
|
83
|
+
os.getenv("RELAY_EGRESS_ALLOWLIST") or os.getenv("EGRESS_ALLOWLIST")
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def run(
|
|
87
|
+
self,
|
|
88
|
+
command: str,
|
|
89
|
+
*,
|
|
90
|
+
arguments: dict[str, Any] | None = None,
|
|
91
|
+
env: dict[str, str] | None = None,
|
|
92
|
+
workdir: str | None = None,
|
|
93
|
+
) -> DockerSandboxResult:
|
|
94
|
+
safe_command = extract_shell_command(arguments) or command.strip()
|
|
95
|
+
inspection = inspect_shell_command(safe_command, self.egress_allowlist)
|
|
96
|
+
|
|
97
|
+
if inspection.blocked:
|
|
98
|
+
raise RelayPolicyError(
|
|
99
|
+
inspection.reason or "Blocked by Relay policy",
|
|
100
|
+
{"status": "blocked", "reason": inspection.reason},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
self.relay.assert_allowed(
|
|
104
|
+
self.tool_name,
|
|
105
|
+
{"command": safe_command, **(arguments or {})},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
mount_dir = Path(workdir or self.workdir).resolve()
|
|
109
|
+
docker_args = [
|
|
110
|
+
"docker",
|
|
111
|
+
"run",
|
|
112
|
+
"--rm",
|
|
113
|
+
"-i",
|
|
114
|
+
"--network",
|
|
115
|
+
"none",
|
|
116
|
+
"-v",
|
|
117
|
+
f"{mount_dir}:/workspace",
|
|
118
|
+
"-w",
|
|
119
|
+
"/workspace",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
for key, value in (env or {}).items():
|
|
123
|
+
docker_args.extend(["-e", f"{key}={value}"])
|
|
124
|
+
|
|
125
|
+
if self.egress_allowlist:
|
|
126
|
+
docker_args.extend(
|
|
127
|
+
["-e", f"RELAY_EGRESS_ALLOWLIST={','.join(self.egress_allowlist)}"]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
docker_args.extend([self.image, "sh", "-lc", safe_command])
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
completed = subprocess.run(
|
|
134
|
+
docker_args,
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True,
|
|
137
|
+
check=False,
|
|
138
|
+
env={**os.environ, **(env or {})},
|
|
139
|
+
)
|
|
140
|
+
except FileNotFoundError as error:
|
|
141
|
+
raise RuntimeError(_docker_error_message(str(error))) from error
|
|
142
|
+
except OSError as error:
|
|
143
|
+
raise RuntimeError(_docker_error_message(str(error))) from error
|
|
144
|
+
|
|
145
|
+
if completed.returncode != 0:
|
|
146
|
+
raise RuntimeError(
|
|
147
|
+
_docker_error_message(completed.stderr or completed.stdout)
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return DockerSandboxResult(
|
|
151
|
+
stdout=completed.stdout,
|
|
152
|
+
stderr=completed.stderr,
|
|
153
|
+
returncode=completed.returncode,
|
|
154
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|