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.
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: relaysecurity-dev
3
- Version: 0.1.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-Expression: MIT
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.1.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 = "MIT"
11
+ license = { text = "Apache-2.0" }
12
12
  authors = [
13
13
  { name = "Relay Security" }
14
14
  ]
@@ -0,0 +1,4 @@
1
+ from .client import Relay, RelayPolicyError
2
+ from .sandbox import DockerSandbox, DockerSandboxResult
3
+
4
+ __all__ = ["Relay", "RelayPolicyError", "DockerSandbox", "DockerSandboxResult"]
@@ -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
+ )
@@ -1,3 +0,0 @@
1
- from .client import Relay, RelayPolicyError
2
-
3
- __all__ = ["Relay", "RelayPolicyError"]