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/process.py
ADDED
|
@@ -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()
|
pulse/sandbox/project.py
ADDED
|
@@ -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."""
|