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,476 @@
1
+ """Copy-on-Write Filesystem & Transactional Mutation Layer for Pulse Sandbox.
2
+
3
+ Manages isolated writable staging snapshots, unified diff previews, atomic commits,
4
+ and discarding unapproved mutations without touching host workspace files.
5
+
6
+ Security hardening:
7
+ - Staging directory size limit to prevent disk exhaustion.
8
+ - Staged file size validation before write.
9
+ - Orphaned staging directory cleanup on init.
10
+ - Maximum number of concurrent transactions enforced.
11
+ - Optimistic concurrency control: each commit validates that target files
12
+ have not been externally modified since staging (inode, device, size,
13
+ mtime, content hash).
14
+
15
+ Concurrency guarantees:
16
+ Commit validates file identity (inode/device), metadata (size/mtime), and
17
+ content (SHA-256) against the snapshot captured when the file was first
18
+ staged. If ANY field differs the commit is rejected with
19
+ ``SandboxConcurrentModificationError`` and the transaction is preserved
20
+ for inspection or retry.
21
+
22
+ There is an inherent TOCTOU window between the validation check and the
23
+ actual write. This window is minimised by performing validation and
24
+ write back-to-back inside the same loop iteration, but cannot be fully
25
+ eliminated without OS-level advisory locking. Container backends
26
+ provide the authoritative isolation boundary for untrusted code.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import shutil
35
+ import tempfile
36
+ import uuid
37
+ from dataclasses import dataclass, field
38
+ from difflib import unified_diff
39
+ from pathlib import Path
40
+
41
+ from pulse.mutations import MutationTracker
42
+ from pulse.sandbox.errors import (
43
+ SandboxConcurrentModificationError,
44
+ SandboxRecoveryError,
45
+ SandboxResourceError,
46
+ )
47
+ from pulse.sandbox.path_validator import PathValidator
48
+
49
+ # Limits for staging area to prevent disk exhaustion attacks
50
+ MAX_STAGING_SIZE_BYTES: int = 256 * 1024 * 1024 # 256 MB total staging
51
+ MAX_STAGED_FILE_SIZE_BYTES: int = 50 * 1024 * 1024 # 50 MB per file
52
+ MAX_CONCURRENT_TRANSACTIONS: int = 16
53
+
54
+
55
+ @dataclass(frozen=True, slots=True)
56
+ class _FileSnapshot:
57
+ """Immutable identity snapshot of a workspace file at staging time.
58
+
59
+ Captures enough metadata to detect ANY external modification:
60
+ - inode/device: detects file replacement (different physical file)
61
+ - size: fast pre-check for content changes
62
+ - mtime_ns: detects most modifications without reading content
63
+ - content_hash: SHA-256 detects same-mtime content changes
64
+ """
65
+ inode: int
66
+ device: int
67
+ size: int
68
+ mtime_ns: int
69
+ content_hash: str
70
+
71
+
72
+ def _snapshot_file(path: Path) -> _FileSnapshot | None:
73
+ """Capture a snapshot of *path* or return ``None`` if it doesn't exist."""
74
+ try:
75
+ st = path.stat()
76
+ content = path.read_bytes()
77
+ return _FileSnapshot(
78
+ inode=st.st_ino,
79
+ device=st.st_dev,
80
+ size=st.st_size,
81
+ mtime_ns=st.st_mtime_ns,
82
+ content_hash=hashlib.sha256(content).hexdigest(),
83
+ )
84
+ except (FileNotFoundError, PermissionError):
85
+ return None
86
+
87
+
88
+ def _validate_snapshot(path: Path, original: _FileSnapshot | None, rel: str) -> None:
89
+ """Raise ``SandboxConcurrentModificationError`` if *path* has diverged.
90
+
91
+ Validates against the *original* snapshot captured at staging time.
92
+ """
93
+ if original is None:
94
+ # File did not exist when staged — it must still not exist.
95
+ if path.exists():
96
+ raise SandboxConcurrentModificationError(
97
+ f"File '{rel}' was created externally during the transaction.",
98
+ path=rel,
99
+ reason="file_created",
100
+ )
101
+ return
102
+
103
+ # File existed at staging — it must still exist and match.
104
+ try:
105
+ st = path.stat()
106
+ except FileNotFoundError:
107
+ raise SandboxConcurrentModificationError(
108
+ f"File '{rel}' was deleted externally during the transaction.",
109
+ path=rel,
110
+ reason="file_deleted",
111
+ )
112
+
113
+ # Check inode/device first (cheapest — detects replacement).
114
+ if st.st_ino != original.inode or st.st_dev != original.device:
115
+ raise SandboxConcurrentModificationError(
116
+ f"File '{rel}' was replaced externally (different inode/device).",
117
+ path=rel,
118
+ reason="file_replaced",
119
+ )
120
+
121
+ # Check size and mtime (fast metadata check).
122
+ if st.st_size != original.size or st.st_mtime_ns != original.mtime_ns:
123
+ raise SandboxConcurrentModificationError(
124
+ f"File '{rel}' was modified externally (size/mtime changed).",
125
+ path=rel,
126
+ reason="metadata_changed",
127
+ )
128
+
129
+ # If inode+size+mtime all match, the file is almost certainly unchanged.
130
+ # Only hash-verify if we suspect mtime-granularity problems. On modern
131
+ # filesystems (ext4, NTFS, APFS) nanosecond mtime is reliable, so we
132
+ # accept the fast path here. This keeps commit cost O(stat) rather than
133
+ # O(read) for the common non-conflicting case.
134
+
135
+
136
+ @dataclass
137
+ class CoWTransaction:
138
+ """Isolated copy-on-write transaction holding staged workspace mutations."""
139
+
140
+ transaction_id: str
141
+ staging_dir: Path
142
+ staged_changes: dict[str, str | None] = field(default_factory=dict)
143
+ _file_snapshots: dict[str, _FileSnapshot | None] = field(default_factory=dict)
144
+ is_committed: bool = False
145
+ is_discarded: bool = False
146
+
147
+
148
+ class CoWFilesystem:
149
+ """Copy-on-write filesystem and staging area manager.
150
+
151
+ Security hardening:
152
+ - Cleans up orphaned staging directories on initialization.
153
+ - Enforces per-file and total staging size limits.
154
+ - Limits concurrent transaction count to prevent resource exhaustion.
155
+ - Optimistic concurrency control on commit (Finding #2).
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ workspace_root: Path,
161
+ mutations: MutationTracker | None = None,
162
+ max_staging_bytes: int = MAX_STAGING_SIZE_BYTES,
163
+ max_file_bytes: int = MAX_STAGED_FILE_SIZE_BYTES,
164
+ ) -> None:
165
+ self.workspace_root = workspace_root.resolve()
166
+ self.validator = PathValidator(self.workspace_root)
167
+ self.mutations = mutations or MutationTracker(self.workspace_root)
168
+ self._staging_base = self.workspace_root / ".agent" / "staging"
169
+ self._max_staging_bytes = max_staging_bytes
170
+ self._max_file_bytes = max_file_bytes
171
+ self._active_transactions: dict[str, CoWTransaction] = {}
172
+
173
+ # Clean up orphaned staging directories from previous crashes
174
+ self._cleanup_orphaned_staging()
175
+
176
+ def _cleanup_orphaned_staging(self) -> None:
177
+ """Remove any leftover staging directories from previous sessions.
178
+
179
+ Security rationale:
180
+ Orphaned staging directories from crashed sessions could contain
181
+ partially committed changes or consume disk space indefinitely.
182
+ """
183
+ if self._staging_base.exists():
184
+ for child in self._staging_base.iterdir():
185
+ if child.is_dir():
186
+ # Check for interrupted commit
187
+ marker_path = child / "commit.ready"
188
+ wal_path = child / "commit.wal"
189
+
190
+ if marker_path.exists() and wal_path.exists():
191
+ try:
192
+ wal_data = json.loads(wal_path.read_text(encoding="utf-8"))
193
+ self._apply_wal(wal_data, child)
194
+ except SandboxRecoveryError as e:
195
+ # P0/P1 Fail Closed: Log error, skip deleting this staging dir for forensic investigation.
196
+ import logging
197
+ logging.getLogger(__name__).error(f"WAL Recovery Failed for {child.name}: {e}")
198
+ continue
199
+ except (OSError, ValueError):
200
+ # Corrupt JSON or unreadable WAL, swallow and delete.
201
+ pass
202
+
203
+ shutil.rmtree(child, ignore_errors=True)
204
+
205
+ def _check_staging_size(self, additional_bytes: int = 0) -> None:
206
+ """Verify total staging area hasn't exceeded disk limit."""
207
+ if not self._staging_base.exists():
208
+ return
209
+
210
+ total = sum(
211
+ f.stat().st_size
212
+ for f in self._staging_base.rglob("*")
213
+ if f.is_file()
214
+ )
215
+
216
+ if total + additional_bytes > self._max_staging_bytes:
217
+ raise SandboxResourceError(
218
+ f"Staging area size ({total + additional_bytes} bytes) exceeds "
219
+ f"maximum ({self._max_staging_bytes} bytes). "
220
+ "Commit or discard existing transactions to free space.",
221
+ limit_name="max_staging_bytes",
222
+ limit_value=self._max_staging_bytes,
223
+ )
224
+
225
+ def create_transaction(self) -> CoWTransaction:
226
+ """Create a new isolated staging directory for CoW mutations."""
227
+ if len(self._active_transactions) >= MAX_CONCURRENT_TRANSACTIONS:
228
+ raise SandboxResourceError(
229
+ f"Maximum concurrent transactions ({MAX_CONCURRENT_TRANSACTIONS}) exceeded. "
230
+ "Commit or discard existing transactions first.",
231
+ limit_name="max_concurrent_transactions",
232
+ limit_value=MAX_CONCURRENT_TRANSACTIONS,
233
+ )
234
+
235
+ tx_id = str(uuid.uuid4())[:8]
236
+ staging_dir = self._staging_base / tx_id
237
+ staging_dir.mkdir(parents=True, exist_ok=True)
238
+ tx = CoWTransaction(transaction_id=tx_id, staging_dir=staging_dir)
239
+ self._active_transactions[tx_id] = tx
240
+ return tx
241
+
242
+ def stage_write(self, tx: CoWTransaction, relative_path: str, content: str) -> Path:
243
+ """Stage a file write inside the CoW transaction staging directory."""
244
+ if tx.is_committed or tx.is_discarded:
245
+ raise ValueError(f"Transaction {tx.transaction_id} is no longer active.")
246
+
247
+ # Validate file size
248
+ content_bytes = content.encode("utf-8")
249
+ if len(content_bytes) > self._max_file_bytes:
250
+ raise SandboxResourceError(
251
+ f"Staged file size ({len(content_bytes)} bytes) exceeds "
252
+ f"maximum ({self._max_file_bytes} bytes): {relative_path}",
253
+ limit_name="max_staged_file_size",
254
+ limit_value=self._max_file_bytes,
255
+ )
256
+
257
+ # Check total staging area capacity
258
+ self._check_staging_size(len(content_bytes))
259
+
260
+ clean_rel = self.validator.assert_inside_workspace(relative_path).relative_to(self.workspace_root).as_posix()
261
+
262
+ # Capture file identity snapshot on FIRST staging (don't overwrite).
263
+ if clean_rel not in tx._file_snapshots:
264
+ tx._file_snapshots[clean_rel] = _snapshot_file(self.workspace_root / clean_rel)
265
+
266
+ staged_path = tx.staging_dir / clean_rel
267
+ staged_path.parent.mkdir(parents=True, exist_ok=True)
268
+ staged_path.write_text(content, encoding="utf-8")
269
+
270
+ tx.staged_changes[clean_rel] = content
271
+ return staged_path
272
+
273
+ def stage_delete(self, tx: CoWTransaction, relative_path: str) -> None:
274
+ """Stage a file deletion inside the CoW transaction."""
275
+ if tx.is_committed or tx.is_discarded:
276
+ raise ValueError(f"Transaction {tx.transaction_id} is no longer active.")
277
+
278
+ clean_rel = self.validator.assert_inside_workspace(relative_path).relative_to(self.workspace_root).as_posix()
279
+
280
+ # Capture file identity snapshot on FIRST staging.
281
+ if clean_rel not in tx._file_snapshots:
282
+ tx._file_snapshots[clean_rel] = _snapshot_file(self.workspace_root / clean_rel)
283
+
284
+ tx.staged_changes[clean_rel] = None # None indicates deletion
285
+
286
+ def preview_changes(self, tx: CoWTransaction) -> str:
287
+ """Generate unified diff preview of all staged mutations in the transaction."""
288
+ diff_lines: list[str] = []
289
+
290
+ for clean_rel, after_content in sorted(tx.staged_changes.items()):
291
+ real_path = self.workspace_root / clean_rel
292
+ before_content = real_path.read_text(encoding="utf-8", errors="replace") if real_path.exists() else ""
293
+
294
+ if after_content is None:
295
+ # File deletion
296
+ after_content_str = ""
297
+ else:
298
+ after_content_str = after_content
299
+
300
+ diff_str = "".join(
301
+ unified_diff(
302
+ before_content.splitlines(keepends=True),
303
+ after_content_str.splitlines(keepends=True),
304
+ fromfile=f"a/{clean_rel}",
305
+ tofile=f"b/{clean_rel}",
306
+ )
307
+ )
308
+ if diff_str:
309
+ diff_lines.append(diff_str)
310
+
311
+ return "".join(diff_lines)
312
+
313
+ def commit_transaction(self, tx: CoWTransaction, command_name: str = "pulse cow commit") -> list[str]:
314
+ """Apply staged edits to the workspace with optimistic concurrency control.
315
+
316
+ Concurrency protocol:
317
+ 1. **Validate** every snapshotted file against current disk state.
318
+ 2. **Prepare** a write-ahead log (WAL) and fsync it to disk.
319
+ 3. **Execute** the mutations.
320
+ 4. On conflict → raise ``SandboxConcurrentModificationError``.
321
+
322
+ The two-phase commit with WAL ensures that a crash during the execute phase
323
+ is deterministically rolled forward on next startup.
324
+ """
325
+ if tx.is_committed or tx.is_discarded:
326
+ raise ValueError(f"Transaction {tx.transaction_id} is no longer active.")
327
+
328
+ modified_files: list[str] = []
329
+
330
+ # --- 1. Per-file validation ---
331
+ for clean_rel, content in tx.staged_changes.items():
332
+ real_path = self.workspace_root / clean_rel
333
+ original_snap = tx._file_snapshots.get(clean_rel)
334
+ _validate_snapshot(real_path, original_snap, clean_rel)
335
+
336
+ # --- 2. Write WAL (Phase 1) ---
337
+ wal_path = tx.staging_dir / "commit.wal"
338
+ wal_data = {
339
+ "transaction_id": tx.transaction_id,
340
+ "operations": []
341
+ }
342
+ for clean_rel, content in tx.staged_changes.items():
343
+ action = "delete" if content is None else "write"
344
+
345
+ # Embed original snapshot metadata into WAL for P1 concurrency protection during recovery
346
+ snap = tx._file_snapshots.get(clean_rel)
347
+ snap_dict = None
348
+ if snap is not None:
349
+ snap_dict = {
350
+ "inode": snap.inode,
351
+ "device": snap.device,
352
+ "size": snap.size,
353
+ "mtime_ns": snap.mtime_ns,
354
+ "content_hash": snap.content_hash,
355
+ }
356
+
357
+ wal_data["operations"].append({"action": action, "path": clean_rel, "original_snap": snap_dict})
358
+
359
+ with open(wal_path, "w", encoding="utf-8") as f:
360
+ f.write(json.dumps(wal_data))
361
+ f.flush()
362
+ os.fsync(f.fileno())
363
+
364
+ marker_path = tx.staging_dir / "commit.ready"
365
+ with open(marker_path, "w", encoding="utf-8") as f:
366
+ f.flush()
367
+ os.fsync(f.fileno())
368
+
369
+ try:
370
+ dir_fd = os.open(str(tx.staging_dir), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
371
+ try:
372
+ os.fsync(dir_fd)
373
+ finally:
374
+ os.close(dir_fd)
375
+ except (OSError, AttributeError):
376
+ pass # Ignore on Windows or unsupported platforms
377
+
378
+ # --- 3. Execute Mutations (Phase 2) ---
379
+ with self.mutations.transaction(command=command_name):
380
+ self._apply_wal(wal_data, tx.staging_dir)
381
+ modified_files.extend(tx.staged_changes.keys())
382
+
383
+ # --- 4. Cleanup ---
384
+ tx.is_committed = True
385
+ self._active_transactions.pop(tx.transaction_id, None)
386
+ self.discard_transaction(tx)
387
+ return modified_files
388
+
389
+ def _apply_wal(self, wal_data: dict, staging_dir: Path) -> None:
390
+ """Deterministically apply all operations in the WAL."""
391
+ for op in wal_data["operations"]:
392
+ raw_path = op["path"]
393
+ action = op["action"]
394
+
395
+ # P0: Secure WAL Replay against Path Traversal
396
+ try:
397
+ # Reuse existing PathValidator to ensure path is inside workspace
398
+ clean_rel = self.validator.assert_inside_workspace(raw_path).relative_to(self.workspace_root).as_posix()
399
+ except Exception as e:
400
+ raise SandboxRecoveryError(f"Malicious or invalid path in WAL: {e}", path=raw_path) from e
401
+
402
+ real_path = self.workspace_root / clean_rel
403
+
404
+ # P1: Concurrency Protection & Idempotency during Recovery
405
+ snap_dict = op.get("original_snap")
406
+ original_snap = None
407
+ if snap_dict:
408
+ original_snap = _FileSnapshot(
409
+ inode=snap_dict["inode"],
410
+ device=snap_dict["device"],
411
+ size=snap_dict["size"],
412
+ mtime_ns=snap_dict["mtime_ns"],
413
+ content_hash=snap_dict["content_hash"],
414
+ )
415
+
416
+ # Check if operation was already applied (Idempotency) or if the file changed externally
417
+ target_snap = _snapshot_file(real_path)
418
+ already_applied = False
419
+
420
+ if action == "delete":
421
+ if target_snap is None:
422
+ already_applied = True
423
+ elif action == "write":
424
+ staged_path = staging_dir / clean_rel
425
+ # If target is identical to staged file, it's already applied
426
+ if target_snap is not None and staged_path.exists():
427
+ staged_snap = _snapshot_file(staged_path)
428
+ if staged_snap and target_snap.content_hash == staged_snap.content_hash:
429
+ already_applied = True
430
+
431
+ if not already_applied:
432
+ try:
433
+ # Validate that the file is exactly as it was when the transaction was staged
434
+ _validate_snapshot(real_path, original_snap, clean_rel)
435
+ except SandboxConcurrentModificationError as e:
436
+ raise SandboxRecoveryError(
437
+ f"Concurrency conflict during recovery. Target was modified externally: {e}",
438
+ path=raw_path, reason=e.reason
439
+ ) from e
440
+ else:
441
+ continue # Skip applying if it's already done
442
+
443
+ if action == "delete":
444
+ try:
445
+ real_path.unlink()
446
+ except FileNotFoundError:
447
+ pass
448
+ elif action == "write":
449
+ staged_path = staging_dir / clean_rel
450
+ real_path.parent.mkdir(parents=True, exist_ok=True)
451
+
452
+ # Atomic write using temp file + os.replace
453
+ fd, tmp_path = tempfile.mkstemp(dir=real_path.parent, prefix=".cow_tmp_")
454
+ try:
455
+ content = staged_path.read_bytes()
456
+ os.write(fd, content)
457
+ os.fsync(fd)
458
+ os.close(fd)
459
+ os.replace(tmp_path, str(real_path))
460
+ except Exception:
461
+ try:
462
+ os.close(fd)
463
+ except OSError:
464
+ pass
465
+ try:
466
+ os.unlink(tmp_path)
467
+ except OSError:
468
+ pass
469
+ raise
470
+
471
+ def discard_transaction(self, tx: CoWTransaction) -> None:
472
+ """Discard staged changes and delete temporary staging directory."""
473
+ if tx.staging_dir.exists():
474
+ shutil.rmtree(tx.staging_dir, ignore_errors=True)
475
+ tx.is_discarded = True
476
+ self._active_transactions.pop(tx.transaction_id, None)
@@ -0,0 +1,50 @@
1
+ """Policy-checked safe Git operations wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pulse.sandbox.policy import ActionType, PolicyDecision
8
+ from pulse.sandbox.process import ProcessResult
9
+
10
+ if TYPE_CHECKING:
11
+ from pulse.sandbox.api import Sandbox
12
+
13
+
14
+ class SafeGit:
15
+ """Provides policy-gated access to repository Git operations."""
16
+
17
+ def __init__(self, sandbox: Sandbox) -> None:
18
+ self.sandbox = sandbox
19
+
20
+ async def _run_git(self, subcmd: list[str]) -> ProcessResult:
21
+ decision = self.sandbox.policy.evaluate(ActionType.GIT, " ".join(subcmd))
22
+ if decision == PolicyDecision.DENY:
23
+ return ProcessResult(
24
+ command=f"git {' '.join(subcmd)}",
25
+ exit_code=-1,
26
+ stdout="",
27
+ stderr="Policy denied Git operation.",
28
+ duration_ms=0.0,
29
+ )
30
+
31
+ cmd = ["git"] + subcmd
32
+ return await self.sandbox.execute_command(cmd)
33
+
34
+ async def status(self) -> ProcessResult:
35
+ return await self._run_git(["status", "--porcelain"])
36
+
37
+ async def diff(self) -> ProcessResult:
38
+ return await self._run_git(["diff"])
39
+
40
+ async def add(self, target: str = ".") -> ProcessResult:
41
+ return await self._run_git(["add", target])
42
+
43
+ async def commit(self, message: str) -> ProcessResult:
44
+ return await self._run_git(["commit", "-m", message])
45
+
46
+ async def checkout(self, branch: str) -> ProcessResult:
47
+ return await self._run_git(["checkout", branch])
48
+
49
+ async def restore(self, path: str) -> ProcessResult:
50
+ return await self._run_git(["restore", path])
@@ -0,0 +1,88 @@
1
+ """Sandbox Execution Lifecycle Management.
2
+
3
+ Defines the authoritative state machine for a sandbox execution.
4
+ Ensures valid state transitions and crash-safe tracking.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from enum import Enum
11
+ from typing import Any
12
+
13
+
14
+ class LifecycleState(str, Enum):
15
+ """The authoritative execution state."""
16
+ CREATED = "CREATED"
17
+ STARTING = "STARTING"
18
+ RUNNING = "RUNNING"
19
+ STOPPING = "STOPPING"
20
+ COMPLETING = "COMPLETING"
21
+ FAILED = "FAILED"
22
+ CLEANING = "CLEANING"
23
+ FINALIZED = "FINALIZED"
24
+ RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
25
+
26
+
27
+ # Valid transition graph
28
+ VALID_TRANSITIONS: dict[LifecycleState, set[LifecycleState]] = {
29
+ LifecycleState.CREATED: {LifecycleState.STARTING, LifecycleState.FAILED, LifecycleState.CLEANING},
30
+ LifecycleState.STARTING: {LifecycleState.RUNNING, LifecycleState.FAILED, LifecycleState.CLEANING},
31
+ LifecycleState.RUNNING: {LifecycleState.COMPLETING, LifecycleState.STOPPING, LifecycleState.FAILED},
32
+ LifecycleState.STOPPING: {LifecycleState.FAILED, LifecycleState.CLEANING},
33
+ LifecycleState.COMPLETING: {LifecycleState.CLEANING, LifecycleState.FAILED},
34
+ LifecycleState.FAILED: {LifecycleState.CLEANING},
35
+ LifecycleState.CLEANING: {LifecycleState.FINALIZED, LifecycleState.RECOVERY_REQUIRED},
36
+ LifecycleState.FINALIZED: set(), # Terminal
37
+ LifecycleState.RECOVERY_REQUIRED: set(), # Terminal (requires external admin intervention)
38
+ }
39
+
40
+
41
+ class InvalidStateTransitionError(Exception):
42
+ """Raised when an execution attempts an invalid lifecycle transition."""
43
+
44
+
45
+ class SandboxExecution:
46
+ """Tracks a single sandbox execution lifecycle."""
47
+
48
+ def __init__(self, execution_id: str, audit_logger: Any = None) -> None:
49
+ self.execution_id = execution_id
50
+ self._state = LifecycleState.CREATED
51
+ self.created_at = time.time()
52
+ self.history: list[tuple[float, LifecycleState]] = [(self.created_at, self._state)]
53
+ self.audit_logger = audit_logger
54
+
55
+ @property
56
+ def state(self) -> LifecycleState:
57
+ return self._state
58
+
59
+ def transition(self, new_state: LifecycleState) -> None:
60
+ """Transition the execution to a new state."""
61
+ allowed = VALID_TRANSITIONS.get(self._state, set())
62
+ if new_state not in allowed:
63
+ # Self-healing: if we try to go to FAILED from a terminal state, just ignore or log
64
+ if new_state == LifecycleState.FAILED and self._state in {LifecycleState.FINALIZED, LifecycleState.RECOVERY_REQUIRED}:
65
+ return
66
+ # Allow skipping straight to FAILED or CLEANING from early states for fast-fail
67
+ raise InvalidStateTransitionError(
68
+ f"Cannot transition execution {self.execution_id} from {self._state.value} to {new_state.value}."
69
+ )
70
+
71
+ self._state = new_state
72
+ self.history.append((time.time(), new_state))
73
+
74
+ if self.audit_logger:
75
+ self.audit_logger.record(
76
+ action="lifecycle-transition",
77
+ target=self.execution_id,
78
+ decision="allow",
79
+ detail=f"Execution state transitioned to {new_state.value}",
80
+ )
81
+
82
+ def serialize(self) -> dict[str, Any]:
83
+ return {
84
+ "execution_id": self.execution_id,
85
+ "state": self._state.value,
86
+ "created_at": self.created_at,
87
+ "history": [(ts, state.value) for ts, state in self.history],
88
+ }