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,331 @@
1
+ """Portable subprocess lifecycle management for sandbox backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ import signal
9
+ import sys
10
+ import typing
11
+ from dataclasses import dataclass
12
+ from enum import Enum
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ class ProcessEnforcementLevel(str, Enum):
18
+ """Degrees to which process containment can be guaranteed."""
19
+ UNSUPPORTED = "unsupported"
20
+ BEST_EFFORT = "best_effort"
21
+ STRONGLY_ENFORCED = "strongly_enforced"
22
+
23
+
24
+ from pulse.sandbox.resources import (
25
+ ExecutionMetrics,
26
+ ResourceController,
27
+ ResourceLimits,
28
+ ResourcePolicy,
29
+ )
30
+ from pulse.subprocesses import isolated_process_kwargs
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ @dataclass(frozen=True, slots=True)
36
+ class ProcessResult:
37
+ """Outcome and resource observations for a managed process execution."""
38
+ command: str
39
+ exit_code: int
40
+ stdout: str
41
+ stderr: str
42
+ duration_ms: float
43
+ timed_out: bool = False
44
+ truncated: bool = False
45
+ pid: int | None = None
46
+ overlay_path: Path | None = None
47
+ metrics: ExecutionMetrics | None = None
48
+ termination_reason: str | None = None
49
+
50
+
51
+ class ProcessManager:
52
+ """Runs isolated process groups with timeout, cancellation and output caps."""
53
+ def __init__(self) -> None:
54
+ self._active_processes: set[asyncio.subprocess.Process] = set()
55
+ # Backends may configure a process-wide default policy. Per-call
56
+ # limits remain authoritative when supplied to ``execute``.
57
+ self.limits: ResourceLimits | ResourcePolicy = ResourcePolicy()
58
+
59
+ @property
60
+ def active_count(self) -> int:
61
+ return len(self._active_processes)
62
+
63
+ async def execute(self, command: str | list[str], cwd: Path | str | None = None, env: dict[str, str] | None = None, limits: ResourceLimits | ResourcePolicy | None = None, output_callback: typing.Callable[[str, bytes], typing.Awaitable[None]] | None = None, *, apply_native_limits: bool = True) -> ProcessResult:
64
+ effective_limits = limits if limits is not None else self.limits
65
+ controller = ResourceController(
66
+ effective_limits
67
+ if isinstance(effective_limits, ResourcePolicy)
68
+ else effective_limits.to_policy()
69
+ )
70
+ policy = controller.policy
71
+ cmd_str = command if isinstance(command, str) else " ".join(command)
72
+ extra_kwargs: dict[str, Any] = {
73
+ "close_fds": True,
74
+ **isolated_process_kwargs(),
75
+ }
76
+ if sys.platform != "win32":
77
+ extra_kwargs["start_new_session"] = True
78
+ if apply_native_limits:
79
+ extra_kwargs["preexec_fn"] = controller.make_preexec_fn()
80
+
81
+ proc: asyncio.subprocess.Process | None = None
82
+ stdout = b""
83
+ stderr = b""
84
+ captured: dict[str, bytearray] = {
85
+ "stdout": bytearray(),
86
+ "stderr": bytearray(),
87
+ }
88
+ reason: str | None = None
89
+ controller.monitor.start()
90
+ try:
91
+ if isinstance(command, str):
92
+ proc = await asyncio.create_subprocess_shell(command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(cwd) if cwd else None, env=controller.sanitize_env(env), **extra_kwargs)
93
+ else:
94
+ proc = await asyncio.create_subprocess_exec(command[0], *command[1:], stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=str(cwd) if cwd else None, env=controller.sanitize_env(env), **extra_kwargs)
95
+ self._active_processes.add(proc)
96
+ try:
97
+ stdout, stderr, output_limited = await asyncio.wait_for(
98
+ self._collect_output(
99
+ proc,
100
+ policy.max_output_bytes,
101
+ output_callback,
102
+ captured,
103
+ ),
104
+ timeout=policy.wall_time_seconds,
105
+ )
106
+ if output_limited:
107
+ reason = "output_limit"
108
+ except TimeoutError:
109
+ reason = "timeout"
110
+ await self._terminate_tree(proc, policy.termination_grace_seconds)
111
+ remaining_stdout, remaining_stderr = await self._drain_output(proc)
112
+ stdout = bytes(captured["stdout"]) + remaining_stdout
113
+ stderr = bytes(captured["stderr"]) + remaining_stderr
114
+ stderr += b"\nProcess execution timed out."
115
+ except asyncio.CancelledError:
116
+ reason = "cancelled"
117
+ await self._terminate_tree(proc, policy.termination_grace_seconds)
118
+ raise
119
+ except asyncio.CancelledError:
120
+ raise
121
+ except Exception as error: # noqa: BLE001, execution boundaries return structured result
122
+ reason = reason or "launch_error"
123
+ stderr += str(error).encode("utf-8", errors="replace")
124
+ finally:
125
+ if proc is not None:
126
+ if proc.returncode is None:
127
+ await self._terminate_tree(proc, policy.termination_grace_seconds)
128
+ self._close_process_transport(proc)
129
+ self._active_processes.discard(proc)
130
+
131
+ exit_code = -9 if reason == "timeout" else (proc.returncode if proc and proc.returncode is not None else -1)
132
+ clean_stdout, stdout_truncated = self._truncate(stdout, policy.max_output_bytes)
133
+ clean_stderr, stderr_truncated = self._truncate(stderr, policy.max_output_bytes)
134
+ metrics = controller.monitor.finish(output_bytes=len(stdout) + len(stderr), exit_status=exit_code, termination_reason=reason)
135
+ if reason == "output_limit":
136
+ marker = "\n... [OUTPUT TRUNCATED BY SANDBOX RESOURCE LIMITER]"
137
+ if len(clean_stdout) >= len(clean_stderr):
138
+ clean_stdout += marker
139
+ else:
140
+ clean_stderr += marker
141
+ return ProcessResult(
142
+ cmd_str,
143
+ exit_code,
144
+ clean_stdout,
145
+ clean_stderr,
146
+ metrics.elapsed_ms,
147
+ reason == "timeout",
148
+ stdout_truncated or stderr_truncated or reason == "output_limit",
149
+ getattr(proc, "pid", None) if proc else None,
150
+ metrics=metrics,
151
+ termination_reason=reason,
152
+ )
153
+
154
+ async def _collect_output(self, proc: asyncio.subprocess.Process, max_bytes: int, output_callback: typing.Callable[[str, bytes], typing.Awaitable[None]] | None = None, captured: dict[str, bytearray] | None = None) -> tuple[bytes, bytes, bool]:
155
+ chunks = captured or {"stdout": bytearray(), "stderr": bytearray()}
156
+ total = 0
157
+ exceeded = False
158
+ lock = asyncio.Lock()
159
+
160
+ async def read_stream(stream: asyncio.StreamReader | None, name: str) -> None:
161
+ nonlocal total, exceeded
162
+ if stream is None:
163
+ return
164
+ while data := await stream.read(65_536):
165
+ async with lock:
166
+ if output_callback:
167
+ try:
168
+ await output_callback(name, data)
169
+ except OSError:
170
+ logger.debug("output_callback failed for stream %s", name)
171
+ remaining = max_bytes - total
172
+ if remaining <= 0:
173
+ exceeded = True
174
+ else:
175
+ chunks[name].extend(data[:remaining])
176
+ total += min(len(data), remaining)
177
+ exceeded = len(data) > remaining
178
+ if exceeded:
179
+ await self._terminate_tree(proc, 0.1)
180
+ return
181
+
182
+ await asyncio.gather(read_stream(proc.stdout, "stdout"), read_stream(proc.stderr, "stderr"))
183
+ await proc.wait()
184
+ return bytes(chunks["stdout"]), bytes(chunks["stderr"]), exceeded
185
+
186
+ @staticmethod
187
+ async def _drain_output(
188
+ proc: asyncio.subprocess.Process,
189
+ ) -> tuple[bytes, bytes]:
190
+ """Drain bytes emitted while a timed-out process is being terminated."""
191
+
192
+ async def read(stream: asyncio.StreamReader | None) -> bytes:
193
+ if stream is None:
194
+ return b""
195
+ try:
196
+ # Descendants can inherit a pipe after the direct child exits;
197
+ # never let diagnostic draining defeat the sandbox deadline.
198
+ return await asyncio.wait_for(stream.read(), timeout=0.5)
199
+ except TimeoutError:
200
+ return b""
201
+
202
+ return await asyncio.gather(read(proc.stdout), read(proc.stderr))
203
+
204
+ @staticmethod
205
+ def _truncate(content: bytes, max_bytes: int) -> tuple[str, bool]:
206
+ if len(content) <= max_bytes:
207
+ return content.decode("utf-8", errors="replace"), False
208
+ return content[:max_bytes].decode("utf-8", errors="ignore"), True
209
+
210
+ async def _terminate_tree(self, proc: asyncio.subprocess.Process, grace_seconds: float) -> None:
211
+ """Terminate the complete process tree, then force reap it if needed.
212
+
213
+ Security architecture:
214
+ On POSIX, ``start_new_session=True`` places the child in a new
215
+ process group whose pgid equals the child PID. ``killpg()``
216
+ sends signals to every process in that group, covering all
217
+ descendants that have NOT called ``setsid()`` / ``setpgid()``.
218
+
219
+ Limitations:
220
+ A descendant that calls ``setsid()`` or ``setpgid(0, 0)``
221
+ creates a new process group and **escapes** ``killpg()``.
222
+ This is a fundamental POSIX limitation. Container backends
223
+ (Docker/Podman) provide PID-namespace isolation which is the
224
+ definitive containment boundary for untrusted code.
225
+ """
226
+ if proc.returncode is not None:
227
+ return
228
+ try:
229
+ if sys.platform == "win32":
230
+ # CREATE_NEW_PROCESS_GROUP does not terminate descendants when
231
+ # the direct child is killed. Use taskkill's tree traversal,
232
+ # but bound the helper so it cannot defeat the sandbox timeout.
233
+ helper: asyncio.subprocess.Process | None = None
234
+ try:
235
+ helper = await asyncio.create_subprocess_exec(
236
+ "taskkill",
237
+ "/PID",
238
+ str(proc.pid),
239
+ "/T",
240
+ "/F",
241
+ stdout=asyncio.subprocess.DEVNULL,
242
+ stderr=asyncio.subprocess.DEVNULL,
243
+ **isolated_process_kwargs(),
244
+ )
245
+ await asyncio.wait_for(
246
+ helper.wait(), timeout=max(1.0, min(5.0, grace_seconds + 1.0))
247
+ )
248
+ except (OSError, ProcessLookupError, TimeoutError):
249
+ if helper is not None and helper.returncode is None:
250
+ helper.kill()
251
+ await helper.wait()
252
+
253
+ if proc.returncode is None:
254
+ try:
255
+ proc.kill()
256
+ except ProcessLookupError:
257
+ pass
258
+ try:
259
+ await asyncio.wait_for(
260
+ proc.wait(), timeout=max(1.0, grace_seconds + 1.0)
261
+ )
262
+ except TimeoutError:
263
+ logger.warning("Timed out reaping Windows process rooted at %s.", proc.pid)
264
+ return
265
+
266
+ # Cache pgid once to avoid PID-recycling race on repeated lookups.
267
+ try:
268
+ pgid = os.getpgid(proc.pid)
269
+ except (ProcessLookupError, PermissionError):
270
+ # Process already exited — nothing to kill.
271
+ return
272
+
273
+ # 1. Graceful SIGTERM to entire process group.
274
+ try:
275
+ os.killpg(pgid, signal.SIGTERM)
276
+ except (ProcessLookupError, PermissionError):
277
+ return
278
+
279
+ # 2. Wait for the grace period.
280
+ try:
281
+ await asyncio.wait_for(proc.wait(), timeout=grace_seconds)
282
+ except TimeoutError:
283
+ # 3. Escalate to SIGKILL.
284
+ try:
285
+ os.killpg(pgid, signal.SIGKILL)
286
+ except (ProcessLookupError, PermissionError):
287
+ pass
288
+
289
+ # 4. Reap the direct child.
290
+ await proc.wait()
291
+
292
+ # 5. Best-effort reap of any remaining group members.
293
+ self._reap_remaining_group(pgid)
294
+
295
+ except (ProcessLookupError, PermissionError):
296
+ pass
297
+
298
+ @staticmethod
299
+ def _close_process_transport(proc: asyncio.subprocess.Process) -> None:
300
+ """Close subprocess pipes after output collection and reaping."""
301
+ transport = getattr(proc, "_transport", None)
302
+ if transport is not None:
303
+ transport.close()
304
+
305
+ @staticmethod
306
+ def _reap_remaining_group(pgid: int) -> None:
307
+ """Best-effort reap of orphaned members still in *pgid*.
308
+
309
+ Uses ``os.waitpid(-pgid, WNOHANG)`` which reaps any child of the
310
+ current process whose process-group equals *pgid*. This covers
311
+ grandchildren that were re-parented to PID 1 / subreaper but are
312
+ still in the original process group.
313
+
314
+ Silently ignored on platforms where this is unsupported.
315
+ """
316
+ if sys.platform == "win32":
317
+ return
318
+ for _ in range(64): # bounded loop to avoid infinite reaping
319
+ try:
320
+ pid, _ = os.waitpid(-pgid, os.WNOHANG)
321
+ if pid == 0:
322
+ break # no more waitable children in this group
323
+ except ChildProcessError:
324
+ break # no children to wait for
325
+ except OSError:
326
+ break
327
+
328
+ async def terminate_all(self) -> None:
329
+ """Kill all tracked active processes and reap them."""
330
+ await asyncio.gather(*(self._terminate_tree(proc, 0.1) for proc in list(self._active_processes)), return_exceptions=True)
331
+ self._active_processes.clear()
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ from pulse.audit import AuditLog
7
+ from pulse.config import SandboxConfig
8
+ from pulse.mutations import MutationTracker
9
+
10
+
11
+ class ProjectSandbox:
12
+ def __init__(self, config: SandboxConfig, audit: AuditLog, mutations: MutationTracker | None = None) -> None:
13
+ self.config = config
14
+ self.audit = audit
15
+ self.mutations = mutations or MutationTracker(config.workspace_root)
16
+
17
+ def list_files(self) -> list[str]:
18
+ ignored = {".git", ".agent", ".agents", ".venv", "__pycache__"}
19
+ files: list[str] = []
20
+
21
+ for path in self.config.workspace_root.rglob("*"):
22
+ if any(part in ignored for part in path.relative_to(self.config.workspace_root).parts):
23
+ continue
24
+ if path.is_file():
25
+ files.append(self._display_path(path))
26
+
27
+ return sorted(files)
28
+
29
+ def read_file(self, file: str, reason: str, *, auto_approve: bool = False) -> str | None:
30
+ if self.config.require_permission_for_reads and not auto_approve and not self._ask_permission("read", file, reason):
31
+ self.audit.record("read-denied", file, "User denied read permission.")
32
+ return None
33
+
34
+ path = self._assert_inside_workspace(file)
35
+ content = path.read_text(encoding="utf-8", errors="replace")
36
+ self.audit.record("read", file, "Read file with permission.")
37
+ return content[:12_000]
38
+
39
+ def request_project_action(self, action: str, file: str, reason: str) -> bool:
40
+ if self.config.require_permission_for_project_actions and not self._ask_permission(action, file, reason):
41
+ self.audit.record(f"{action}-denied", file, "User denied project action.")
42
+ return False
43
+
44
+ self.audit.record(action, file, reason)
45
+ return True
46
+
47
+ def write_file(self, file: str, content: str, reason: str) -> bool:
48
+ if not self.config.allow_writes:
49
+ self.audit.record("edit-blocked", file, "Writes are disabled by sandbox config.")
50
+ print(f"Writes are disabled. Skipped edit on {file}.")
51
+ return False
52
+
53
+ if not self.request_project_action("edit", file, reason):
54
+ return False
55
+
56
+ path = self._assert_inside_workspace(file)
57
+ with self.mutations.transaction():
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ path.write_text(content, encoding="utf-8")
60
+ self.audit.record("edited", file, "Edited file with permission.")
61
+ return True
62
+
63
+ def read_file_for_edit(self, file: str) -> str | None:
64
+ """Read the complete current text for a proposed edit without prompting.
65
+
66
+ The proposal is not an action and the caller must still obtain explicit
67
+ approval before ``apply_approved_edit`` can write it.
68
+ """
69
+ path = self._assert_inside_workspace(file)
70
+ return path.read_text(encoding="utf-8", errors="replace") if path.exists() else None
71
+
72
+ def apply_approved_edit(self, file: str, content: str, reason: str) -> None:
73
+ """Apply an edit already approved by the edit workflow.
74
+
75
+ This deliberately does not consult ``allow_writes``: that setting keeps
76
+ unapproved/direct writes off by default, while this narrow method is
77
+ protected by the workflow's per-edit approval.
78
+ """
79
+ path = self._assert_inside_workspace(file)
80
+ with self.mutations.transaction(command="pulse approved edit"):
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ self._write_text_exact(path, content)
83
+ self.audit.record("edited", file, f"Approved edit: {reason}")
84
+
85
+ def record_rejected_edit(self, file: str, reason: str) -> None:
86
+ self.audit.record("edit-rejected", file, reason)
87
+
88
+ def rollback_last_approved_edit(self) -> bool:
89
+ events = self.mutations.last_approved_edit()
90
+ if not events:
91
+ return False
92
+ with self.mutations.transaction(command="pulse rollback"):
93
+ for event in events:
94
+ self._restore_event(event)
95
+ self.audit.record("rollback", ", ".join(str(event["file_path"]) for event in events), "Restored last approved edit.")
96
+ return True
97
+
98
+ def _restore_event(self, event: dict[str, object]) -> None:
99
+ file = str(event["file_path"])
100
+ if " -> " in file:
101
+ raise ValueError("Rollback does not support renamed files.")
102
+ path = self._assert_inside_workspace(file)
103
+ before = event.get("before_content")
104
+ if before is None:
105
+ if path.exists():
106
+ path.unlink()
107
+ return
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ self._write_text_exact(path, str(before))
110
+
111
+ @staticmethod
112
+ def _write_text_exact(path: Path, content: str) -> None:
113
+ """Avoid Windows newline conversion when restoring a tracked snapshot."""
114
+ with path.open("w", encoding="utf-8", newline="") as handle:
115
+ handle.write(content)
116
+
117
+ def delete_file(self, file: str, reason: str) -> bool:
118
+ if not self.config.allow_writes:
119
+ self.audit.record("delete-blocked", file, "Writes are disabled by sandbox config.")
120
+ return False
121
+ if not self.request_project_action("delete", file, reason):
122
+ return False
123
+ path = self._assert_inside_workspace(file)
124
+ with self.mutations.transaction():
125
+ path.unlink()
126
+ self.audit.record("deleted", file, "Deleted file with permission.")
127
+ return True
128
+
129
+ def rename_file(self, source: str, destination: str, reason: str) -> bool:
130
+ if not self.config.allow_writes:
131
+ self.audit.record("rename-blocked", source, "Writes are disabled by sandbox config.")
132
+ return False
133
+ if not self.request_project_action("rename", source, reason):
134
+ return False
135
+ source_path = self._assert_inside_workspace(source)
136
+ destination_path = self._assert_inside_workspace(destination)
137
+ with self.mutations.transaction():
138
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
139
+ source_path.rename(destination_path)
140
+ self.audit.record("renamed", f"{source} -> {destination}", "Renamed file with permission.")
141
+ return True
142
+
143
+ def _ask_permission(self, action: str, file: str, reason: str) -> bool:
144
+ if not sys.stdin.isatty():
145
+ print(f"Denied {action} on {file}: no interactive terminal available for permission.")
146
+ return False
147
+
148
+ answer = input(f"Allow {action} on {file}? {reason} [y/N] ").strip().lower()
149
+ return answer in {"y", "yes"}
150
+
151
+ def _assert_inside_workspace(self, file: str) -> Path:
152
+ path = (self.config.workspace_root / file).resolve()
153
+ if path != self.config.workspace_root and self.config.workspace_root not in path.parents:
154
+ raise ValueError(f"Path is outside workspace: {file}")
155
+ return path
156
+
157
+ def _display_path(self, path: Path) -> str:
158
+ return str(path.relative_to(self.config.workspace_root))
@@ -0,0 +1,62 @@
1
+ """Policy-checked safe Python and package environment execution wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import TYPE_CHECKING
7
+
8
+ from pulse.sandbox.policy import ActionType, PolicyDecision
9
+ from pulse.sandbox.process import ProcessResult
10
+
11
+ if TYPE_CHECKING:
12
+ from pulse.sandbox.api import Sandbox
13
+
14
+
15
+ class SafePython:
16
+ """Provides policy-gated access to Python interpreter and virtual environments."""
17
+
18
+ def __init__(self, sandbox: Sandbox, python_executable: str | None = None) -> None:
19
+ self.sandbox = sandbox
20
+ self.python_executable = python_executable or sys.executable
21
+
22
+ async def run_script(self, script_path: str, args: list[str] | None = None) -> ProcessResult:
23
+ decision = self.sandbox.policy.evaluate(ActionType.PYTHON, script_path)
24
+ if decision == PolicyDecision.DENY:
25
+ return ProcessResult(
26
+ command=f"python {script_path}",
27
+ exit_code=-1,
28
+ stdout="",
29
+ stderr="Policy denied Python script execution.",
30
+ duration_ms=0.0,
31
+ )
32
+
33
+ cmd = [self.python_executable, script_path] + (args or [])
34
+ return await self.sandbox.execute_command(cmd)
35
+
36
+ async def run_module(self, module: str, args: list[str] | None = None) -> ProcessResult:
37
+ decision = self.sandbox.policy.evaluate(ActionType.PYTHON, f"-m {module}")
38
+ if decision == PolicyDecision.DENY:
39
+ return ProcessResult(
40
+ command=f"python -m {module}",
41
+ exit_code=-1,
42
+ stdout="",
43
+ stderr="Policy denied Python module execution.",
44
+ duration_ms=0.0,
45
+ )
46
+
47
+ cmd = [self.python_executable, "-m", module] + (args or [])
48
+ return await self.sandbox.execute_command(cmd)
49
+
50
+ async def run_uv(self, uv_args: list[str]) -> ProcessResult:
51
+ decision = self.sandbox.policy.evaluate(ActionType.PYTHON, f"uv {' '.join(uv_args)}")
52
+ if decision == PolicyDecision.DENY:
53
+ return ProcessResult(
54
+ command=f"uv {' '.join(uv_args)}",
55
+ exit_code=-1,
56
+ stdout="",
57
+ stderr="Policy denied uv command execution.",
58
+ duration_ms=0.0,
59
+ )
60
+
61
+ cmd = ["uv"] + uv_args
62
+ return await self.sandbox.execute_command(cmd)
@@ -0,0 +1 @@
1
+ """Remote Sandbox Backend and Worker definitions."""