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/api.py
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""High-level Sandbox API facade with Dependency Injection.
|
|
2
|
+
|
|
3
|
+
Unified entry point integrating SandboxPolicy, PathValidator, SecretScrubber,
|
|
4
|
+
StructuredAuditLogger, ResourceLimiter, ContainerBackend, and CoWFilesystem.
|
|
5
|
+
|
|
6
|
+
Security hardening:
|
|
7
|
+
- Fail-secure: raises SandboxUnavailableError if no container backend available
|
|
8
|
+
and unsafe_host_execution is not explicitly True.
|
|
9
|
+
- read_file() uses TOCTOU-safe PathValidator.safe_read() with size limits.
|
|
10
|
+
- execute_command() determines isolation_level and logs it in audit.
|
|
11
|
+
- Host fallback requires explicit opt-in and logs UNSAFE_HOST warnings.
|
|
12
|
+
- initialize() must be called before first execute_command().
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import time
|
|
18
|
+
import uuid
|
|
19
|
+
import warnings
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from pulse.sandbox.audit import StructuredAuditLogger
|
|
24
|
+
from pulse.sandbox.backend import ContainerBackend, DockerBackend, HostBackend
|
|
25
|
+
from pulse.sandbox.errors import SandboxUnavailableError, SandboxUnsupportedPolicyError
|
|
26
|
+
from pulse.sandbox.filesystem import CoWFilesystem, CoWTransaction
|
|
27
|
+
from pulse.sandbox.lifecycle import LifecycleState, SandboxExecution
|
|
28
|
+
from pulse.sandbox.network import NetworkEnforcementLevel, NetworkMode, NetworkPolicy
|
|
29
|
+
from pulse.sandbox.path_validator import PathValidator
|
|
30
|
+
from pulse.sandbox.policy import ActionType, PolicyDecision, SandboxPolicy
|
|
31
|
+
from pulse.sandbox.process import ProcessResult
|
|
32
|
+
from pulse.sandbox.resources import ResourceLimits, ResourcePolicy
|
|
33
|
+
from pulse.sandbox.secrets import (
|
|
34
|
+
SecretEnforcementLevel,
|
|
35
|
+
SecretMode,
|
|
36
|
+
SecretPolicy,
|
|
37
|
+
SecretScrubber,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# SecurityWarning is not available in all Python builds; define a fallback.
|
|
41
|
+
try:
|
|
42
|
+
_SecurityWarning = SecurityWarning # type: ignore[name-defined]
|
|
43
|
+
except NameError:
|
|
44
|
+
|
|
45
|
+
class _SecurityWarning(UserWarning): # type: ignore[no-redef]
|
|
46
|
+
"""Fallback warning class for security-sensitive operations."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class SandboxSession:
|
|
51
|
+
"""Represents an active sandboxed agent session."""
|
|
52
|
+
|
|
53
|
+
session_id: str
|
|
54
|
+
workspace_root: Path
|
|
55
|
+
created_at: float = field(default_factory=time.time)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Sandbox:
|
|
59
|
+
"""Production-ready secure execution sandbox facade.
|
|
60
|
+
|
|
61
|
+
Security architecture:
|
|
62
|
+
- Fail-secure by default: no silent host fallback.
|
|
63
|
+
- TOCTOU-safe file reads via PathValidator.safe_read().
|
|
64
|
+
- All file writes routed through CoW transactions.
|
|
65
|
+
- Container execution with workspace mounted read-only.
|
|
66
|
+
- Audit logging with isolation level tracking.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
workspace_root: Path to the workspace directory.
|
|
70
|
+
policy: Policy engine for action authorization.
|
|
71
|
+
allowed_external_reads: Paths outside workspace allowed for reads.
|
|
72
|
+
secrets: List of secret strings to redact from output.
|
|
73
|
+
limits: Resource limits for process execution.
|
|
74
|
+
backend: Explicit container backend (overrides auto-detection).
|
|
75
|
+
audit_log_path: Path to the audit log file.
|
|
76
|
+
unsafe_host_execution: If True, allows fallback to HostBackend when
|
|
77
|
+
no container engine is available. Default False (fail-secure).
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
SandboxUnavailableError: If no container backend is available and
|
|
81
|
+
unsafe_host_execution is False (after initialize() is called).
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
workspace_root: Path,
|
|
87
|
+
policy: SandboxPolicy | None = None,
|
|
88
|
+
allowed_external_reads: list[Path] | None = None,
|
|
89
|
+
secrets: list[str] | None = None,
|
|
90
|
+
limits: ResourceLimits | ResourcePolicy | None = None,
|
|
91
|
+
network_policy: NetworkPolicy | None = None,
|
|
92
|
+
secret_policy: SecretPolicy | None = None,
|
|
93
|
+
backend: ContainerBackend | None = None,
|
|
94
|
+
audit_log_path: Path | None = None,
|
|
95
|
+
unsafe_host_execution: bool = False,
|
|
96
|
+
) -> None:
|
|
97
|
+
self.workspace_root = workspace_root.resolve()
|
|
98
|
+
self.policy = policy or SandboxPolicy()
|
|
99
|
+
self.network_policy = network_policy
|
|
100
|
+
self.secret_policy = secret_policy
|
|
101
|
+
self.validator = PathValidator(
|
|
102
|
+
self.workspace_root, allowed_external_reads=allowed_external_reads
|
|
103
|
+
)
|
|
104
|
+
self.scrubber = SecretScrubber(secrets=secrets)
|
|
105
|
+
self.limits = limits or ResourcePolicy()
|
|
106
|
+
self._limits_explicit = limits is not None
|
|
107
|
+
self._unsafe_host_execution = unsafe_host_execution
|
|
108
|
+
|
|
109
|
+
log_file = audit_log_path or (
|
|
110
|
+
self.workspace_root / ".agent" / "logs" / "audit.jsonl"
|
|
111
|
+
)
|
|
112
|
+
self.audit_logger = StructuredAuditLogger(log_file, scrubber=self.scrubber)
|
|
113
|
+
|
|
114
|
+
# Backend initialization: explicit backend or deferred to initialize()
|
|
115
|
+
self._backend_explicit = backend is not None
|
|
116
|
+
self.backend = backend
|
|
117
|
+
self._initialized = False
|
|
118
|
+
|
|
119
|
+
self.cow = CoWFilesystem(self.workspace_root)
|
|
120
|
+
|
|
121
|
+
async def initialize(self) -> None:
|
|
122
|
+
"""Select preferred container backend. Must be called before execute_command().
|
|
123
|
+
|
|
124
|
+
Security behavior:
|
|
125
|
+
1. If a remote endpoint is configured, try it first.
|
|
126
|
+
2. Fall back to local Docker/Podman when the remote is unavailable.
|
|
127
|
+
3. If unavailable AND unsafe_host_execution=True: fall back to HostBackend
|
|
128
|
+
with warnings and audit logging.
|
|
129
|
+
4. If unavailable AND unsafe_host_execution=False: raise SandboxUnavailableError.
|
|
130
|
+
"""
|
|
131
|
+
if self._backend_explicit and self.backend is not None:
|
|
132
|
+
# Respect the caller's backend selection while still performing
|
|
133
|
+
# mandatory startup reconciliation.
|
|
134
|
+
await self.backend.reconcile()
|
|
135
|
+
self._initialized = True
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# A configured remote is an explicit request to keep untrusted execution
|
|
139
|
+
# off the user's workstation. A local container engine is the secure
|
|
140
|
+
# fallback; host execution is never selected implicitly.
|
|
141
|
+
import logging
|
|
142
|
+
import os
|
|
143
|
+
|
|
144
|
+
from pulse.sandbox.backend.remote import RemoteSandboxBackend
|
|
145
|
+
|
|
146
|
+
remote_url = os.environ.get("PULSE_REMOTE_URL", "").strip()
|
|
147
|
+
remote_token = os.environ.get("PULSE_REMOTE_TOKEN", "").strip()
|
|
148
|
+
if remote_url or remote_token:
|
|
149
|
+
try:
|
|
150
|
+
remote_be = RemoteSandboxBackend(
|
|
151
|
+
endpoint_url=remote_url or None,
|
|
152
|
+
auth_token=remote_token or None,
|
|
153
|
+
)
|
|
154
|
+
if await remote_be.is_available():
|
|
155
|
+
self.backend = remote_be
|
|
156
|
+
self._initialized = True
|
|
157
|
+
await self.backend.reconcile()
|
|
158
|
+
self.audit_logger.record(
|
|
159
|
+
action="sandbox-init",
|
|
160
|
+
target=remote_be.name,
|
|
161
|
+
decision="allow",
|
|
162
|
+
isolation_level="remote_container",
|
|
163
|
+
detail="Secure remote backend initialized from environment configuration.",
|
|
164
|
+
)
|
|
165
|
+
return
|
|
166
|
+
logging.getLogger(__name__).warning(
|
|
167
|
+
"Configured remote sandbox is incomplete or unavailable; trying a local container engine."
|
|
168
|
+
)
|
|
169
|
+
except Exception as error: # noqa: BLE001
|
|
170
|
+
logging.getLogger(__name__).warning(
|
|
171
|
+
"Remote backend check failed; trying a local container engine: %s",
|
|
172
|
+
error,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
docker_be = DockerBackend()
|
|
177
|
+
if await docker_be.is_available():
|
|
178
|
+
self.backend = docker_be
|
|
179
|
+
self._initialized = True
|
|
180
|
+
await self.backend.reconcile()
|
|
181
|
+
self.audit_logger.record(
|
|
182
|
+
action="sandbox-init",
|
|
183
|
+
target=docker_be.name,
|
|
184
|
+
decision="allow",
|
|
185
|
+
isolation_level="container",
|
|
186
|
+
detail=f"Secure container backend '{docker_be.name}' initialized.",
|
|
187
|
+
)
|
|
188
|
+
return
|
|
189
|
+
except Exception as e: # noqa: BLE001
|
|
190
|
+
# Catch initialization/availability errors to avoid silent fallbacks
|
|
191
|
+
logging.getLogger(__name__).warning("Docker backend check failed: %s", e)
|
|
192
|
+
|
|
193
|
+
# No container engine available
|
|
194
|
+
if self._unsafe_host_execution:
|
|
195
|
+
# Explicit opt-in to unsafe host execution
|
|
196
|
+
warnings.warn(
|
|
197
|
+
"No container engine (Docker/Podman) found. Falling back to "
|
|
198
|
+
"HostBackend — untrusted code will execute directly on the host. "
|
|
199
|
+
"This is NOT safe for production use with untrusted AI-generated code.",
|
|
200
|
+
_SecurityWarning,
|
|
201
|
+
stacklevel=2,
|
|
202
|
+
)
|
|
203
|
+
self.backend = HostBackend()
|
|
204
|
+
self._initialized = True
|
|
205
|
+
await self.backend.reconcile()
|
|
206
|
+
self.audit_logger.record(
|
|
207
|
+
action="sandbox-init",
|
|
208
|
+
target="host",
|
|
209
|
+
decision="allow",
|
|
210
|
+
isolation_level="host_unsafe",
|
|
211
|
+
detail="WARNING: Falling back to unsafe host execution. No container isolation.",
|
|
212
|
+
)
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# Fail-secure: no container, no opt-in
|
|
216
|
+
self.audit_logger.record(
|
|
217
|
+
action="sandbox-init",
|
|
218
|
+
target="none",
|
|
219
|
+
decision="deny",
|
|
220
|
+
isolation_level="unavailable",
|
|
221
|
+
detail="No secure backend available and unsafe_host_execution=False.",
|
|
222
|
+
)
|
|
223
|
+
raise SandboxUnavailableError(
|
|
224
|
+
"No secure backend available (remote and Docker/Podman are missing or unreachable), "
|
|
225
|
+
"and unsafe host fallback is disabled."
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _get_isolation_level(self) -> str:
|
|
229
|
+
"""Determine the current isolation level for audit logging."""
|
|
230
|
+
if not self.backend:
|
|
231
|
+
return "unavailable"
|
|
232
|
+
if isinstance(self.backend, HostBackend) or getattr(
|
|
233
|
+
self.backend, "is_unsafe", False
|
|
234
|
+
):
|
|
235
|
+
return "host_unsafe"
|
|
236
|
+
return "container"
|
|
237
|
+
|
|
238
|
+
def read_file(self, relative_path: str) -> str:
|
|
239
|
+
"""Read a file securely within workspace boundaries after policy authorization.
|
|
240
|
+
|
|
241
|
+
Security guarantees:
|
|
242
|
+
- Policy authorization checked before any I/O.
|
|
243
|
+
- TOCTOU-safe read via PathValidator.safe_read() (atomic open + validate).
|
|
244
|
+
- File size enforced (MAX_FILE_SIZE prevents OOM).
|
|
245
|
+
- Secret scrubbing applied to returned content.
|
|
246
|
+
"""
|
|
247
|
+
decision = self.policy.evaluate(ActionType.READ, relative_path)
|
|
248
|
+
if decision == PolicyDecision.DENY:
|
|
249
|
+
self.audit_logger.record(
|
|
250
|
+
action="read",
|
|
251
|
+
target=relative_path,
|
|
252
|
+
decision="deny",
|
|
253
|
+
detail="Policy denied read access",
|
|
254
|
+
)
|
|
255
|
+
raise PermissionError(f"Policy denied read access to '{relative_path}'")
|
|
256
|
+
|
|
257
|
+
# TOCTOU-safe read: validates path, opens with O_NOFOLLOW, checks size, reads in chunks
|
|
258
|
+
raw_content = self.validator.safe_read(relative_path)
|
|
259
|
+
clean_content = self.scrubber.redact(raw_content)
|
|
260
|
+
|
|
261
|
+
self.audit_logger.record(
|
|
262
|
+
action="read",
|
|
263
|
+
target=relative_path,
|
|
264
|
+
decision=decision.value,
|
|
265
|
+
detail="Read file successfully",
|
|
266
|
+
)
|
|
267
|
+
return clean_content
|
|
268
|
+
|
|
269
|
+
async def execute_command(
|
|
270
|
+
self,
|
|
271
|
+
command: str | list[str],
|
|
272
|
+
cwd: Path | str | None = None,
|
|
273
|
+
env: dict[str, str] | None = None,
|
|
274
|
+
*,
|
|
275
|
+
execution_id: str | None = None,
|
|
276
|
+
) -> ProcessResult:
|
|
277
|
+
"""Execute a shell command inside the isolated container backend.
|
|
278
|
+
|
|
279
|
+
Security guarantees:
|
|
280
|
+
- Policy authorization checked before execution.
|
|
281
|
+
- If not initialized, auto-initializes (fail-secure).
|
|
282
|
+
- Isolation level tracked and audit-logged.
|
|
283
|
+
- Secret scrubbing applied to stdout/stderr.
|
|
284
|
+
"""
|
|
285
|
+
# Auto-initialize if not yet done (fail-secure)
|
|
286
|
+
if not self._initialized:
|
|
287
|
+
await self.initialize()
|
|
288
|
+
|
|
289
|
+
execution_id = execution_id or str(uuid.uuid4())
|
|
290
|
+
execution = SandboxExecution(execution_id, self.audit_logger)
|
|
291
|
+
execution.transition(LifecycleState.STARTING)
|
|
292
|
+
|
|
293
|
+
try:
|
|
294
|
+
cmd_str = command if isinstance(command, str) else " ".join(command)
|
|
295
|
+
network_allowed = self.policy.is_allowed(ActionType.NETWORK)
|
|
296
|
+
secrets_allowed = self.policy.is_allowed(ActionType.SECRETS)
|
|
297
|
+
|
|
298
|
+
# Resolve effective network policy
|
|
299
|
+
effective_network_policy = self.network_policy
|
|
300
|
+
if not network_allowed:
|
|
301
|
+
effective_network_policy = NetworkPolicy(mode=NetworkMode.DENY_ALL)
|
|
302
|
+
elif not effective_network_policy:
|
|
303
|
+
if getattr(self.backend, "is_unsafe", False):
|
|
304
|
+
effective_network_policy = NetworkPolicy(mode=NetworkMode.ALLOW_ALL)
|
|
305
|
+
else:
|
|
306
|
+
effective_network_policy = NetworkPolicy(mode=NetworkMode.DENY_ALL)
|
|
307
|
+
|
|
308
|
+
# Resolve effective secret policy
|
|
309
|
+
effective_secret_policy = self.secret_policy
|
|
310
|
+
if not secrets_allowed:
|
|
311
|
+
effective_secret_policy = SecretPolicy(mode=SecretMode.DENY_ALL)
|
|
312
|
+
elif not effective_secret_policy:
|
|
313
|
+
if getattr(self.backend, "is_unsafe", False):
|
|
314
|
+
effective_secret_policy = SecretPolicy(mode=SecretMode.ALLOW_ALL)
|
|
315
|
+
else:
|
|
316
|
+
effective_secret_policy = SecretPolicy(mode=SecretMode.DENY_ALL)
|
|
317
|
+
|
|
318
|
+
# Enforce fail-closed capability checks
|
|
319
|
+
net_capability = self.backend.get_network_enforcement_capability(
|
|
320
|
+
effective_network_policy
|
|
321
|
+
)
|
|
322
|
+
sec_capability = self.backend.get_secret_enforcement_capability(
|
|
323
|
+
effective_secret_policy
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
self.audit_logger.log_network(
|
|
327
|
+
destination="*",
|
|
328
|
+
port=None,
|
|
329
|
+
protocol="any",
|
|
330
|
+
decision="allow"
|
|
331
|
+
if effective_network_policy.mode != NetworkMode.DENY_ALL
|
|
332
|
+
else "deny",
|
|
333
|
+
backend=getattr(self.backend, "name", "unknown"),
|
|
334
|
+
enforcement_level=net_capability.value,
|
|
335
|
+
detail=f"Network policy mode applied: {effective_network_policy.mode.value}",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
self.audit_logger.record(
|
|
339
|
+
action="secrets",
|
|
340
|
+
target="environment",
|
|
341
|
+
decision="allow"
|
|
342
|
+
if effective_secret_policy.mode != SecretMode.DENY_ALL
|
|
343
|
+
else "deny",
|
|
344
|
+
isolation_level=sec_capability.value,
|
|
345
|
+
detail=f"Secret policy mode applied: {effective_secret_policy.mode.value}",
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if (
|
|
349
|
+
effective_network_policy.mode != NetworkMode.DENY_ALL
|
|
350
|
+
and not network_allowed
|
|
351
|
+
):
|
|
352
|
+
pass
|
|
353
|
+
elif net_capability != NetworkEnforcementLevel.STRONGLY_ENFORCED:
|
|
354
|
+
execution.transition(LifecycleState.FAILED)
|
|
355
|
+
self.audit_logger.record(
|
|
356
|
+
action="shell",
|
|
357
|
+
target=cmd_str,
|
|
358
|
+
decision="deny",
|
|
359
|
+
isolation_level=self._get_isolation_level(),
|
|
360
|
+
detail=f"Backend cannot strongly enforce network policy: {effective_network_policy.mode.value}",
|
|
361
|
+
)
|
|
362
|
+
raise SandboxUnsupportedPolicyError(
|
|
363
|
+
f"Backend '{getattr(self.backend, 'name', 'unknown')}' cannot strongly enforce "
|
|
364
|
+
f"network policy mode '{effective_network_policy.mode.value}'. "
|
|
365
|
+
"Execution rejected to prevent silent security downgrades."
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if (
|
|
369
|
+
effective_secret_policy.mode != SecretMode.DENY_ALL
|
|
370
|
+
and not secrets_allowed
|
|
371
|
+
):
|
|
372
|
+
pass
|
|
373
|
+
elif sec_capability != SecretEnforcementLevel.STRONGLY_ENFORCED:
|
|
374
|
+
execution.transition(LifecycleState.FAILED)
|
|
375
|
+
self.audit_logger.record(
|
|
376
|
+
action="shell",
|
|
377
|
+
target=cmd_str,
|
|
378
|
+
decision="deny",
|
|
379
|
+
isolation_level=self._get_isolation_level(),
|
|
380
|
+
detail=f"Backend cannot strongly enforce secret policy: {effective_secret_policy.mode.value}",
|
|
381
|
+
)
|
|
382
|
+
raise SandboxUnsupportedPolicyError(
|
|
383
|
+
f"Backend '{getattr(self.backend, 'name', 'unknown')}' cannot strongly enforce "
|
|
384
|
+
f"secret policy mode '{effective_secret_policy.mode.value}'. "
|
|
385
|
+
"Execution rejected to prevent silent credential leakage."
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
isolation_level = self._get_isolation_level()
|
|
389
|
+
|
|
390
|
+
decision = self.policy.evaluate(ActionType.SHELL, cmd_str)
|
|
391
|
+
if decision == PolicyDecision.DENY:
|
|
392
|
+
execution.transition(LifecycleState.FAILED)
|
|
393
|
+
self.audit_logger.record(
|
|
394
|
+
action="shell",
|
|
395
|
+
target=cmd_str,
|
|
396
|
+
decision="deny",
|
|
397
|
+
isolation_level=isolation_level,
|
|
398
|
+
detail="Policy denied shell command execution",
|
|
399
|
+
)
|
|
400
|
+
return ProcessResult(
|
|
401
|
+
command=cmd_str,
|
|
402
|
+
exit_code=-1,
|
|
403
|
+
stdout="",
|
|
404
|
+
stderr="Policy denied shell execution.",
|
|
405
|
+
duration_ms=0.0,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
start_time = time.monotonic()
|
|
409
|
+
target_dir = self.validator.validate_path(cwd or self.workspace_root)
|
|
410
|
+
|
|
411
|
+
execution.transition(LifecycleState.RUNNING)
|
|
412
|
+
try:
|
|
413
|
+
result = await self.backend.execute(
|
|
414
|
+
command=command,
|
|
415
|
+
workspace_root=self.workspace_root,
|
|
416
|
+
cwd=target_dir,
|
|
417
|
+
env=env,
|
|
418
|
+
# Let an explicitly configured backend ProcessManager
|
|
419
|
+
# supply its own default when the Sandbox caller did not
|
|
420
|
+
# choose a policy. This preserves backend-level timeout
|
|
421
|
+
# configuration and avoids silently overriding it with a
|
|
422
|
+
# new generic default on every call.
|
|
423
|
+
limits=self.limits if self._limits_explicit else None,
|
|
424
|
+
network_policy=effective_network_policy,
|
|
425
|
+
secret_policy=effective_secret_policy,
|
|
426
|
+
execution_id=execution_id,
|
|
427
|
+
)
|
|
428
|
+
if result.timed_out or result.exit_code != 0:
|
|
429
|
+
execution.transition(LifecycleState.FAILED)
|
|
430
|
+
else:
|
|
431
|
+
execution.transition(LifecycleState.COMPLETING)
|
|
432
|
+
except Exception:
|
|
433
|
+
execution.transition(LifecycleState.FAILED)
|
|
434
|
+
raise
|
|
435
|
+
|
|
436
|
+
clean_stdout = self.scrubber.redact(result.stdout)
|
|
437
|
+
clean_stderr = self.scrubber.redact(result.stderr)
|
|
438
|
+
duration_ms = (time.monotonic() - start_time) * 1000.0
|
|
439
|
+
|
|
440
|
+
self.audit_logger.record(
|
|
441
|
+
action="shell",
|
|
442
|
+
target=cmd_str,
|
|
443
|
+
decision=decision.value,
|
|
444
|
+
exit_code=result.exit_code,
|
|
445
|
+
duration_ms=duration_ms,
|
|
446
|
+
container_id=getattr(self.backend, "name", "unknown"),
|
|
447
|
+
isolation_level=isolation_level,
|
|
448
|
+
detail=f"Executed command via {self.backend.name} backend",
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Extract overlay changes to the active workspace via CoW
|
|
452
|
+
if result.overlay_path and result.overlay_path.exists():
|
|
453
|
+
import shutil
|
|
454
|
+
|
|
455
|
+
from pulse.sandbox.errors import SandboxConcurrentModificationError
|
|
456
|
+
|
|
457
|
+
try:
|
|
458
|
+
tx = None
|
|
459
|
+
for item in result.overlay_path.rglob("*"):
|
|
460
|
+
if item.is_file():
|
|
461
|
+
try:
|
|
462
|
+
rel_path = item.relative_to(result.overlay_path)
|
|
463
|
+
if tx is None:
|
|
464
|
+
tx = self.create_transaction()
|
|
465
|
+
content = item.read_bytes()
|
|
466
|
+
self.stage_write(
|
|
467
|
+
tx,
|
|
468
|
+
str(rel_path),
|
|
469
|
+
content.decode("utf-8", errors="replace"),
|
|
470
|
+
)
|
|
471
|
+
except ValueError:
|
|
472
|
+
pass
|
|
473
|
+
if tx and tx.staged_changes:
|
|
474
|
+
try:
|
|
475
|
+
self.commit_transaction(tx)
|
|
476
|
+
except SandboxConcurrentModificationError as e:
|
|
477
|
+
self.logger.record(
|
|
478
|
+
action="commit_overlay",
|
|
479
|
+
target=str(e.path),
|
|
480
|
+
decision="deny",
|
|
481
|
+
reason="concurrent_modification",
|
|
482
|
+
)
|
|
483
|
+
self.discard_transaction(tx)
|
|
484
|
+
finally:
|
|
485
|
+
shutil.rmtree(result.overlay_path, ignore_errors=True)
|
|
486
|
+
|
|
487
|
+
return ProcessResult(
|
|
488
|
+
command=cmd_str,
|
|
489
|
+
exit_code=result.exit_code,
|
|
490
|
+
stdout=clean_stdout,
|
|
491
|
+
stderr=clean_stderr,
|
|
492
|
+
duration_ms=duration_ms,
|
|
493
|
+
timed_out=result.timed_out,
|
|
494
|
+
truncated=result.truncated,
|
|
495
|
+
pid=result.pid,
|
|
496
|
+
overlay_path=None, # Consumed and cleaned up
|
|
497
|
+
metrics=result.metrics,
|
|
498
|
+
termination_reason=result.termination_reason,
|
|
499
|
+
)
|
|
500
|
+
finally:
|
|
501
|
+
# Ensure cleanup runs regardless of execution outcome
|
|
502
|
+
if execution.state != LifecycleState.FINALIZED:
|
|
503
|
+
execution.transition(LifecycleState.CLEANING)
|
|
504
|
+
try:
|
|
505
|
+
if self._initialized and self.backend:
|
|
506
|
+
# Clean up engine resources, which naturally handles execution orphans
|
|
507
|
+
|
|
508
|
+
# We use a task or direct await to clean up backend resources
|
|
509
|
+
# Since we're in an async finally block, await is valid.
|
|
510
|
+
await self.backend.cleanup()
|
|
511
|
+
execution.transition(LifecycleState.FINALIZED)
|
|
512
|
+
except Exception as cleanup_err: # noqa: BLE001
|
|
513
|
+
execution.transition(LifecycleState.RECOVERY_REQUIRED)
|
|
514
|
+
self.audit_logger.record(
|
|
515
|
+
action="sandbox-cleanup",
|
|
516
|
+
target="engine",
|
|
517
|
+
decision="deny",
|
|
518
|
+
detail=f"CRITICAL: Cleanup failed, resources may be orphaned. Error: {cleanup_err}",
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# -----------------------------------------------------------------------
|
|
522
|
+
# CoW Transaction API
|
|
523
|
+
# -----------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
def create_transaction(self) -> CoWTransaction:
|
|
526
|
+
return self.cow.create_transaction()
|
|
527
|
+
|
|
528
|
+
def stage_write(self, tx: CoWTransaction, relative_path: str, content: str) -> Path:
|
|
529
|
+
decision = self.policy.evaluate(ActionType.WRITE, relative_path)
|
|
530
|
+
if decision == PolicyDecision.DENY:
|
|
531
|
+
self.audit_logger.record(
|
|
532
|
+
action="write",
|
|
533
|
+
target=relative_path,
|
|
534
|
+
decision="deny",
|
|
535
|
+
detail="Policy denied write access",
|
|
536
|
+
)
|
|
537
|
+
raise PermissionError(f"Policy denied write access to '{relative_path}'")
|
|
538
|
+
|
|
539
|
+
if self.scrubber.contains_explicit_secret(content):
|
|
540
|
+
from pulse.sandbox.errors import SandboxSecurityError
|
|
541
|
+
|
|
542
|
+
self.audit_logger.record(
|
|
543
|
+
action="write",
|
|
544
|
+
target=relative_path,
|
|
545
|
+
decision="deny",
|
|
546
|
+
detail="Commit rejected: explicitly authorized secret found in staged file.",
|
|
547
|
+
)
|
|
548
|
+
raise SandboxSecurityError(
|
|
549
|
+
"Commit rejected: explicitly authorized secret found in staged file.",
|
|
550
|
+
operation="stage_write",
|
|
551
|
+
path=relative_path,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
path = self.cow.stage_write(tx, relative_path, content)
|
|
555
|
+
self.audit_logger.record(
|
|
556
|
+
action="write-staged", target=relative_path, decision=decision.value
|
|
557
|
+
)
|
|
558
|
+
return path
|
|
559
|
+
|
|
560
|
+
def stage_delete(self, tx: CoWTransaction, relative_path: str) -> None:
|
|
561
|
+
decision = self.policy.evaluate(ActionType.DELETE, relative_path)
|
|
562
|
+
if decision == PolicyDecision.DENY:
|
|
563
|
+
self.audit_logger.record(
|
|
564
|
+
action="delete",
|
|
565
|
+
target=relative_path,
|
|
566
|
+
decision="deny",
|
|
567
|
+
detail="Policy denied delete access",
|
|
568
|
+
)
|
|
569
|
+
raise PermissionError(f"Policy denied delete access to '{relative_path}'")
|
|
570
|
+
|
|
571
|
+
self.cow.stage_delete(tx, relative_path)
|
|
572
|
+
self.audit_logger.record(
|
|
573
|
+
action="delete-staged", target=relative_path, decision=decision.value
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
def preview_changes(self, tx: CoWTransaction) -> str:
|
|
577
|
+
return self.cow.preview_changes(tx)
|
|
578
|
+
|
|
579
|
+
def commit_transaction(self, tx: CoWTransaction) -> list[str]:
|
|
580
|
+
modified = self.cow.commit_transaction(tx)
|
|
581
|
+
for f in modified:
|
|
582
|
+
self.audit_logger.record(
|
|
583
|
+
action="commit", target=f, decision="allow", detail="Committed CoW edit"
|
|
584
|
+
)
|
|
585
|
+
return modified
|
|
586
|
+
|
|
587
|
+
def discard_transaction(self, tx: CoWTransaction) -> None:
|
|
588
|
+
self.cow.discard_transaction(tx)
|
|
589
|
+
self.audit_logger.record(
|
|
590
|
+
action="discard",
|
|
591
|
+
target=tx.transaction_id,
|
|
592
|
+
decision="allow",
|
|
593
|
+
detail="Discarded CoW transaction",
|
|
594
|
+
)
|