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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Remote execution backend for Sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import typing
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from pulse.sandbox.errors import SandboxUnavailableError
|
|
11
|
+
from pulse.sandbox.network import NetworkEnforcementLevel, NetworkMode, NetworkPolicy
|
|
12
|
+
from pulse.sandbox.process import ProcessEnforcementLevel, ProcessResult
|
|
13
|
+
from pulse.sandbox.remote.client import RemoteClient
|
|
14
|
+
from pulse.sandbox.remote.models import SubmitExecutionRequest
|
|
15
|
+
from pulse.sandbox.resources import ResourceLimits
|
|
16
|
+
from pulse.sandbox.secrets import SecretEnforcementLevel, SecretPolicy
|
|
17
|
+
from pulse.telemetry import get_correlation_id
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RemoteSandboxBackend:
|
|
21
|
+
"""Production-grade remote container execution backend.
|
|
22
|
+
|
|
23
|
+
Communicates securely with a remote worker that enforces
|
|
24
|
+
strong container isolation identical to the local DockerBackend.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, endpoint_url: str | None = None, auth_token: str | None = None) -> None:
|
|
28
|
+
self.endpoint_url = endpoint_url
|
|
29
|
+
self.auth_token = auth_token
|
|
30
|
+
self._client: RemoteClient | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def client(self) -> RemoteClient:
|
|
34
|
+
if not self._client:
|
|
35
|
+
if not self.endpoint_url or not self.auth_token:
|
|
36
|
+
raise SandboxUnavailableError("Remote backend is not fully configured (missing endpoint or token).")
|
|
37
|
+
self._client = RemoteClient(self.endpoint_url, self.auth_token)
|
|
38
|
+
return self._client
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def name(self) -> str:
|
|
42
|
+
return "remote"
|
|
43
|
+
|
|
44
|
+
async def is_available(self) -> bool:
|
|
45
|
+
"""Return True if remote endpoint is configured and reachable."""
|
|
46
|
+
if not self.endpoint_url or not self.auth_token:
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
# Try to establish a connection to check health
|
|
50
|
+
try:
|
|
51
|
+
client = self.client
|
|
52
|
+
await client.connect()
|
|
53
|
+
await client.disconnect()
|
|
54
|
+
return True
|
|
55
|
+
except (OSError, ConnectionError, TimeoutError, ValueError, RuntimeError):
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
async def execute(
|
|
59
|
+
self,
|
|
60
|
+
command: str | list[str],
|
|
61
|
+
workspace_root: Path,
|
|
62
|
+
cwd: Path | None = None,
|
|
63
|
+
env: dict[str, str] | None = None,
|
|
64
|
+
limits: ResourceLimits | None = None,
|
|
65
|
+
network_policy: NetworkPolicy | None = None,
|
|
66
|
+
secret_policy: SecretPolicy | None = None,
|
|
67
|
+
execution_id: str | None = None,
|
|
68
|
+
output_callback: typing.Callable[[str, bytes], typing.Awaitable[None]] | None = None,
|
|
69
|
+
) -> ProcessResult:
|
|
70
|
+
|
|
71
|
+
exec_id = execution_id or str(uuid.uuid4())
|
|
72
|
+
|
|
73
|
+
# Calculate relative working directory
|
|
74
|
+
rel_cwd = None
|
|
75
|
+
if cwd:
|
|
76
|
+
try:
|
|
77
|
+
rel_cwd = cwd.relative_to(workspace_root).as_posix()
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# Convert limits to policy format
|
|
82
|
+
from pulse.sandbox.resources import ResourcePolicy
|
|
83
|
+
res_policy = limits if isinstance(limits, ResourcePolicy) else (limits.to_policy() if limits else None)
|
|
84
|
+
|
|
85
|
+
req = SubmitExecutionRequest(
|
|
86
|
+
protocol_version="1.0",
|
|
87
|
+
execution_id=exec_id,
|
|
88
|
+
idempotency_key=str(uuid.uuid4()),
|
|
89
|
+
command=command,
|
|
90
|
+
correlation_id=get_correlation_id(),
|
|
91
|
+
working_directory=rel_cwd,
|
|
92
|
+
env=env,
|
|
93
|
+
resource_policy_dict=res_policy.to_dict() if res_policy else None,
|
|
94
|
+
network_policy_dict=network_policy.to_dict() if network_policy else None,
|
|
95
|
+
secret_policy_dict=secret_policy.to_dict() if secret_policy else None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
client = self.client
|
|
99
|
+
|
|
100
|
+
import io
|
|
101
|
+
import tarfile
|
|
102
|
+
import tempfile
|
|
103
|
+
|
|
104
|
+
# Phase 6: Upload artifacts
|
|
105
|
+
bio = io.BytesIO()
|
|
106
|
+
with tarfile.open(fileobj=bio, mode="w:gz") as tar:
|
|
107
|
+
if workspace_root.exists():
|
|
108
|
+
for item in workspace_root.iterdir():
|
|
109
|
+
# Skip internal sandbox tracking directories to save space
|
|
110
|
+
if item.name == ".agent":
|
|
111
|
+
continue
|
|
112
|
+
tar.add(item, arcname=item.name)
|
|
113
|
+
|
|
114
|
+
await client.upload_artifact(exec_id, bio.getvalue())
|
|
115
|
+
|
|
116
|
+
# Submit execution
|
|
117
|
+
await client.submit(req)
|
|
118
|
+
|
|
119
|
+
# Process streaming output in the background
|
|
120
|
+
async def handle_stream() -> None:
|
|
121
|
+
if output_callback:
|
|
122
|
+
async for stream_type, chunk in client.stream_output(exec_id):
|
|
123
|
+
await output_callback(stream_type, chunk.encode("utf-8", errors="replace"))
|
|
124
|
+
else:
|
|
125
|
+
# Still consume it so the queue doesn't back up
|
|
126
|
+
async for _ in client.stream_output(exec_id):
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
stream_task = asyncio.create_task(handle_stream())
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
# Wait for completion
|
|
133
|
+
res_model = await client.get_result(exec_id)
|
|
134
|
+
except asyncio.CancelledError:
|
|
135
|
+
await client.cancel(exec_id)
|
|
136
|
+
raise
|
|
137
|
+
finally:
|
|
138
|
+
await stream_task
|
|
139
|
+
|
|
140
|
+
# Phase 6: Download artifacts
|
|
141
|
+
overlay_bytes = await client.download_artifact(exec_id)
|
|
142
|
+
local_overlay_path = None
|
|
143
|
+
if overlay_bytes:
|
|
144
|
+
local_overlay_path = Path(tempfile.gettempdir()) / f"pulse_remote_overlay_{exec_id}"
|
|
145
|
+
local_overlay_path.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
try:
|
|
147
|
+
with tarfile.open(fileobj=io.BytesIO(overlay_bytes), mode="r:gz") as tar:
|
|
148
|
+
import os
|
|
149
|
+
max_size = 50 * 1024 * 1024
|
|
150
|
+
current_size = 0
|
|
151
|
+
for member in tar.getmembers():
|
|
152
|
+
if member.issym() or member.islnk():
|
|
153
|
+
from pulse.sandbox.errors import SandboxSecurityError
|
|
154
|
+
raise SandboxSecurityError("Symlinks are not allowed in remote artifacts")
|
|
155
|
+
current_size += member.size
|
|
156
|
+
if current_size > max_size:
|
|
157
|
+
raise SandboxSecurityError(f"Artifact size exceeded limit of {max_size} bytes")
|
|
158
|
+
|
|
159
|
+
member_path = os.path.join(str(local_overlay_path), member.name)
|
|
160
|
+
if not os.path.abspath(member_path).startswith(os.path.abspath(str(local_overlay_path))):
|
|
161
|
+
raise SandboxSecurityError("Path traversal detected in download_artifact")
|
|
162
|
+
if hasattr(tarfile, 'data_filter'):
|
|
163
|
+
tar.extractall(path=local_overlay_path, filter='data')
|
|
164
|
+
else:
|
|
165
|
+
tar.extractall(path=local_overlay_path)
|
|
166
|
+
except (OSError, ValueError, RuntimeError) as e:
|
|
167
|
+
from pulse.sandbox.errors import SandboxSecurityError
|
|
168
|
+
# Raise an explicit security error on path traversal
|
|
169
|
+
raise SandboxSecurityError(
|
|
170
|
+
f"Remote execution compromised: Malformed or malicious workspace overlay. Details: {e}",
|
|
171
|
+
operation="download_artifact",
|
|
172
|
+
path=str(local_overlay_path),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
return ProcessResult(
|
|
176
|
+
command=res_model.command,
|
|
177
|
+
exit_code=res_model.exit_code,
|
|
178
|
+
stdout=res_model.stdout,
|
|
179
|
+
stderr=res_model.stderr,
|
|
180
|
+
duration_ms=res_model.duration_ms,
|
|
181
|
+
timed_out=res_model.timed_out,
|
|
182
|
+
truncated=res_model.truncated,
|
|
183
|
+
pid=None, # Remote PID not exposed securely
|
|
184
|
+
overlay_path=local_overlay_path,
|
|
185
|
+
termination_reason=res_model.termination_reason,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def get_network_enforcement_capability(self, policy: NetworkPolicy) -> NetworkEnforcementLevel:
|
|
189
|
+
"""Determine if this backend can strongly enforce the requested network policy."""
|
|
190
|
+
if not policy or policy.mode == NetworkMode.ALLOW_ALL:
|
|
191
|
+
return NetworkEnforcementLevel.STRONGLY_ENFORCED
|
|
192
|
+
|
|
193
|
+
if policy.mode in (NetworkMode.DENY_ALL, NetworkMode.LOCALHOST_ONLY):
|
|
194
|
+
return NetworkEnforcementLevel.STRONGLY_ENFORCED
|
|
195
|
+
|
|
196
|
+
return NetworkEnforcementLevel.UNSUPPORTED
|
|
197
|
+
|
|
198
|
+
def get_secret_enforcement_capability(self, policy: SecretPolicy) -> SecretEnforcementLevel:
|
|
199
|
+
"""Determine if this backend can strongly enforce the requested secret isolation policy."""
|
|
200
|
+
from pulse.sandbox.secrets import SecretMode
|
|
201
|
+
if not policy or policy.mode == SecretMode.ALLOW_ALL:
|
|
202
|
+
return SecretEnforcementLevel.STRONGLY_ENFORCED
|
|
203
|
+
|
|
204
|
+
if policy.mode in (SecretMode.DENY_ALL, SecretMode.ALLOW_EXPLICIT):
|
|
205
|
+
return SecretEnforcementLevel.STRONGLY_ENFORCED
|
|
206
|
+
|
|
207
|
+
return SecretEnforcementLevel.UNSUPPORTED
|
|
208
|
+
|
|
209
|
+
def get_process_containment_capability(self) -> ProcessEnforcementLevel:
|
|
210
|
+
"""Determine if this backend provides strong process containment.
|
|
211
|
+
|
|
212
|
+
Delegated to remote DockerBackend.
|
|
213
|
+
"""
|
|
214
|
+
return ProcessEnforcementLevel.STRONGLY_ENFORCED
|
|
215
|
+
|
|
216
|
+
async def cleanup(self) -> None:
|
|
217
|
+
"""Reap temporary remote execution artifacts."""
|
|
218
|
+
if self._client:
|
|
219
|
+
await self._client.disconnect()
|
|
220
|
+
|
|
221
|
+
async def reconcile(self) -> None:
|
|
222
|
+
"""Clean up orphaned remote backend resources."""
|
|
223
|
+
if self._client:
|
|
224
|
+
await self._client.reconcile()
|
pulse/sandbox/errors.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Custom exception hierarchy for Pulse sandbox security failures.
|
|
2
|
+
|
|
3
|
+
Provides structured, catchable error types for sandbox unavailability,
|
|
4
|
+
security boundary violations, and resource limit breaches.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SandboxUnavailableError(RuntimeError):
|
|
11
|
+
"""No secure execution backend (Docker/Podman) is available.
|
|
12
|
+
|
|
13
|
+
Raised when the sandbox cannot find a container engine and the caller
|
|
14
|
+
has NOT explicitly opted into unsafe host execution.
|
|
15
|
+
|
|
16
|
+
Security rationale:
|
|
17
|
+
Silent fallback to host execution is a catastrophic isolation failure.
|
|
18
|
+
This exception forces callers to make a conscious, auditable decision
|
|
19
|
+
about running untrusted code directly on the host.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, message: str | None = None) -> None:
|
|
23
|
+
super().__init__(
|
|
24
|
+
message
|
|
25
|
+
or (
|
|
26
|
+
"No secure container backend (Docker/Podman) is available. "
|
|
27
|
+
"Set unsafe_host_execution=True to explicitly allow host execution "
|
|
28
|
+
"(NOT recommended for untrusted code)."
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SandboxUnsupportedPolicyError(SandboxUnavailableError):
|
|
34
|
+
"""The requested security policy cannot be strongly enforced by the active backend.
|
|
35
|
+
|
|
36
|
+
Security rationale:
|
|
37
|
+
Fail-closed behavior is mandatory. If a restrictive network or isolation policy
|
|
38
|
+
is requested but the backend lacks the OS-level capability to enforce it
|
|
39
|
+
(e.g., trying to use ALLOWLIST in rootless Docker without egress filtering,
|
|
40
|
+
or DENY_ALL in HostBackend), execution must be rejected rather than silently
|
|
41
|
+
downgraded to advisory enforcement.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SandboxSecurityError(RuntimeError):
|
|
46
|
+
"""A sandbox security boundary has been violated.
|
|
47
|
+
|
|
48
|
+
Raised on TOCTOU detection, symlink escape attempts, path traversal
|
|
49
|
+
after validation, or any operation that breaches isolation invariants.
|
|
50
|
+
|
|
51
|
+
Security rationale:
|
|
52
|
+
Hard failure prevents partial-state exploitation. Every security
|
|
53
|
+
violation is terminal for the current operation.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, *, operation: str = "", path: str = "") -> None:
|
|
57
|
+
self.operation = operation
|
|
58
|
+
self.path = path
|
|
59
|
+
detail = f" [op={operation}]" if operation else ""
|
|
60
|
+
detail += f" [path={path}]" if path else ""
|
|
61
|
+
super().__init__(f"{message}{detail}")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SandboxResourceError(RuntimeError):
|
|
65
|
+
"""A sandbox resource limit has been exceeded.
|
|
66
|
+
|
|
67
|
+
Raised when file size, output size, or memory constraints are breached.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, message: str, *, limit_name: str = "", limit_value: int = 0) -> None:
|
|
71
|
+
self.limit_name = limit_name
|
|
72
|
+
self.limit_value = limit_value
|
|
73
|
+
super().__init__(message)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SandboxConcurrentModificationError(RuntimeError):
|
|
77
|
+
"""A target file was modified externally during a CoW transaction.
|
|
78
|
+
|
|
79
|
+
Raised when ``commit_transaction()`` detects that a file's identity,
|
|
80
|
+
size, mtime, or content has changed since the transaction first staged
|
|
81
|
+
it. The transaction is NOT destroyed when this error is raised, so
|
|
82
|
+
callers may retry or inspect staged changes.
|
|
83
|
+
|
|
84
|
+
Attributes:
|
|
85
|
+
path: Relative workspace path of the conflicting file.
|
|
86
|
+
reason: Human-readable description of the detected change.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, message: str, *, path: str = "", reason: str = "") -> None:
|
|
90
|
+
self.path = path
|
|
91
|
+
self.reason = reason
|
|
92
|
+
super().__init__(message)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class SandboxRecoveryError(RuntimeError):
|
|
96
|
+
"""A fatal error occurred during CoW transaction recovery.
|
|
97
|
+
|
|
98
|
+
Raised when a WAL replay detects path traversal attempts, malformed data,
|
|
99
|
+
or irreconcilable concurrency conflicts (where a file was modified externally
|
|
100
|
+
while the system was offline).
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(self, message: str, *, path: str = "", reason: str = "") -> None:
|
|
104
|
+
self.path = path
|
|
105
|
+
self.reason = reason
|
|
106
|
+
super().__init__(message)
|