-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsecurity.py
More file actions
181 lines (149 loc) · 5.6 KB
/
Copy pathsecurity.py
File metadata and controls
181 lines (149 loc) · 5.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"""
security.py — Hardened validation for user / tool inputs
Centralises the checks that keep DevMind safe:
- Path traversal / symlink escape protection
- Command sanitization for the bash tool
- Generic input validators (length, type, encoding)
Every public helper returns `(ok: bool, message: str, value)` so callers
can surface the result to the user without raising inside the tool path.
"""
from __future__ import annotations
import os
import re
import shlex
from pathlib import Path
from config import config
from logger import get_logger
log = get_logger("security")
# ============================================================
# Path validation
# ============================================================
def validate_path(
filepath: str,
*,
base_dir: Path | None = None,
must_exist: bool = False,
allow_symlinks: bool = False,
) -> tuple[bool, str, Path | None]:
"""
Validate a filesystem path.
Rules:
- Input must be a non-empty string
- Null bytes are rejected
- Resolved path must be inside `base_dir` (defaults to cwd)
- Symlinks that escape the base_dir are rejected
Returns:
(is_valid, error_message, resolved_path)
"""
if not isinstance(filepath, str) or not filepath.strip():
return False, "Path must be a non-empty string.", None
if "\x00" in filepath:
log.warning("Null byte in path blocked")
return False, "Invalid path (null byte).", None
try:
base = (base_dir or Path(os.getcwd())).resolve()
# First, resolve without strict=True so missing files are allowed
candidate = Path(filepath)
if not candidate.is_absolute():
candidate = base / candidate
resolved = candidate.resolve()
# Block traversal using relative_to
try:
resolved.relative_to(base)
except ValueError:
log.warning(f"Path traversal attempt blocked: {filepath!r}")
return (
False,
(
"Security: Only files within the current working directory "
"can be accessed."
),
None,
)
if must_exist and not resolved.exists():
return False, f"File not found: {filepath}", None
# Detect symlink escapes even when relative_to passes
if not allow_symlinks and resolved.exists() and resolved.is_symlink():
real = resolved.resolve(strict=True)
try:
real.relative_to(base)
except ValueError:
log.warning(f"Symlink escape blocked: {filepath!r} → {real}")
return False, "Security: symlink points outside the workspace.", None
return True, "", resolved
except Exception as e:
return False, f"Invalid path: {e}", None
# ============================================================
# Command validation
# ============================================================
_SUSPICIOUS_CHAR_RUNS = re.compile(r"`.+`|\$\([^)]+\)")
def validate_command(command: str) -> tuple[bool, str]:
"""
Validate a shell command before running it.
Rules:
- Must be a non-empty string
- Null bytes rejected
- Must not contain any blocked substring from config.tool.blocked_commands
- Tokens starting with `sudo` are blocked
- Length is capped (10 KB) to avoid abuse
Returns:
(is_valid, error_message)
"""
if not isinstance(command, str):
return False, "Command must be a string."
stripped = command.strip()
if not stripped:
return False, "No command provided."
if "\x00" in command:
return False, "Invalid command (null byte)."
if len(command) > 10_000:
return False, "Command too long (max 10 KB)."
lower = stripped.lower()
for blocked in config.tool.blocked_commands:
if blocked.strip() and blocked.lower() in lower:
log.warning(f"Blocked command pattern '{blocked}': {stripped[:80]}")
return (
False,
(
"This command has been blocked for security reasons "
f"({blocked.strip()})."
),
)
# Block sudo and su outright
try:
tokens = shlex.split(stripped, posix=True)
except ValueError:
# Unbalanced quotes etc. — allow bash itself to error out
tokens = stripped.split()
if tokens and tokens[0] in ("sudo", "su", "doas"):
log.warning(f"Privilege escalation blocked: {stripped[:80]}")
return False, "Privilege escalation commands (sudo/su/doas) are blocked."
return True, ""
# ============================================================
# Generic string validator
# ============================================================
def validate_string(
value,
*,
name: str = "value",
max_length: int = 100_000,
allow_empty: bool = False,
) -> tuple[bool, str]:
"""
Basic string guardrails shared across tools.
"""
if not isinstance(value, str):
return False, f"{name} must be a string."
if not allow_empty and not value.strip():
return False, f"{name} must not be empty."
if len(value) > max_length:
return False, f"{name} too long (max {max_length} characters)."
if "\x00" in value:
return False, f"{name} contains a null byte."
return True, ""
if __name__ == "__main__":
print(validate_command("echo hi"))
print(validate_command("sudo rm -rf /"))
print(validate_command("curl https://evil"))
print(validate_path("README.md"))
print(validate_path("../../etc/passwd"))