pulse-coding-agent 0.1.0__py3-none-any.whl
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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/sandbox/network.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Network policy and isolation engine for Pulse sandbox execution.
|
|
2
|
+
|
|
3
|
+
Implements backend-independent network security controls including DNS
|
|
4
|
+
rebinding protection, proxy configuration, and connection allowlisting.
|
|
5
|
+
|
|
6
|
+
Security architecture:
|
|
7
|
+
- Default policy is DENY_ALL.
|
|
8
|
+
- ALLOWLIST dynamically resolves hostnames to prevent DNS rebinding
|
|
9
|
+
to internal/private IPs.
|
|
10
|
+
- PROXY mode securely isolates credentials from direct container exposure.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fnmatch
|
|
16
|
+
import ipaddress
|
|
17
|
+
import socket
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class NetworkMode(str, Enum):
|
|
24
|
+
"""Execution network isolation modes."""
|
|
25
|
+
DENY_ALL = "deny_all"
|
|
26
|
+
LOCALHOST_ONLY = "localhost_only"
|
|
27
|
+
ALLOWLIST = "allowlist"
|
|
28
|
+
PROXY = "proxy"
|
|
29
|
+
ALLOW_ALL = "allow_all"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NetworkEnforcementLevel(str, Enum):
|
|
33
|
+
"""Strength of network enforcement provided by a backend.
|
|
34
|
+
|
|
35
|
+
Security: Only STRONGLY_ENFORCED is accepted by the Sandbox API for strict modes.
|
|
36
|
+
ADVISORY is rejected to prevent silent downgrades of security policies.
|
|
37
|
+
"""
|
|
38
|
+
STRONGLY_ENFORCED = "strongly_enforced"
|
|
39
|
+
PARTIALLY_ENFORCED = "partially_enforced"
|
|
40
|
+
ADVISORY = "advisory"
|
|
41
|
+
UNSUPPORTED = "unsupported"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Protocol(str, Enum):
|
|
45
|
+
"""Network protocols."""
|
|
46
|
+
TCP = "tcp"
|
|
47
|
+
UDP = "udp"
|
|
48
|
+
ANY = "any"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class NetworkRule:
|
|
53
|
+
"""A rule defining an allowed network destination."""
|
|
54
|
+
|
|
55
|
+
destination: str # Can be an IP, a hostname, or a wildcard domain (e.g. *.example.com)
|
|
56
|
+
port: int | None = None # None means any port
|
|
57
|
+
protocol: Protocol = Protocol.ANY
|
|
58
|
+
|
|
59
|
+
def matches(self, host: str, port: int, protocol: Protocol) -> bool:
|
|
60
|
+
"""Check if a specific request matches this rule."""
|
|
61
|
+
if self.port is not None and self.port != port:
|
|
62
|
+
return False
|
|
63
|
+
if self.protocol != Protocol.ANY and self.protocol != protocol:
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# Exact match or IP match
|
|
67
|
+
if self.destination.lower() == host.lower():
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
# Wildcard domain match (e.g. *.example.com)
|
|
71
|
+
return fnmatch.fnmatch(host.lower(), self.destination.lower())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True, slots=True)
|
|
75
|
+
class NetworkPolicy:
|
|
76
|
+
"""Backend-independent network security policy.
|
|
77
|
+
|
|
78
|
+
Security architecture:
|
|
79
|
+
- Mode defaults to DENY_ALL.
|
|
80
|
+
- Rules only apply if mode is ALLOWLIST.
|
|
81
|
+
- Proxy URL is used if mode is PROXY.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
mode: NetworkMode = NetworkMode.DENY_ALL
|
|
85
|
+
rules: list[NetworkRule] = field(default_factory=list)
|
|
86
|
+
proxy_url: str | None = None
|
|
87
|
+
|
|
88
|
+
def is_safe_ip(self, ip_str: str) -> bool:
|
|
89
|
+
"""Determine if an IP address is a safe external address.
|
|
90
|
+
|
|
91
|
+
Security: Protects against SSRF and DNS rebinding by explicitly
|
|
92
|
+
blocking RFC1918 private addresses, loopback, link-local, and multicast
|
|
93
|
+
from being accessed when a domain resolves to them.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
ip = ipaddress.ip_address(ip_str)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
# Explicitly block internal/private ranges
|
|
101
|
+
if ip.is_loopback:
|
|
102
|
+
return False
|
|
103
|
+
if ip.is_private:
|
|
104
|
+
return False
|
|
105
|
+
if ip.is_link_local:
|
|
106
|
+
return False
|
|
107
|
+
return not ip.is_multicast
|
|
108
|
+
|
|
109
|
+
def validate_destination(self, host: str, port: int, protocol: Protocol = Protocol.TCP) -> bool:
|
|
110
|
+
"""Validate an outbound connection attempt against the policy.
|
|
111
|
+
|
|
112
|
+
This is primarily used by backends that can intercept connections
|
|
113
|
+
or by API layers wrapping specific network requests.
|
|
114
|
+
"""
|
|
115
|
+
if self.mode == NetworkMode.DENY_ALL:
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
if self.mode == NetworkMode.LOCALHOST_ONLY:
|
|
119
|
+
try:
|
|
120
|
+
ip = ipaddress.ip_address(host)
|
|
121
|
+
return ip.is_loopback
|
|
122
|
+
except ValueError:
|
|
123
|
+
return host.lower() == "localhost"
|
|
124
|
+
|
|
125
|
+
if self.mode == NetworkMode.PROXY:
|
|
126
|
+
# In proxy mode, direct connections are denied; they must go through the proxy URL.
|
|
127
|
+
# This validation returns False for direct requests unless it's the proxy itself.
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
if self.mode == NetworkMode.ALLOWLIST:
|
|
131
|
+
rule_matched = False
|
|
132
|
+
for rule in self.rules:
|
|
133
|
+
if rule.matches(host, port, protocol):
|
|
134
|
+
rule_matched = True
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if not rule_matched:
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
# DNS Rebinding Protection:
|
|
141
|
+
# If the host is a hostname (not a raw IP), we must resolve it locally
|
|
142
|
+
# and verify it does not resolve to an internal/private IP.
|
|
143
|
+
try:
|
|
144
|
+
ipaddress.ip_address(host)
|
|
145
|
+
# It's already a raw IP, and it was in the allowlist.
|
|
146
|
+
return True
|
|
147
|
+
except ValueError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# Resolve hostname
|
|
152
|
+
# Use getaddrinfo to handle both IPv4 and IPv6
|
|
153
|
+
addr_info = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
154
|
+
for family, type, proto, canonname, sockaddr in addr_info:
|
|
155
|
+
ip_str = sockaddr[0]
|
|
156
|
+
if not self.is_safe_ip(ip_str):
|
|
157
|
+
return False # Deny if ANY resolved IP is private/unsafe
|
|
158
|
+
return True
|
|
159
|
+
except socket.gaierror:
|
|
160
|
+
# If we can't resolve it, fail closed.
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
@classmethod
|
|
166
|
+
def from_dict(cls, data: dict[str, Any]) -> NetworkPolicy:
|
|
167
|
+
mode_str = str(data.get("mode", "deny_all")).lower()
|
|
168
|
+
try:
|
|
169
|
+
mode = NetworkMode(mode_str)
|
|
170
|
+
except ValueError:
|
|
171
|
+
mode = NetworkMode.DENY_ALL
|
|
172
|
+
|
|
173
|
+
rules = []
|
|
174
|
+
for raw_rule in data.get("rules", []):
|
|
175
|
+
try:
|
|
176
|
+
protocol_str = str(raw_rule.get("protocol", "any")).lower()
|
|
177
|
+
rules.append(
|
|
178
|
+
NetworkRule(
|
|
179
|
+
destination=str(raw_rule.get("destination")),
|
|
180
|
+
port=int(raw_rule["port"]) if raw_rule.get("port") is not None else None,
|
|
181
|
+
protocol=Protocol(protocol_str),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
except (ValueError, KeyError):
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
return cls(
|
|
188
|
+
mode=mode,
|
|
189
|
+
rules=rules,
|
|
190
|
+
proxy_url=data.get("proxy_url")
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def to_dict(self) -> dict[str, Any]:
|
|
194
|
+
return {
|
|
195
|
+
"mode": self.mode.value,
|
|
196
|
+
"rules": [
|
|
197
|
+
{
|
|
198
|
+
"destination": r.destination,
|
|
199
|
+
"port": r.port,
|
|
200
|
+
"protocol": r.protocol.value,
|
|
201
|
+
}
|
|
202
|
+
for r in self.rules
|
|
203
|
+
],
|
|
204
|
+
"proxy_url": self.proxy_url,
|
|
205
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Path validation, TOCTOU-safe file access, and workspace boundary isolation.
|
|
2
|
+
|
|
3
|
+
Prevents path traversal, symlink loops, symlink escapes, and unauthorized access
|
|
4
|
+
outside configured workspace boundaries.
|
|
5
|
+
|
|
6
|
+
Security hardening (TOCTOU + Memory Exhaustion):
|
|
7
|
+
- safe_open() atomically validates and opens file descriptors.
|
|
8
|
+
- O_NOFOLLOW used on POSIX to prevent symlink following.
|
|
9
|
+
- Post-open fstat() re-validates the resolved path matches expectations.
|
|
10
|
+
- MAX_FILE_SIZE enforced before reading to prevent OOM attacks.
|
|
11
|
+
- safe_read() returns content with enforced size limits.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import stat
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path, PureWindowsPath
|
|
20
|
+
|
|
21
|
+
from pulse.sandbox.errors import SandboxResourceError, SandboxSecurityError
|
|
22
|
+
|
|
23
|
+
# Maximum file size that can be read through the sandbox (50 MB)
|
|
24
|
+
MAX_FILE_SIZE: int = 50 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PathValidationError(ValueError):
|
|
28
|
+
"""Raised when a path violates security boundaries."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, path: str | Path, reason: str) -> None:
|
|
31
|
+
super().__init__(f"{message}: {path} ({reason})")
|
|
32
|
+
self.path = str(path)
|
|
33
|
+
self.reason = reason
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PathValidator:
|
|
37
|
+
"""Security boundary validator for workspace filesystem access.
|
|
38
|
+
|
|
39
|
+
Provides TOCTOU-safe file operations that atomically validate and open
|
|
40
|
+
file descriptors, preventing race condition exploits.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
workspace_root: Path,
|
|
46
|
+
allowed_external_reads: list[Path] | None = None,
|
|
47
|
+
max_file_size: int = MAX_FILE_SIZE,
|
|
48
|
+
) -> None:
|
|
49
|
+
self.workspace_root = workspace_root.resolve()
|
|
50
|
+
self.allowed_external_reads = [p.resolve() for p in (allowed_external_reads or [])]
|
|
51
|
+
self.max_file_size = max_file_size
|
|
52
|
+
|
|
53
|
+
def validate_path(
|
|
54
|
+
self,
|
|
55
|
+
path: str | Path,
|
|
56
|
+
*,
|
|
57
|
+
allow_read_only_external: bool = False,
|
|
58
|
+
must_exist: bool = False,
|
|
59
|
+
) -> Path:
|
|
60
|
+
"""Resolve and validate that a path is strictly inside workspace boundaries.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path: Relative or absolute path to validate.
|
|
64
|
+
allow_read_only_external: Whether to allow access to configured external read paths.
|
|
65
|
+
must_exist: If True, raises PathValidationError if target path does not exist.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Resolved canonical Path object.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
PathValidationError: If path escapes workspace or violates symlink rules.
|
|
72
|
+
"""
|
|
73
|
+
raw_value = os.fspath(path)
|
|
74
|
+
windows_path = PureWindowsPath(raw_value)
|
|
75
|
+
if sys.platform != "win32" and (
|
|
76
|
+
windows_path.is_absolute()
|
|
77
|
+
or windows_path.drive
|
|
78
|
+
or ".." in windows_path.parts
|
|
79
|
+
):
|
|
80
|
+
raise PathValidationError(
|
|
81
|
+
"Path is outside workspace boundary",
|
|
82
|
+
path,
|
|
83
|
+
"Windows absolute, UNC, or traversal path is forbidden",
|
|
84
|
+
)
|
|
85
|
+
raw_path = Path(raw_value)
|
|
86
|
+
|
|
87
|
+
# 1. Resolve path candidate relative to workspace root if relative
|
|
88
|
+
if not raw_path.is_absolute():
|
|
89
|
+
candidate = (self.workspace_root / raw_path)
|
|
90
|
+
else:
|
|
91
|
+
candidate = raw_path
|
|
92
|
+
|
|
93
|
+
# 2. Check symlink loops and resolve target
|
|
94
|
+
try:
|
|
95
|
+
resolved = candidate.resolve()
|
|
96
|
+
except RuntimeError as err:
|
|
97
|
+
raise PathValidationError("Symlink resolution failed", path, "Possible symlink loop") from err
|
|
98
|
+
|
|
99
|
+
if must_exist and not resolved.exists():
|
|
100
|
+
raise PathValidationError("Path does not exist", path, "Target path missing")
|
|
101
|
+
|
|
102
|
+
# 3. Verify workspace root containment
|
|
103
|
+
if self._is_contained_in(resolved, self.workspace_root):
|
|
104
|
+
return resolved
|
|
105
|
+
|
|
106
|
+
# 4. Check allowed external read paths if permitted
|
|
107
|
+
if allow_read_only_external:
|
|
108
|
+
for ext_path in self.allowed_external_reads:
|
|
109
|
+
if self._is_contained_in(resolved, ext_path):
|
|
110
|
+
return resolved
|
|
111
|
+
|
|
112
|
+
raise PathValidationError("Path is outside workspace boundary", path, f"Resolved to {resolved}")
|
|
113
|
+
|
|
114
|
+
def is_inside_workspace(self, path: str | Path) -> bool:
|
|
115
|
+
"""Return True if path resolves inside workspace_root."""
|
|
116
|
+
try:
|
|
117
|
+
self.validate_path(path, allow_read_only_external=False, must_exist=False)
|
|
118
|
+
return True
|
|
119
|
+
except PathValidationError:
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def assert_inside_workspace(self, path: str | Path) -> Path:
|
|
123
|
+
"""Convenience assertion method returning resolved Path or raising PathValidationError."""
|
|
124
|
+
return self.validate_path(path, allow_read_only_external=False, must_exist=False)
|
|
125
|
+
|
|
126
|
+
def safe_open(self, path: str | Path, *, for_write: bool = False) -> int:
|
|
127
|
+
"""Atomically validate and open a file descriptor, preventing TOCTOU races.
|
|
128
|
+
|
|
129
|
+
Security guarantees:
|
|
130
|
+
1. Path is validated inside workspace BEFORE opening.
|
|
131
|
+
2. On POSIX, O_NOFOLLOW prevents following symlinks at the final component.
|
|
132
|
+
3. After open, fstat() verifies the file is a regular file.
|
|
133
|
+
4. File size is checked against max_file_size before returning.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
path: Relative or absolute path to open.
|
|
137
|
+
for_write: If True, open for writing (O_WRONLY | O_CREAT).
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Raw file descriptor (caller is responsible for os.close()).
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
PathValidationError: If path escapes workspace.
|
|
144
|
+
SandboxSecurityError: If symlink detected at open time, or file is not regular.
|
|
145
|
+
SandboxResourceError: If file exceeds max_file_size.
|
|
146
|
+
"""
|
|
147
|
+
resolved = self.assert_inside_workspace(path)
|
|
148
|
+
|
|
149
|
+
# Build open flags
|
|
150
|
+
flags = os.O_RDONLY
|
|
151
|
+
if for_write:
|
|
152
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
153
|
+
|
|
154
|
+
# On POSIX, add O_NOFOLLOW to prevent symlink following at the final path component.
|
|
155
|
+
# This closes the TOCTOU gap between validate_path() and the actual open().
|
|
156
|
+
is_windows = sys.platform == "win32"
|
|
157
|
+
if not is_windows and hasattr(os, "O_NOFOLLOW"):
|
|
158
|
+
flags |= os.O_NOFOLLOW
|
|
159
|
+
|
|
160
|
+
# Windows TOCTOU mitigation: lstat before open, fstat after open
|
|
161
|
+
pre_stat = None
|
|
162
|
+
if is_windows:
|
|
163
|
+
try:
|
|
164
|
+
pre_stat = os.lstat(str(resolved))
|
|
165
|
+
if stat.S_ISLNK(pre_stat.st_mode):
|
|
166
|
+
raise SandboxSecurityError(
|
|
167
|
+
"Symlink detected during safe_open (pre-stat) — possible TOCTOU attack",
|
|
168
|
+
operation="safe_open",
|
|
169
|
+
path=str(path),
|
|
170
|
+
)
|
|
171
|
+
except FileNotFoundError:
|
|
172
|
+
# File doesn't exist yet, which is fine if for_write is True
|
|
173
|
+
if not for_write:
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
fd = os.open(str(resolved), flags, 0o644)
|
|
178
|
+
except OSError as err:
|
|
179
|
+
if err.errno == 40: # ELOOP — symlink detected with O_NOFOLLOW
|
|
180
|
+
raise SandboxSecurityError(
|
|
181
|
+
"Symlink detected during safe_open — possible TOCTOU attack",
|
|
182
|
+
operation="safe_open",
|
|
183
|
+
path=str(path),
|
|
184
|
+
) from err
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
# Post-open validation: verify file descriptor points to a regular file
|
|
189
|
+
file_stat = os.fstat(fd)
|
|
190
|
+
|
|
191
|
+
# Windows TOCTOU mitigation: verify st_ino and st_dev match pre_stat
|
|
192
|
+
if is_windows and pre_stat is not None: # noqa: SIM102
|
|
193
|
+
if pre_stat.st_ino != file_stat.st_ino or pre_stat.st_dev != file_stat.st_dev:
|
|
194
|
+
os.close(fd)
|
|
195
|
+
raise SandboxSecurityError(
|
|
196
|
+
"File identity changed during safe_open (inode mismatch) — TOCTOU race detected",
|
|
197
|
+
operation="safe_open",
|
|
198
|
+
path=str(path),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if not stat.S_ISREG(file_stat.st_mode) and not for_write:
|
|
202
|
+
os.close(fd)
|
|
203
|
+
raise SandboxSecurityError(
|
|
204
|
+
"Opened file is not a regular file (possible device/pipe/socket injection)",
|
|
205
|
+
operation="safe_open",
|
|
206
|
+
path=str(path),
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Check file size against limit (only for reads)
|
|
210
|
+
if not for_write and file_stat.st_size > self.max_file_size:
|
|
211
|
+
os.close(fd)
|
|
212
|
+
raise SandboxResourceError(
|
|
213
|
+
f"File size ({file_stat.st_size} bytes) exceeds maximum "
|
|
214
|
+
f"({self.max_file_size} bytes): {path}",
|
|
215
|
+
limit_name="max_file_size",
|
|
216
|
+
limit_value=self.max_file_size,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return fd
|
|
220
|
+
|
|
221
|
+
except Exception:
|
|
222
|
+
# Ensure fd is closed on any validation failure
|
|
223
|
+
try:
|
|
224
|
+
os.close(fd)
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
raise
|
|
228
|
+
|
|
229
|
+
def safe_read(self, path: str | Path) -> str:
|
|
230
|
+
"""TOCTOU-safe file read with size limit enforcement.
|
|
231
|
+
|
|
232
|
+
Atomically validates, opens, checks size, and reads the file content
|
|
233
|
+
through a single file descriptor, closing the race window between
|
|
234
|
+
validation and use.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
path: Relative or absolute path to read.
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
File content as a UTF-8 string.
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
PathValidationError: If path escapes workspace.
|
|
244
|
+
SandboxSecurityError: On TOCTOU/symlink detection.
|
|
245
|
+
SandboxResourceError: If file exceeds max_file_size.
|
|
246
|
+
"""
|
|
247
|
+
fd = self.safe_open(path, for_write=False)
|
|
248
|
+
try:
|
|
249
|
+
# Read in chunks to prevent unbounded memory allocation
|
|
250
|
+
chunks: list[bytes] = []
|
|
251
|
+
total_read = 0
|
|
252
|
+
chunk_size = 1024 * 1024 # 1 MB chunks
|
|
253
|
+
|
|
254
|
+
while True:
|
|
255
|
+
chunk = os.read(fd, chunk_size)
|
|
256
|
+
if not chunk:
|
|
257
|
+
break
|
|
258
|
+
total_read += len(chunk)
|
|
259
|
+
if total_read > self.max_file_size:
|
|
260
|
+
raise SandboxResourceError(
|
|
261
|
+
f"File read exceeded maximum size ({self.max_file_size} bytes): {path}",
|
|
262
|
+
limit_name="max_file_size",
|
|
263
|
+
limit_value=self.max_file_size,
|
|
264
|
+
)
|
|
265
|
+
chunks.append(chunk)
|
|
266
|
+
|
|
267
|
+
return b"".join(chunks).decode("utf-8", errors="replace")
|
|
268
|
+
finally:
|
|
269
|
+
os.close(fd)
|
|
270
|
+
|
|
271
|
+
@staticmethod
|
|
272
|
+
def _is_contained_in(target: Path, parent: Path) -> bool:
|
|
273
|
+
"""Return True if target equals parent or is a child of parent."""
|
|
274
|
+
if target == parent:
|
|
275
|
+
return True
|
|
276
|
+
try:
|
|
277
|
+
target.relative_to(parent)
|
|
278
|
+
return True
|
|
279
|
+
except ValueError:
|
|
280
|
+
return False
|
pulse/sandbox/policy.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Policy-based permission engine for Pulse sandbox execution.
|
|
2
|
+
|
|
3
|
+
Implements zero-trust fine-grained policy evaluation with inheritance,
|
|
4
|
+
wildcard pattern matching, and action-level overrides.
|
|
5
|
+
|
|
6
|
+
Security hardening (case bypass fix):
|
|
7
|
+
- All targets are normalized through _normalize_target() before matching.
|
|
8
|
+
- Normalization covers case, separators, Unicode NFC, and drive letters.
|
|
9
|
+
- Rules match against BOTH raw and normalized targets (defense-in-depth).
|
|
10
|
+
- Default decisions changed to DENY for all actions except READ.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import fnmatch
|
|
16
|
+
import json
|
|
17
|
+
import unicodedata
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ActionType(str, Enum):
|
|
25
|
+
READ = "read"
|
|
26
|
+
WRITE = "write"
|
|
27
|
+
DELETE = "delete"
|
|
28
|
+
RENAME = "rename"
|
|
29
|
+
SHELL = "shell"
|
|
30
|
+
GIT = "git"
|
|
31
|
+
PYTHON = "python"
|
|
32
|
+
NETWORK = "network"
|
|
33
|
+
SECRETS = "secrets"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class PolicyDecision(str, Enum):
|
|
37
|
+
ALLOW = "allow"
|
|
38
|
+
DENY = "deny"
|
|
39
|
+
ASK = "ask"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class PolicyRule:
|
|
44
|
+
"""Individual policy rule matching an action and optional target pattern."""
|
|
45
|
+
|
|
46
|
+
action: str
|
|
47
|
+
target_pattern: str = "*"
|
|
48
|
+
decision: PolicyDecision = PolicyDecision.ASK
|
|
49
|
+
reason: str = ""
|
|
50
|
+
|
|
51
|
+
def matches(self, action: str, target: str) -> bool:
|
|
52
|
+
"""Check if this rule matches the given action and target.
|
|
53
|
+
|
|
54
|
+
Security: matches against BOTH the raw target and the normalized
|
|
55
|
+
target. If either matches, the rule applies. This prevents
|
|
56
|
+
case-sensitivity bypass on Linux where filenames are case-sensitive
|
|
57
|
+
but policy rules may have been written case-insensitively.
|
|
58
|
+
"""
|
|
59
|
+
if self.action != "*" and self.action.casefold() != action.casefold():
|
|
60
|
+
return False
|
|
61
|
+
if self.target_pattern == "*":
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
normalized_pattern = SandboxPolicy.normalize_target(self.target_pattern)
|
|
65
|
+
|
|
66
|
+
# Match against both raw and normalized target
|
|
67
|
+
raw_normalized = SandboxPolicy.normalize_target(target)
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
fnmatch.fnmatch(raw_normalized, normalized_pattern)
|
|
71
|
+
or fnmatch.fnmatch(target.replace("\\", "/").casefold(), normalized_pattern)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SandboxPolicy:
|
|
76
|
+
"""Production-grade policy manager supporting inheritance and pattern overrides.
|
|
77
|
+
|
|
78
|
+
Security hardening:
|
|
79
|
+
- Default decisions are DENY for all mutating actions.
|
|
80
|
+
- READ defaults to ALLOW (read-only operations are safe).
|
|
81
|
+
- GIT and PYTHON default to ASK (require explicit approval).
|
|
82
|
+
- NETWORK defaults to DENY.
|
|
83
|
+
- SECRETS defaults to DENY.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
DEFAULT_DECISIONS: dict[str, PolicyDecision] = { # noqa: RUF012
|
|
87
|
+
ActionType.READ.value: PolicyDecision.ALLOW,
|
|
88
|
+
ActionType.WRITE.value: PolicyDecision.DENY,
|
|
89
|
+
ActionType.DELETE.value: PolicyDecision.DENY,
|
|
90
|
+
ActionType.RENAME.value: PolicyDecision.DENY,
|
|
91
|
+
ActionType.SHELL.value: PolicyDecision.DENY,
|
|
92
|
+
ActionType.GIT.value: PolicyDecision.ASK,
|
|
93
|
+
ActionType.PYTHON.value: PolicyDecision.ASK,
|
|
94
|
+
ActionType.NETWORK.value: PolicyDecision.DENY,
|
|
95
|
+
ActionType.SECRETS.value: PolicyDecision.DENY,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
default_decisions: dict[str, PolicyDecision] | None = None,
|
|
101
|
+
rules: list[PolicyRule] | None = None,
|
|
102
|
+
parent_policy: SandboxPolicy | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self.default_decisions = {**self.DEFAULT_DECISIONS, **(default_decisions or {})}
|
|
105
|
+
self.rules: list[PolicyRule] = list(rules or [])
|
|
106
|
+
self.parent_policy = parent_policy
|
|
107
|
+
|
|
108
|
+
def add_rule(self, rule: PolicyRule) -> None:
|
|
109
|
+
self.rules.insert(0, rule) # Higher priority rules first
|
|
110
|
+
|
|
111
|
+
def evaluate(self, action: ActionType | str, target: str = "") -> PolicyDecision:
|
|
112
|
+
action_str = action.value if isinstance(action, ActionType) else str(action).casefold()
|
|
113
|
+
|
|
114
|
+
# 1. Check explicit rules (most specific target patterns first)
|
|
115
|
+
for rule in self.rules:
|
|
116
|
+
if rule.matches(action_str, target):
|
|
117
|
+
return rule.decision
|
|
118
|
+
|
|
119
|
+
# 2. Consult parent policy if present
|
|
120
|
+
if self.parent_policy:
|
|
121
|
+
return self.parent_policy.evaluate(action, target)
|
|
122
|
+
|
|
123
|
+
# 3. Fall back to default action decision (DENY if unknown action)
|
|
124
|
+
return self.default_decisions.get(action_str, PolicyDecision.DENY)
|
|
125
|
+
|
|
126
|
+
def is_allowed(self, action: ActionType | str, target: str = "") -> bool:
|
|
127
|
+
return self.evaluate(action, target) == PolicyDecision.ALLOW
|
|
128
|
+
|
|
129
|
+
def requires_approval(self, action: ActionType | str, target: str = "") -> bool:
|
|
130
|
+
return self.evaluate(action, target) == PolicyDecision.ASK
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def normalize_target(target: str) -> str:
|
|
134
|
+
"""Normalize a path/target string for consistent policy matching.
|
|
135
|
+
|
|
136
|
+
Security: prevents bypass via case differences, separator
|
|
137
|
+
inconsistencies, Unicode confusables, or drive letter prefixes.
|
|
138
|
+
|
|
139
|
+
Normalization steps:
|
|
140
|
+
1. Unicode NFC normalization (canonical decomposition + composition)
|
|
141
|
+
2. Backslash → forward slash
|
|
142
|
+
3. Lowercase
|
|
143
|
+
4. Strip Windows drive letter prefix (e.g., C:/)
|
|
144
|
+
5. Collapse consecutive slashes
|
|
145
|
+
6. Strip leading/trailing slashes for relative matching
|
|
146
|
+
"""
|
|
147
|
+
# 1. Unicode NFC normalization
|
|
148
|
+
normalized = unicodedata.normalize("NFC", target)
|
|
149
|
+
|
|
150
|
+
# 2. Normalize separators
|
|
151
|
+
normalized = normalized.replace("\\", "/")
|
|
152
|
+
|
|
153
|
+
# 3. Casefold (robust lowercasing for Unicode)
|
|
154
|
+
normalized = normalized.casefold()
|
|
155
|
+
|
|
156
|
+
# 4. Strip drive letter prefix (e.g., c:/ or C:/ or C:file.txt)
|
|
157
|
+
if len(normalized) >= 2 and normalized[0].isalpha() and normalized[1] == ":":
|
|
158
|
+
if len(normalized) >= 3 and normalized[2] == "/":
|
|
159
|
+
normalized = normalized[3:] # Strip "c:/"
|
|
160
|
+
else:
|
|
161
|
+
normalized = normalized[2:] # Strip "c:" (drive-relative)
|
|
162
|
+
|
|
163
|
+
# 5. Collapse consecutive slashes
|
|
164
|
+
while "//" in normalized:
|
|
165
|
+
normalized = normalized.replace("//", "/")
|
|
166
|
+
|
|
167
|
+
# 6. Strip leading slash for relative matching (preserves internal structure)
|
|
168
|
+
normalized = normalized.strip("/")
|
|
169
|
+
|
|
170
|
+
return normalized
|
|
171
|
+
|
|
172
|
+
@classmethod
|
|
173
|
+
def from_dict(cls, data: dict[str, Any], parent_policy: SandboxPolicy | None = None) -> SandboxPolicy:
|
|
174
|
+
defaults: dict[str, PolicyDecision] = {}
|
|
175
|
+
for action_name, decision_val in data.get("defaults", {}).items():
|
|
176
|
+
defaults[action_name.casefold()] = PolicyDecision(str(decision_val).casefold())
|
|
177
|
+
|
|
178
|
+
rules: list[PolicyRule] = []
|
|
179
|
+
for raw_rule in data.get("rules", []):
|
|
180
|
+
rules.append(
|
|
181
|
+
PolicyRule(
|
|
182
|
+
action=str(raw_rule.get("action", "*")).casefold(),
|
|
183
|
+
target_pattern=str(raw_rule.get("target_pattern", "*")),
|
|
184
|
+
decision=PolicyDecision(str(raw_rule.get("decision", "ask")).casefold()),
|
|
185
|
+
reason=str(raw_rule.get("reason", "")),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
return cls(default_decisions=defaults, rules=rules, parent_policy=parent_policy)
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def from_file(cls, path: Path, parent_policy: SandboxPolicy | None = None) -> SandboxPolicy:
|
|
193
|
+
text = path.read_text(encoding="utf-8")
|
|
194
|
+
raw = json.loads(text)
|
|
195
|
+
return cls.from_dict(raw, parent_policy=parent_policy)
|
|
196
|
+
|
|
197
|
+
def to_dict(self) -> dict[str, Any]:
|
|
198
|
+
return {
|
|
199
|
+
"defaults": {k: v.value for k, v in self.default_decisions.items()},
|
|
200
|
+
"rules": [
|
|
201
|
+
{
|
|
202
|
+
"action": r.action,
|
|
203
|
+
"target_pattern": r.target_pattern,
|
|
204
|
+
"decision": r.decision.value,
|
|
205
|
+
"reason": r.reason,
|
|
206
|
+
}
|
|
207
|
+
for r in reversed(self.rules)
|
|
208
|
+
],
|
|
209
|
+
}
|