pulse-coding-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,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()