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,498 @@
|
|
|
1
|
+
"""Rootless Docker and Podman container execution backend.
|
|
2
|
+
|
|
3
|
+
Enforces capability dropping (--cap-drop=ALL), read-only root filesystems (--read-only),
|
|
4
|
+
tmpfs /tmp mounts, non-root user execution, and network isolation (--network none).
|
|
5
|
+
|
|
6
|
+
Security hardening (CoW bypass fix):
|
|
7
|
+
- Workspace mounted as READ-ONLY (:ro) inside the container.
|
|
8
|
+
- Writable overlay at /workspace-overlay for container writes.
|
|
9
|
+
- After execution, overlay changes are extracted and returned for CoW staging.
|
|
10
|
+
- --memory-swap equal to --memory prevents swap exhaustion.
|
|
11
|
+
- --user 65534:65534 (nobody) prevents container root execution.
|
|
12
|
+
- --cpu-quota/--cpu-period for CPU limiting.
|
|
13
|
+
|
|
14
|
+
Security hardening (secret leakage fix):
|
|
15
|
+
- Environment variables are injected via --env-file instead of --env CLI args.
|
|
16
|
+
- This prevents secrets from leaking through the host process table (ps aux).
|
|
17
|
+
- The env file is created with restrictive permissions (0o600 on POSIX).
|
|
18
|
+
- The env file is deleted in a finally block after docker run starts.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import shutil
|
|
27
|
+
import stat
|
|
28
|
+
import tempfile
|
|
29
|
+
import typing
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from pulse.sandbox.network import NetworkEnforcementLevel, NetworkMode, NetworkPolicy
|
|
34
|
+
from pulse.sandbox.process import ProcessEnforcementLevel, ProcessManager, ProcessResult
|
|
35
|
+
from pulse.sandbox.resources import ResourceLimits, ResourcePolicy
|
|
36
|
+
from pulse.sandbox.secrets import (
|
|
37
|
+
SecretEnforcementLevel,
|
|
38
|
+
SecretMode,
|
|
39
|
+
SecretPolicy,
|
|
40
|
+
build_isolated_environment,
|
|
41
|
+
)
|
|
42
|
+
from pulse.subprocesses import isolated_process_kwargs, terminate_process
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DockerBackend:
|
|
46
|
+
"""Production-grade rootless container execution backend.
|
|
47
|
+
|
|
48
|
+
Security architecture:
|
|
49
|
+
The workspace is mounted read-only (:ro) to prevent any container
|
|
50
|
+
process from modifying workspace files directly, closing the CoW
|
|
51
|
+
bypass vulnerability. A writable tmpfs overlay at /workspace-overlay
|
|
52
|
+
is provided for container processes that need to write files. After
|
|
53
|
+
execution, the caller can extract changes from the overlay and route
|
|
54
|
+
them through the CoW transaction layer.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def __init__(
|
|
58
|
+
self,
|
|
59
|
+
image: str = "python:3.11-slim",
|
|
60
|
+
container_engine: str | None = None,
|
|
61
|
+
process_manager: ProcessManager | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.image = image
|
|
64
|
+
self._engine = container_engine
|
|
65
|
+
self.process_manager = process_manager or ProcessManager()
|
|
66
|
+
|
|
67
|
+
async def reconcile(self) -> None:
|
|
68
|
+
"""Startup reconciliation to aggressively reap orphaned Pulse containers.
|
|
69
|
+
|
|
70
|
+
Queries the engine for containers labeled with `pulse.sandbox.managed=true`
|
|
71
|
+
and force-removes them. This guarantees no leftover processes consume resources.
|
|
72
|
+
"""
|
|
73
|
+
if not await self.is_available():
|
|
74
|
+
return
|
|
75
|
+
engine = self._engine
|
|
76
|
+
if not engine:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
proc = await asyncio.create_subprocess_exec(
|
|
80
|
+
engine,
|
|
81
|
+
"ps",
|
|
82
|
+
"-a",
|
|
83
|
+
"-q",
|
|
84
|
+
"--filter",
|
|
85
|
+
"label=pulse.sandbox.managed=true",
|
|
86
|
+
stdout=asyncio.subprocess.PIPE,
|
|
87
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
88
|
+
**isolated_process_kwargs(),
|
|
89
|
+
)
|
|
90
|
+
stdout, _ = await proc.communicate()
|
|
91
|
+
cids = stdout.decode().strip().split()
|
|
92
|
+
|
|
93
|
+
if cids:
|
|
94
|
+
rm_proc = await asyncio.create_subprocess_exec(
|
|
95
|
+
engine,
|
|
96
|
+
"rm",
|
|
97
|
+
"-f",
|
|
98
|
+
*cids,
|
|
99
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
100
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
101
|
+
**isolated_process_kwargs(),
|
|
102
|
+
)
|
|
103
|
+
await rm_proc.wait()
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def name(self) -> str:
|
|
107
|
+
return self._engine or "docker"
|
|
108
|
+
|
|
109
|
+
async def is_available(self) -> bool:
|
|
110
|
+
"""Check that a Docker or Podman CLI and its daemon are operational."""
|
|
111
|
+
if self._engine:
|
|
112
|
+
return await self._engine_operational(self._engine)
|
|
113
|
+
for candidate in ("docker", "podman"):
|
|
114
|
+
if await self._engine_operational(candidate):
|
|
115
|
+
self._engine = candidate
|
|
116
|
+
return True
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
async def _engine_operational(engine: str) -> bool:
|
|
121
|
+
if not shutil.which(engine):
|
|
122
|
+
return False
|
|
123
|
+
proc: asyncio.subprocess.Process | None = None
|
|
124
|
+
try:
|
|
125
|
+
proc = await asyncio.create_subprocess_exec(
|
|
126
|
+
engine,
|
|
127
|
+
"info",
|
|
128
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
129
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
130
|
+
**isolated_process_kwargs(),
|
|
131
|
+
)
|
|
132
|
+
await asyncio.wait_for(proc.wait(), timeout=10.0)
|
|
133
|
+
return proc.returncode == 0
|
|
134
|
+
except (OSError, TimeoutError):
|
|
135
|
+
await terminate_process(proc)
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
def get_network_enforcement_capability(self, policy: NetworkPolicy) -> NetworkEnforcementLevel:
|
|
139
|
+
"""Determine what level of security this backend can enforce for the policy.
|
|
140
|
+
|
|
141
|
+
Docker without root/iptables capabilities can only strictly enforce
|
|
142
|
+
DENY_ALL and LOCALHOST_ONLY (container loopback) via --network none.
|
|
143
|
+
ALLOWLIST and PROXY cannot be strictly enforced against raw sockets.
|
|
144
|
+
"""
|
|
145
|
+
if not policy or policy.mode == NetworkMode.ALLOW_ALL:
|
|
146
|
+
return NetworkEnforcementLevel.STRONGLY_ENFORCED
|
|
147
|
+
|
|
148
|
+
if policy.mode in (NetworkMode.DENY_ALL, NetworkMode.LOCALHOST_ONLY):
|
|
149
|
+
return NetworkEnforcementLevel.STRONGLY_ENFORCED
|
|
150
|
+
|
|
151
|
+
return NetworkEnforcementLevel.UNSUPPORTED
|
|
152
|
+
|
|
153
|
+
def get_secret_enforcement_capability(self, policy: SecretPolicy) -> SecretEnforcementLevel:
|
|
154
|
+
"""Determine if this backend can strongly enforce the requested secret isolation policy.
|
|
155
|
+
|
|
156
|
+
Docker without root/mounts naturally isolates the host filesystem and environment,
|
|
157
|
+
making it trivial to enforce DENY_ALL and ALLOW_EXPLICIT cleanly.
|
|
158
|
+
"""
|
|
159
|
+
if not policy or policy.mode == SecretMode.ALLOW_ALL:
|
|
160
|
+
return SecretEnforcementLevel.STRONGLY_ENFORCED
|
|
161
|
+
|
|
162
|
+
if policy.mode in (SecretMode.DENY_ALL, SecretMode.ALLOW_EXPLICIT):
|
|
163
|
+
return SecretEnforcementLevel.STRONGLY_ENFORCED
|
|
164
|
+
|
|
165
|
+
return SecretEnforcementLevel.UNSUPPORTED
|
|
166
|
+
|
|
167
|
+
def get_process_containment_capability(self) -> ProcessEnforcementLevel:
|
|
168
|
+
"""Determine if this backend provides strong process containment.
|
|
169
|
+
|
|
170
|
+
Docker leverages Linux namespaces and cgroups to strongly contain
|
|
171
|
+
processes, regardless of daemonization/setsid behavior.
|
|
172
|
+
"""
|
|
173
|
+
return ProcessEnforcementLevel.STRONGLY_ENFORCED
|
|
174
|
+
|
|
175
|
+
@staticmethod
|
|
176
|
+
def _write_env_file(env: dict[str, str], path: Path) -> None:
|
|
177
|
+
"""Write environment variables to a file for --env-file injection.
|
|
178
|
+
|
|
179
|
+
Security architecture:
|
|
180
|
+
- File is created with restrictive permissions (0o600 on POSIX)
|
|
181
|
+
to prevent other host users from reading secrets.
|
|
182
|
+
- Values are written as KEY=VALUE, one per line.
|
|
183
|
+
- The caller is responsible for deleting the file after use.
|
|
184
|
+
"""
|
|
185
|
+
# Open with restrictive permissions on POSIX; on Windows os.open
|
|
186
|
+
# ignores the mode but the file is user-owned by default.
|
|
187
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
188
|
+
fd = os.open(str(path), flags, stat.S_IRUSR | stat.S_IWUSR)
|
|
189
|
+
try:
|
|
190
|
+
lines = [f"{k}={v}\n" for k, v in env.items()]
|
|
191
|
+
os.write(fd, "".join(lines).encode("utf-8"))
|
|
192
|
+
finally:
|
|
193
|
+
os.close(fd)
|
|
194
|
+
|
|
195
|
+
def build_docker_cmd(
|
|
196
|
+
self,
|
|
197
|
+
command: str | list[str],
|
|
198
|
+
workspace_root: Path,
|
|
199
|
+
cwd: Path | None = None,
|
|
200
|
+
env: dict[str, str] | None = None,
|
|
201
|
+
limits: ResourceLimits | ResourcePolicy | None = None,
|
|
202
|
+
network_policy: NetworkPolicy | None = None,
|
|
203
|
+
secret_policy: SecretPolicy | None = None,
|
|
204
|
+
cidfile: Path | None = None,
|
|
205
|
+
env_file_path: Path | None = None,
|
|
206
|
+
execution_id: str | None = None,
|
|
207
|
+
overlay_export_path: Path | None = None,
|
|
208
|
+
export_wrapper_path: Path | None = None,
|
|
209
|
+
) -> list[str]:
|
|
210
|
+
"""Construct the exact `docker run` or `podman run` CLI arguments.
|
|
211
|
+
|
|
212
|
+
Security architecture:
|
|
213
|
+
1. Workspace is mounted READ-ONLY (:ro) — prevents CoW bypass.
|
|
214
|
+
2. /workspace-overlay is a writable tmpfs for container writes.
|
|
215
|
+
3. Capabilities are dropped (--cap-drop=ALL).
|
|
216
|
+
4. Root filesystem is read-only (--read-only).
|
|
217
|
+
5. No new privileges (--security-opt=no-new-privileges:true).
|
|
218
|
+
6. User is nobody (65534:65534) — no root in container.
|
|
219
|
+
7. Memory-swap equals memory — prevents swap exhaustion.
|
|
220
|
+
8. Environment injected via --env-file — prevents secret leakage
|
|
221
|
+
through the host process table (ps aux).
|
|
222
|
+
"""
|
|
223
|
+
engine = self._engine or "docker"
|
|
224
|
+
workspace_abs = str(workspace_root.resolve())
|
|
225
|
+
|
|
226
|
+
# Determine relative container working directory
|
|
227
|
+
rel_workdir = "/workspace"
|
|
228
|
+
if cwd:
|
|
229
|
+
resolved_cwd = cwd.resolve()
|
|
230
|
+
try:
|
|
231
|
+
rel_parts = resolved_cwd.relative_to(workspace_root.resolve())
|
|
232
|
+
rel_workdir = f"/workspace/{rel_parts.as_posix()}".rstrip("/")
|
|
233
|
+
except ValueError:
|
|
234
|
+
rel_workdir = "/workspace"
|
|
235
|
+
|
|
236
|
+
cmd_args: list[str] = [
|
|
237
|
+
engine,
|
|
238
|
+
"run",
|
|
239
|
+
]
|
|
240
|
+
if not cidfile:
|
|
241
|
+
cmd_args.append("--rm")
|
|
242
|
+
|
|
243
|
+
cmd_args.extend([
|
|
244
|
+
"-i",
|
|
245
|
+
"--read-only",
|
|
246
|
+
"--cap-drop=ALL",
|
|
247
|
+
"--security-opt=no-new-privileges:true",
|
|
248
|
+
# Non-root user inside container (nobody)
|
|
249
|
+
"--user", "65534:65534",
|
|
250
|
+
# /tmp as writable tmpfs (noexec prevents execution from /tmp)
|
|
251
|
+
"--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
|
|
252
|
+
# CRITICAL FIX: Workspace mounted READ-ONLY to prevent CoW bypass.
|
|
253
|
+
# Container processes CANNOT modify workspace files directly.
|
|
254
|
+
"-v", f"{workspace_abs}:/workspace:ro",
|
|
255
|
+
# Writable overlay for any container writes — extracted after execution
|
|
256
|
+
"--tmpfs", "/workspace-overlay:rw,size=256m",
|
|
257
|
+
# Working directory
|
|
258
|
+
"-w", rel_workdir,
|
|
259
|
+
])
|
|
260
|
+
|
|
261
|
+
if cidfile:
|
|
262
|
+
cmd_args.insert(2, f"--cidfile={cidfile.as_posix()}")
|
|
263
|
+
|
|
264
|
+
if execution_id:
|
|
265
|
+
cmd_args.extend(["--label", f"pulse.sandbox.execution_id={execution_id}"])
|
|
266
|
+
cmd_args.extend(["--label", "pulse.sandbox.managed=true"])
|
|
267
|
+
|
|
268
|
+
if overlay_export_path and export_wrapper_path:
|
|
269
|
+
cmd_args.extend(
|
|
270
|
+
[
|
|
271
|
+
"-v",
|
|
272
|
+
f"{overlay_export_path.resolve()}:/workspace-export:rw",
|
|
273
|
+
"-v",
|
|
274
|
+
f"{export_wrapper_path.resolve()}:/pulse-export-wrapper.sh:ro",
|
|
275
|
+
]
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Network isolation flag
|
|
279
|
+
if not network_policy or network_policy.mode in (NetworkMode.DENY_ALL, NetworkMode.LOCALHOST_ONLY):
|
|
280
|
+
cmd_args.extend(["--network", "none"])
|
|
281
|
+
|
|
282
|
+
# Resource limits flags
|
|
283
|
+
if limits:
|
|
284
|
+
policy = limits if isinstance(limits, ResourcePolicy) else limits.to_policy()
|
|
285
|
+
if policy.memory_bytes:
|
|
286
|
+
cmd_args.extend(["--memory", f"{policy.memory_bytes}b", "--memory-swap", f"{policy.memory_bytes}b"])
|
|
287
|
+
if policy.max_processes:
|
|
288
|
+
cmd_args.extend(["--pids-limit", f"{policy.max_processes}"])
|
|
289
|
+
if policy.cpu_quota_percent:
|
|
290
|
+
cpus = max(0.01, policy.cpu_quota_percent / 100.0)
|
|
291
|
+
cmd_args.extend(["--cpus", f"{cpus}"])
|
|
292
|
+
if policy.disk_bytes:
|
|
293
|
+
# --storage-opt size= relies on overlayfs backing (xfs/btrfs).
|
|
294
|
+
# Podman supports it natively in most overlay setups. Docker supports it with xfs pquota.
|
|
295
|
+
# If unsupported by the daemon, execution will gracefully fail closed during startup.
|
|
296
|
+
cmd_args.extend(["--storage-opt", f"size={policy.disk_bytes}"])
|
|
297
|
+
# File descriptor limit (Finding #4)
|
|
298
|
+
if policy.max_open_files:
|
|
299
|
+
cmd_args.extend(["--ulimit", f"nofile={policy.max_open_files}:{policy.max_open_files}"])
|
|
300
|
+
# CPU time hard-kill limit
|
|
301
|
+
if policy.cpu_time_seconds is not None:
|
|
302
|
+
cpu_secs = max(1, int(policy.cpu_time_seconds))
|
|
303
|
+
cmd_args.extend(["--ulimit", f"cpu={cpu_secs}:{cpu_secs}"])
|
|
304
|
+
|
|
305
|
+
# Environment variables isolation — injected via --env-file to prevent
|
|
306
|
+
# secrets from leaking through the host process table (ps aux).
|
|
307
|
+
safe_env = build_isolated_environment(secret_policy, extra_env=env)
|
|
308
|
+
if env_file_path:
|
|
309
|
+
self._write_env_file(safe_env, env_file_path)
|
|
310
|
+
cmd_args.extend(["--env-file", str(env_file_path)])
|
|
311
|
+
else:
|
|
312
|
+
# Fallback for unit tests calling build_docker_cmd() directly
|
|
313
|
+
# without an env_file_path — uses non-secret minimal env only.
|
|
314
|
+
for k, v in safe_env.items():
|
|
315
|
+
cmd_args.extend(["--env", f"{k}={v}"])
|
|
316
|
+
|
|
317
|
+
# Image and target command
|
|
318
|
+
cmd_args.append(self.image)
|
|
319
|
+
if overlay_export_path and export_wrapper_path:
|
|
320
|
+
cmd_args.extend(["sh", "/pulse-export-wrapper.sh"])
|
|
321
|
+
if isinstance(command, str):
|
|
322
|
+
cmd_args.extend(["sh", "-c", command])
|
|
323
|
+
else:
|
|
324
|
+
cmd_args.extend(command)
|
|
325
|
+
elif isinstance(command, str):
|
|
326
|
+
cmd_args.extend(["sh", "-c", command])
|
|
327
|
+
else:
|
|
328
|
+
cmd_args.extend(command)
|
|
329
|
+
|
|
330
|
+
return cmd_args
|
|
331
|
+
|
|
332
|
+
async def execute(
|
|
333
|
+
self,
|
|
334
|
+
command: str | list[str],
|
|
335
|
+
workspace_root: Path,
|
|
336
|
+
cwd: Path | None = None,
|
|
337
|
+
env: dict[str, str] | None = None,
|
|
338
|
+
limits: ResourceLimits | ResourcePolicy | None = None,
|
|
339
|
+
network_policy: NetworkPolicy | None = None,
|
|
340
|
+
secret_policy: SecretPolicy | None = None,
|
|
341
|
+
execution_id: str | None = None,
|
|
342
|
+
output_callback: typing.Callable[[str, bytes], typing.Awaitable[None]] | None = None,
|
|
343
|
+
) -> ProcessResult:
|
|
344
|
+
if not await self.is_available():
|
|
345
|
+
return ProcessResult(
|
|
346
|
+
command=str(command),
|
|
347
|
+
exit_code=-1,
|
|
348
|
+
stdout="",
|
|
349
|
+
stderr=f"Container engine '{self.name}' is not installed or available on this system.",
|
|
350
|
+
duration_ms=0.0,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
engine = self._engine or "docker"
|
|
354
|
+
# A private randomized directory prevents symlink/pre-creation attacks
|
|
355
|
+
# against predictable cid, environment, and wrapper paths.
|
|
356
|
+
operation_dir = Path(tempfile.mkdtemp(prefix="pulse-sandbox-"))
|
|
357
|
+
tx_id = execution_id or str(uuid.uuid4())
|
|
358
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", tx_id):
|
|
359
|
+
shutil.rmtree(operation_dir, ignore_errors=True)
|
|
360
|
+
raise ValueError("execution_id must be a safe 1-128 character identifier")
|
|
361
|
+
cidfile = operation_dir / "container.cid"
|
|
362
|
+
overlay_extract_dir = operation_dir / "overlay"
|
|
363
|
+
env_file_path = operation_dir / "container.env"
|
|
364
|
+
export_wrapper_path = operation_dir / "export.sh"
|
|
365
|
+
overlay_extract_dir.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
try:
|
|
367
|
+
overlay_extract_dir.chmod(0o777)
|
|
368
|
+
except OSError:
|
|
369
|
+
pass
|
|
370
|
+
self._write_export_wrapper(export_wrapper_path)
|
|
371
|
+
|
|
372
|
+
docker_cmd = self.build_docker_cmd(
|
|
373
|
+
command=command,
|
|
374
|
+
workspace_root=workspace_root,
|
|
375
|
+
cwd=cwd,
|
|
376
|
+
env=env,
|
|
377
|
+
limits=limits,
|
|
378
|
+
network_policy=network_policy,
|
|
379
|
+
secret_policy=secret_policy,
|
|
380
|
+
cidfile=cidfile,
|
|
381
|
+
env_file_path=env_file_path,
|
|
382
|
+
execution_id=tx_id,
|
|
383
|
+
overlay_export_path=overlay_extract_dir,
|
|
384
|
+
export_wrapper_path=export_wrapper_path,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
completed = False
|
|
388
|
+
try:
|
|
389
|
+
# The container engine enforces resource limits inside the container.
|
|
390
|
+
# Applying POSIX rlimits to the Docker/Podman client itself can prevent
|
|
391
|
+
# the client from starting and does not strengthen containment.
|
|
392
|
+
result = await self.process_manager.execute(
|
|
393
|
+
docker_cmd,
|
|
394
|
+
cwd=workspace_root,
|
|
395
|
+
limits=limits,
|
|
396
|
+
output_callback=output_callback,
|
|
397
|
+
apply_native_limits=False,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# The wrapper exports the tmpfs overlay before the container exits.
|
|
401
|
+
# Keep the cidfile only for deterministic container cleanup.
|
|
402
|
+
if cidfile.exists():
|
|
403
|
+
cid = cidfile.read_text(encoding="utf-8").strip()
|
|
404
|
+
if cid:
|
|
405
|
+
rm_cmd = [engine, "rm", "-f", cid]
|
|
406
|
+
rm_proc = await asyncio.create_subprocess_exec(
|
|
407
|
+
*rm_cmd,
|
|
408
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
409
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
410
|
+
**isolated_process_kwargs(),
|
|
411
|
+
)
|
|
412
|
+
await rm_proc.wait()
|
|
413
|
+
|
|
414
|
+
self._validate_exported_overlay(overlay_extract_dir)
|
|
415
|
+
export_marker = overlay_extract_dir / ".pulse-export-complete"
|
|
416
|
+
export_complete = export_marker.is_file()
|
|
417
|
+
export_marker.unlink(missing_ok=True)
|
|
418
|
+
if not export_complete and result.exit_code == 0:
|
|
419
|
+
raise RuntimeError(
|
|
420
|
+
"Container command succeeded but its tmpfs overlay was not exported."
|
|
421
|
+
)
|
|
422
|
+
if not export_complete:
|
|
423
|
+
shutil.rmtree(overlay_extract_dir, ignore_errors=True)
|
|
424
|
+
|
|
425
|
+
# Create a new result with the overlay path
|
|
426
|
+
completed = True
|
|
427
|
+
return ProcessResult(
|
|
428
|
+
command=result.command,
|
|
429
|
+
exit_code=result.exit_code,
|
|
430
|
+
stdout=result.stdout,
|
|
431
|
+
stderr=result.stderr,
|
|
432
|
+
duration_ms=result.duration_ms,
|
|
433
|
+
timed_out=result.timed_out,
|
|
434
|
+
truncated=result.truncated,
|
|
435
|
+
pid=result.pid,
|
|
436
|
+
overlay_path=overlay_extract_dir if export_complete else None,
|
|
437
|
+
metrics=result.metrics,
|
|
438
|
+
termination_reason=result.termination_reason,
|
|
439
|
+
)
|
|
440
|
+
finally:
|
|
441
|
+
# Always clean temporary files (Finding #3 — cleanup hardening)
|
|
442
|
+
if cidfile.exists():
|
|
443
|
+
try:
|
|
444
|
+
cidfile.unlink(missing_ok=True)
|
|
445
|
+
except OSError:
|
|
446
|
+
pass
|
|
447
|
+
if env_file_path.exists():
|
|
448
|
+
try:
|
|
449
|
+
env_file_path.unlink(missing_ok=True)
|
|
450
|
+
except OSError:
|
|
451
|
+
pass
|
|
452
|
+
export_wrapper_path.unlink(missing_ok=True)
|
|
453
|
+
if not completed:
|
|
454
|
+
shutil.rmtree(operation_dir, ignore_errors=True)
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def _write_export_wrapper(path: Path) -> None:
|
|
458
|
+
script = (
|
|
459
|
+
b'#!/bin/sh\n'
|
|
460
|
+
b'"$@"\n'
|
|
461
|
+
b'status=$?\n'
|
|
462
|
+
# Do not use ``cp -a`` here. GNU cp tries to preserve metadata on
|
|
463
|
+
# the bind-mount root itself, which a non-root container user
|
|
464
|
+
# cannot chmod/chown, and turns every successful command into 125.
|
|
465
|
+
b'cp -R /workspace-overlay/. /workspace-export/ || '
|
|
466
|
+
b'{ echo "Pulse overlay copy failed" >&2; exit 74; }\n'
|
|
467
|
+
b"find /workspace-export -mindepth 1 -exec chmod a+rwX {} \\; || "
|
|
468
|
+
b'{ echo "Pulse overlay chmod failed" >&2; exit 74; }\n'
|
|
469
|
+
b': > /workspace-export/.pulse-export-complete || '
|
|
470
|
+
b'{ echo "Pulse export marker failed" >&2; exit 74; }\n'
|
|
471
|
+
b'exit "$status"\n'
|
|
472
|
+
)
|
|
473
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o755)
|
|
474
|
+
try:
|
|
475
|
+
os.write(fd, script)
|
|
476
|
+
finally:
|
|
477
|
+
os.close(fd)
|
|
478
|
+
try:
|
|
479
|
+
path.chmod(0o755)
|
|
480
|
+
except OSError:
|
|
481
|
+
pass
|
|
482
|
+
|
|
483
|
+
@staticmethod
|
|
484
|
+
def _validate_exported_overlay(dest: Path) -> None:
|
|
485
|
+
resolved_dest = dest.resolve()
|
|
486
|
+
for item in dest.rglob("*"):
|
|
487
|
+
if item.is_symlink():
|
|
488
|
+
raise RuntimeError("Container overlay export contains a symbolic link.")
|
|
489
|
+
try:
|
|
490
|
+
item.resolve().relative_to(resolved_dest)
|
|
491
|
+
except (OSError, ValueError) as exc:
|
|
492
|
+
raise RuntimeError(
|
|
493
|
+
"Container overlay export escaped its destination."
|
|
494
|
+
) from exc
|
|
495
|
+
|
|
496
|
+
async def cleanup(self) -> None:
|
|
497
|
+
await self.process_manager.terminate_all()
|
|
498
|
+
await self.reconcile()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Restricted host process execution backend.
|
|
2
|
+
|
|
3
|
+
Fallback backend that executes processes directly on the host using ProcessManager
|
|
4
|
+
and PathValidator when container engines are unavailable.
|
|
5
|
+
|
|
6
|
+
Security hardening (unsafe host fallback):
|
|
7
|
+
- Marked as is_unsafe=True to distinguish from container execution.
|
|
8
|
+
- Every execution logs an UNSAFE_HOST isolation level warning.
|
|
9
|
+
- network_enabled parameter is now respected (blocks via policy, not enforcement).
|
|
10
|
+
- This backend should ONLY be used when the caller explicitly opts in
|
|
11
|
+
via unsafe_host_execution=True on the Sandbox constructor.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import warnings
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pulse.sandbox.network import NetworkEnforcementLevel, NetworkMode, NetworkPolicy
|
|
20
|
+
from pulse.sandbox.path_validator import PathValidator
|
|
21
|
+
from pulse.sandbox.process import ProcessEnforcementLevel, ProcessManager, ProcessResult
|
|
22
|
+
from pulse.sandbox.resources import ResourceLimits
|
|
23
|
+
from pulse.sandbox.secrets import (
|
|
24
|
+
SecretEnforcementLevel,
|
|
25
|
+
SecretMode,
|
|
26
|
+
SecretPolicy,
|
|
27
|
+
build_isolated_environment,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# SecurityWarning is not available in all Python builds; define a fallback.
|
|
31
|
+
try:
|
|
32
|
+
_SecurityWarning = SecurityWarning # type: ignore[name-defined]
|
|
33
|
+
except NameError:
|
|
34
|
+
class _SecurityWarning(UserWarning): # type: ignore[no-redef]
|
|
35
|
+
"""Fallback warning class for security-sensitive operations."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class HostBackend:
|
|
39
|
+
"""Restricted host execution fallback engine.
|
|
40
|
+
|
|
41
|
+
WARNING: This backend provides NO container isolation. All commands
|
|
42
|
+
execute directly on the host machine. It exists only as an explicitly
|
|
43
|
+
opt-in fallback for development environments where Docker/Podman
|
|
44
|
+
is unavailable.
|
|
45
|
+
|
|
46
|
+
Security properties:
|
|
47
|
+
- is_unsafe=True: always reports itself as unsafe.
|
|
48
|
+
- PathValidator enforces workspace directory containment for cwd.
|
|
49
|
+
- ResourceLimiter enforces POSIX rlimits (memory, PIDs, files) where available.
|
|
50
|
+
- Environment is sanitized (dangerous vars stripped).
|
|
51
|
+
- NO filesystem isolation, NO network isolation, NO capability dropping.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name = "host"
|
|
55
|
+
is_unsafe: bool = True
|
|
56
|
+
|
|
57
|
+
def __init__(self, process_manager: ProcessManager | None = None) -> None:
|
|
58
|
+
self.process_manager = process_manager or ProcessManager()
|
|
59
|
+
|
|
60
|
+
async def reconcile(self) -> None:
|
|
61
|
+
"""HostBackend processes are reaped by the OS; no orphan reconciliation needed."""
|
|
62
|
+
|
|
63
|
+
async def is_available(self) -> bool:
|
|
64
|
+
"""Host backend is always available."""
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
def get_network_enforcement_capability(self, policy: NetworkPolicy) -> NetworkEnforcementLevel:
|
|
68
|
+
"""Determine what level of security this backend can enforce for the policy.
|
|
69
|
+
|
|
70
|
+
HostBackend has NO network isolation capabilities. It cannot strongly enforce
|
|
71
|
+
ANY restrictive policy mode.
|
|
72
|
+
"""
|
|
73
|
+
if not policy or policy.mode == NetworkMode.ALLOW_ALL:
|
|
74
|
+
return NetworkEnforcementLevel.STRONGLY_ENFORCED
|
|
75
|
+
return NetworkEnforcementLevel.UNSUPPORTED
|
|
76
|
+
|
|
77
|
+
def get_secret_enforcement_capability(self, policy: SecretPolicy) -> SecretEnforcementLevel:
|
|
78
|
+
"""Determine what level of security this backend can enforce for the policy.
|
|
79
|
+
|
|
80
|
+
HostBackend has NO filesystem/environment isolation capabilities from the host user.
|
|
81
|
+
It cannot strongly enforce DENY_ALL or ALLOW_EXPLICIT because arbitrary code
|
|
82
|
+
can read ~/.ssh or ~/.aws.
|
|
83
|
+
"""
|
|
84
|
+
if not policy or policy.mode == SecretMode.ALLOW_ALL:
|
|
85
|
+
return SecretEnforcementLevel.STRONGLY_ENFORCED
|
|
86
|
+
return SecretEnforcementLevel.UNSUPPORTED
|
|
87
|
+
|
|
88
|
+
def get_process_containment_capability(self) -> ProcessEnforcementLevel:
|
|
89
|
+
"""Determine if this backend provides strong process containment.
|
|
90
|
+
|
|
91
|
+
HostBackend relies on POSIX process groups or Windows Job objects,
|
|
92
|
+
which can be escaped by descendants daemonizing (e.g. setsid).
|
|
93
|
+
"""
|
|
94
|
+
return ProcessEnforcementLevel.BEST_EFFORT
|
|
95
|
+
|
|
96
|
+
async def execute(
|
|
97
|
+
self,
|
|
98
|
+
command: str | list[str],
|
|
99
|
+
workspace_root: Path,
|
|
100
|
+
cwd: Path | None = None,
|
|
101
|
+
env: dict[str, str] | None = None,
|
|
102
|
+
limits: ResourceLimits | None = None,
|
|
103
|
+
network_policy: NetworkPolicy | None = None,
|
|
104
|
+
secret_policy: SecretPolicy | None = None,
|
|
105
|
+
execution_id: str | None = None,
|
|
106
|
+
) -> ProcessResult:
|
|
107
|
+
"""Execute command directly on host — NO CONTAINER ISOLATION.
|
|
108
|
+
|
|
109
|
+
Security warning:
|
|
110
|
+
This method executes arbitrary commands on the host machine.
|
|
111
|
+
It should only be reachable when the Sandbox was constructed
|
|
112
|
+
with unsafe_host_execution=True.
|
|
113
|
+
"""
|
|
114
|
+
warnings.warn(
|
|
115
|
+
"HostBackend.execute(): Running untrusted code directly on host "
|
|
116
|
+
"without container isolation. This is NOT safe for production use.",
|
|
117
|
+
_SecurityWarning,
|
|
118
|
+
stacklevel=2,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
validator = PathValidator(workspace_root)
|
|
122
|
+
target_dir = validator.validate_path(cwd or workspace_root)
|
|
123
|
+
|
|
124
|
+
env = env or {}
|
|
125
|
+
# Note: PROXY and ALLOWLIST are UNSUPPORTED and fail closed in api.py,
|
|
126
|
+
# so no fake proxy environment variable injection is done here.
|
|
127
|
+
|
|
128
|
+
# Build pristine environment to prevent accidental leak, though HostBackend
|
|
129
|
+
# cannot prevent code from actively reading host credential files.
|
|
130
|
+
safe_env = build_isolated_environment(secret_policy, extra_env=env)
|
|
131
|
+
|
|
132
|
+
return await self.process_manager.execute(
|
|
133
|
+
command=command,
|
|
134
|
+
cwd=target_dir,
|
|
135
|
+
env=safe_env,
|
|
136
|
+
limits=limits,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
async def cleanup(self) -> None:
|
|
140
|
+
await self.process_manager.terminate_all()
|