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
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
+ )