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.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
@@ -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
@@ -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
+ }