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,175 @@
1
+ """Remote Sandbox Worker.
2
+
3
+ Wraps the DockerBackend to securely execute commands on behalf of the RemoteServer.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import logging
10
+ import typing
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pulse.sandbox.backend.docker import DockerBackend
15
+ from pulse.sandbox.network import NetworkPolicy
16
+ from pulse.sandbox.process import ProcessResult
17
+ from pulse.sandbox.remote.models import (
18
+ ExecutionResultModel,
19
+ SubmitExecutionRequest,
20
+ validate_execution_id,
21
+ )
22
+ from pulse.sandbox.resources import ResourcePolicy
23
+ from pulse.sandbox.secrets import SecretPolicy, SecretScrubber
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ class RemoteWorker:
29
+ """Executes untrusted code inside a hardened Docker/Podman container.
30
+
31
+ This worker acts as the server-side counterpart to the local DockerBackend,
32
+ enforcing identical security semantics.
33
+ """
34
+
35
+ def __init__(self, workspace_base_path: Path | None = None) -> None:
36
+ self.backend = DockerBackend()
37
+ # Default isolation base path where tenant workspaces will be unpacked
38
+ self.workspace_base_path = workspace_base_path or Path(
39
+ "/tmp/pulse_remote_workspaces"
40
+ )
41
+ self.workspace_base_path.mkdir(parents=True, exist_ok=True)
42
+ self.overlays: dict[str, Path] = {}
43
+ self._active_executions: dict[str, asyncio.Task[Any]] = {}
44
+
45
+ async def initialize(self) -> None:
46
+ """Initialize the worker backend."""
47
+ await self.backend.reconcile()
48
+
49
+ async def execute_request(
50
+ self,
51
+ req: SubmitExecutionRequest,
52
+ tenant_id: str = "unknown",
53
+ output_callback: typing.Callable[[str, bytes], typing.Awaitable[None]]
54
+ | None = None,
55
+ ) -> ExecutionResultModel:
56
+ """Execute a remote request securely."""
57
+
58
+ # Deserialize policies
59
+ res_policy = (
60
+ ResourcePolicy.from_dict(req.resource_policy_dict)
61
+ if req.resource_policy_dict
62
+ else ResourcePolicy(wall_time_seconds=600.0)
63
+ )
64
+ net_policy = (
65
+ NetworkPolicy.from_dict(req.network_policy_dict)
66
+ if req.network_policy_dict
67
+ else None
68
+ )
69
+ sec_policy = (
70
+ SecretPolicy.from_dict(req.secret_policy_dict)
71
+ if req.secret_policy_dict
72
+ else None
73
+ )
74
+ secret_values = list((req.env or {}).values())
75
+ if sec_policy:
76
+ secret_values.extend(sec_policy.explicit_env.values())
77
+ scrubber = SecretScrubber(secret_values)
78
+
79
+ async def safe_output_callback(stream: str, data: bytes) -> None:
80
+ if output_callback is None:
81
+ return
82
+ text = data.decode("utf-8", errors="replace")
83
+ redacted = scrubber.redact(text).encode("utf-8", errors="replace")
84
+ await output_callback(stream, redacted)
85
+
86
+ # Resolve paths for the execution
87
+ tenant_workspace = self.workspace_base_path / tenant_id / req.execution_id
88
+ tenant_workspace.mkdir(parents=True, exist_ok=True)
89
+
90
+ cwd = (
91
+ tenant_workspace / req.working_directory
92
+ if req.working_directory
93
+ else tenant_workspace
94
+ )
95
+
96
+ # Keep track of the current asyncio task so we can cancel it via cancel()
97
+ import asyncio
98
+
99
+ self._active_executions[req.execution_id] = asyncio.current_task()
100
+
101
+ try:
102
+ # We delegate to DockerBackend for strict isolation (--cap-drop=ALL, etc)
103
+ result: ProcessResult = await self.backend.execute(
104
+ command=req.command,
105
+ workspace_root=tenant_workspace,
106
+ cwd=cwd,
107
+ env=req.env,
108
+ limits=res_policy,
109
+ network_policy=net_policy,
110
+ secret_policy=sec_policy,
111
+ execution_id=req.execution_id,
112
+ output_callback=safe_output_callback if output_callback else None,
113
+ )
114
+
115
+ # Store the overlay path for retrieval (R6)
116
+ if result.overlay_path and result.overlay_path.exists():
117
+ self.overlays[req.execution_id] = result.overlay_path
118
+
119
+ return ExecutionResultModel(
120
+ execution_id=req.execution_id,
121
+ command=scrubber.redact(result.command),
122
+ exit_code=result.exit_code,
123
+ stdout=scrubber.redact(result.stdout),
124
+ stderr=scrubber.redact(result.stderr),
125
+ duration_ms=result.duration_ms,
126
+ timed_out=result.timed_out,
127
+ truncated=result.truncated,
128
+ termination_reason=result.termination_reason,
129
+ )
130
+
131
+ except (OSError, RuntimeError, asyncio.CancelledError):
132
+ logger.error("Worker execution failed with an internal error.")
133
+ return ExecutionResultModel(
134
+ execution_id=req.execution_id,
135
+ command=scrubber.redact(str(req.command)),
136
+ exit_code=-1,
137
+ stdout="",
138
+ stderr="Worker execution failed with an internal error.",
139
+ duration_ms=0.0,
140
+ termination_reason="worker_crash",
141
+ )
142
+ finally:
143
+ self._active_executions.pop(req.execution_id, None)
144
+
145
+ def get_overlay_path(self, execution_id: str) -> Path | None:
146
+ """Get the stored overlay path for an execution."""
147
+ return self.overlays.get(execution_id)
148
+
149
+ def cleanup_overlay(self, execution_id: str) -> None:
150
+ """Clean up the stored overlay."""
151
+ overlay = self.overlays.pop(execution_id, None)
152
+ if overlay and overlay.exists():
153
+ import shutil
154
+
155
+ shutil.rmtree(overlay, ignore_errors=True)
156
+ try:
157
+ overlay.parent.rmdir()
158
+ except OSError:
159
+ pass
160
+
161
+ def cleanup_workspace(self, tenant_id: str, execution_id: str) -> None:
162
+ """Clean up the tenant workspace."""
163
+ validate_execution_id(execution_id)
164
+ workspace = self.workspace_base_path / tenant_id / execution_id
165
+ if workspace.exists():
166
+ import shutil
167
+
168
+ shutil.rmtree(workspace, ignore_errors=True)
169
+ self.cleanup_overlay(execution_id)
170
+
171
+ async def cancel(self, execution_id: str) -> None:
172
+ """Cancel a running execution on this worker."""
173
+ task = self._active_executions.get(execution_id)
174
+ if task and not task.done():
175
+ task.cancel()
@@ -0,0 +1,236 @@
1
+ """Backend-independent resource governance for sandbox executions.
2
+
3
+ Policies describe limits without referring to a backend. Backends translate
4
+ them to native controls (cgroups for containers and ``resource`` on POSIX),
5
+ while :class:`ResourceController` owns the portable execution lifecycle.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import signal
12
+ import sys
13
+ import time
14
+ from dataclasses import asdict, dataclass, replace
15
+ from typing import Any
16
+
17
+ try:
18
+ import resource
19
+ except ImportError: # pragma: no cover - Windows has no resource module
20
+ resource = None
21
+
22
+ _linux_prctl: Any | None = None
23
+ if sys.platform.startswith("linux"):
24
+ try: # Resolve libc before forking; importing or loading it in preexec can deadlock.
25
+ import ctypes
26
+
27
+ _linux_prctl = ctypes.CDLL(None, use_errno=True).prctl
28
+ except (ImportError, OSError, AttributeError): # pragma: no cover - unusual libc
29
+ _linux_prctl = None
30
+
31
+ DANGEROUS_ENV_VARS: frozenset[str] = frozenset({
32
+ "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES",
33
+ "DYLD_LIBRARY_PATH", "PYTHONSTARTUP", "PYTHONPATH", "PERL5LIB",
34
+ "RUBYLIB", "NODE_OPTIONS", "BASH_ENV", "ENV", "CDPATH",
35
+ })
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ResourcePolicy:
40
+ """Immutable, serializable limits applied to one sandbox execution.
41
+
42
+ A zero or ``None`` limit means that the corresponding native limit is not
43
+ requested. Not every operating system exposes every limit; callers can
44
+ inspect backend capabilities without changing their execution code.
45
+ """
46
+
47
+ cpu_quota_percent: float = 100.0
48
+ cpu_time_seconds: float | None = None
49
+ memory_bytes: int | None = 1_073_741_824
50
+ swap_bytes: int | None = 1_073_741_824
51
+ disk_bytes: int | None = None
52
+ max_processes: int | None = 64
53
+ max_open_files: int | None = 256
54
+ max_output_bytes: int = 5_242_880
55
+ wall_time_seconds: float = 30.0
56
+ termination_grace_seconds: float = 1.5
57
+ working_directory_bytes: int | None = None
58
+
59
+ def __post_init__(self) -> None:
60
+ for name in ("cpu_quota_percent", "max_output_bytes", "wall_time_seconds", "termination_grace_seconds"):
61
+ if getattr(self, name) <= 0:
62
+ raise ValueError(f"{name} must be positive")
63
+ for name in ("cpu_time_seconds", "memory_bytes", "swap_bytes", "disk_bytes", "max_processes", "max_open_files", "working_directory_bytes"):
64
+ value = getattr(self, name)
65
+ if value is not None and value <= 0:
66
+ raise ValueError(f"{name} must be positive when set")
67
+
68
+ def compose(self, **overrides: Any) -> ResourcePolicy:
69
+ """Return a derived policy, preserving immutability and validation."""
70
+ return replace(self, **overrides)
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return asdict(self)
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, Any]) -> ResourcePolicy:
77
+ return cls(**data)
78
+
79
+
80
+ @dataclass(frozen=True, slots=True)
81
+ class ExecutionMetrics:
82
+ """Structured observations collected for a completed execution."""
83
+
84
+ elapsed_ms: float
85
+ cpu_time_ms: float | None = None
86
+ peak_memory_bytes: int | None = None
87
+ process_count: int | None = None
88
+ output_bytes: int = 0
89
+ exit_status: int | None = None
90
+ termination_reason: str | None = None
91
+
92
+
93
+ class ResourceLimitExceeded(RuntimeError):
94
+ """A named execution resource limit was reached."""
95
+
96
+ def __init__(self, limit_name: str, limit_value: float) -> None:
97
+ self.limit_name = limit_name
98
+ self.limit_value = limit_value
99
+ super().__init__(f"Sandbox resource limit exceeded: {limit_name}={limit_value}")
100
+
101
+
102
+ class TimeoutExceeded(ResourceLimitExceeded):
103
+ """The wall-clock execution deadline elapsed."""
104
+
105
+ def __init__(self, timeout_seconds: float) -> None:
106
+ super().__init__("wall_time_seconds", timeout_seconds)
107
+
108
+
109
+ class ResourceMonitor:
110
+ """Collect portable elapsed/output metrics and POSIX child resource usage."""
111
+
112
+ def __init__(self) -> None:
113
+ self._started_at = 0.0
114
+ self._rusage_before: Any | None = None
115
+
116
+ def start(self) -> None:
117
+ self._started_at = time.monotonic()
118
+ self._rusage_before = resource.getrusage(resource.RUSAGE_CHILDREN) if resource else None
119
+
120
+ def finish(self, *, output_bytes: int, exit_status: int, termination_reason: str | None, process_count: int | None = 1) -> ExecutionMetrics:
121
+ cpu_time_ms: float | None = None
122
+ peak_memory_bytes: int | None = None
123
+ if resource and self._rusage_before is not None:
124
+ after = resource.getrusage(resource.RUSAGE_CHILDREN)
125
+ cpu_time_ms = ((after.ru_utime + after.ru_stime) - (self._rusage_before.ru_utime + self._rusage_before.ru_stime)) * 1000
126
+ # ru_maxrss is KiB on Linux and bytes on macOS.
127
+ peak_memory_bytes = int(after.ru_maxrss * (1 if sys.platform == "darwin" else 1024))
128
+ return ExecutionMetrics((time.monotonic() - self._started_at) * 1000, cpu_time_ms, peak_memory_bytes, process_count, output_bytes, exit_status, termination_reason)
129
+
130
+
131
+ class ResourceController:
132
+ """Coordinates portable timeout, cancellation, output and metrics behavior."""
133
+
134
+ def __init__(self, policy: ResourcePolicy | None = None, monitor: ResourceMonitor | None = None) -> None:
135
+ self.policy = policy or ResourcePolicy()
136
+ self.monitor = monitor or ResourceMonitor()
137
+
138
+ def sanitize_env(self, env: dict[str, str] | None = None) -> dict[str, str]:
139
+ # An explicit environment is authoritative. Merging it with os.environ
140
+ # silently reintroduces host credentials into supposedly isolated jobs.
141
+ merged = dict(os.environ if env is None else env)
142
+ for variable in DANGEROUS_ENV_VARS:
143
+ merged.pop(variable, None)
144
+ return merged
145
+
146
+ def make_preexec_fn(self) -> Any | None:
147
+ """Create POSIX native enforcement for host processes."""
148
+ if not resource or sys.platform == "win32":
149
+ return None
150
+ policy = self.policy
151
+
152
+ def set_limit(limit: int, value: int | None) -> None:
153
+ if value is not None:
154
+ try:
155
+ resource.setrlimit(limit, (value, value))
156
+ except (OSError, ValueError):
157
+ pass
158
+
159
+ def preexec() -> None:
160
+ # Keep this callback async-signal-safe in the post-fork child: all
161
+ # imports and dynamic-library loading happen at module import time.
162
+ # PR_SET_PDEATHSIG is 1.
163
+ if _linux_prctl is not None:
164
+ _linux_prctl(1, signal.SIGKILL)
165
+
166
+ try:
167
+ if hasattr(resource, "RLIMIT_NPROC"):
168
+ set_limit(resource.RLIMIT_NPROC, policy.max_processes)
169
+ if hasattr(resource, "RLIMIT_NOFILE"):
170
+ set_limit(resource.RLIMIT_NOFILE, policy.max_open_files)
171
+ if hasattr(resource, "RLIMIT_AS"):
172
+ set_limit(resource.RLIMIT_AS, policy.memory_bytes)
173
+ if (
174
+ hasattr(resource, "RLIMIT_CPU")
175
+ and policy.cpu_time_seconds is not None
176
+ ):
177
+ set_limit(
178
+ resource.RLIMIT_CPU, max(1, int(policy.cpu_time_seconds))
179
+ )
180
+ if hasattr(resource, "RLIMIT_FSIZE"):
181
+ set_limit(resource.RLIMIT_FSIZE, policy.disk_bytes)
182
+ except Exception: # noqa: BLE001 - fail closed at the subprocess boundary
183
+ # subprocess cannot safely propagate rich exceptions from a
184
+ # preexec callback. Exit closed if an unexpected limit setup
185
+ # failure escapes the individual setrlimit guards.
186
+ os._exit(126)
187
+
188
+ return preexec
189
+
190
+
191
+ @dataclass(frozen=True, slots=True)
192
+ class ResourceLimits:
193
+ """Legacy-compatible resource limits adapter.
194
+
195
+ New integrations should accept :class:`ResourcePolicy`; this type remains
196
+ supported by public APIs introduced in earlier sandbox phases.
197
+ """
198
+ max_memory_bytes: int = 1_073_741_824
199
+ max_cpu_percent: float = 100.0
200
+ max_pids: int = 64
201
+ max_open_files: int = 256
202
+ timeout_seconds: float = 30.0
203
+ max_output_bytes: int = 5_242_880
204
+ max_file_read_bytes: int = 52_428_800
205
+ max_storage_bytes: int | None = None
206
+
207
+ def to_policy(self) -> ResourcePolicy:
208
+ return ResourcePolicy(cpu_quota_percent=self.max_cpu_percent, memory_bytes=self.max_memory_bytes, swap_bytes=self.max_memory_bytes, max_processes=self.max_pids, max_open_files=self.max_open_files, max_output_bytes=self.max_output_bytes, wall_time_seconds=self.timeout_seconds, disk_bytes=self.max_storage_bytes)
209
+
210
+ def to_dict(self) -> dict[str, Any]:
211
+ return asdict(self)
212
+
213
+
214
+ class ResourceLimiter:
215
+ """Backward-compatible adapter around :class:`ResourceController`."""
216
+ def __init__(self, limits: ResourceLimits | ResourcePolicy | None = None) -> None:
217
+ self.policy = limits if isinstance(limits, ResourcePolicy) else (limits or ResourceLimits()).to_policy()
218
+ self.limits = limits if isinstance(limits, ResourceLimits) else ResourceLimits(
219
+ max_memory_bytes=self.policy.memory_bytes or 0, max_cpu_percent=self.policy.cpu_quota_percent,
220
+ max_pids=self.policy.max_processes or 0, max_open_files=self.policy.max_open_files or 0,
221
+ timeout_seconds=self.policy.wall_time_seconds, max_output_bytes=self.policy.max_output_bytes,
222
+ max_storage_bytes=self.policy.disk_bytes)
223
+ self.controller = ResourceController(self.policy)
224
+
225
+ def make_preexec_fn(self) -> Any | None:
226
+ return self.controller.make_preexec_fn()
227
+
228
+ def sanitize_env(self, env: dict[str, str] | None = None) -> dict[str, str]:
229
+ return self.controller.sanitize_env(env)
230
+
231
+ def truncate_output(self, content: str | bytes) -> tuple[str, bool]:
232
+ raw = content.decode("utf-8", errors="replace") if isinstance(content, bytes) else content
233
+ encoded = raw.encode("utf-8")
234
+ if len(encoded) <= self.policy.max_output_bytes:
235
+ return raw, False
236
+ return encoded[:self.policy.max_output_bytes].decode("utf-8", errors="ignore") + "\n... [OUTPUT TRUNCATED BY SANDBOX RESOURCE LIMITER]", True
@@ -0,0 +1,241 @@
1
+ """Secret protection and automated credential scrubbing engine.
2
+
3
+ Redacts API keys, SSH keys, passwords, bearer tokens, and environment secrets
4
+ from logs, terminal outputs, and audit records.
5
+
6
+ Security hardening (ReDoS remediation):
7
+ All regex patterns have been audited for catastrophic backtracking.
8
+ - No nested quantifiers (e.g. (a+)+ patterns).
9
+ - Alternations are anchored or bounded.
10
+ - A per-call timeout guard prevents any single redact() from blocking.
11
+ - Patterns use possessive-equivalent constructs where possible.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import re
18
+ import threading
19
+ import urllib.parse
20
+ from dataclasses import dataclass, field
21
+ from enum import Enum
22
+ from typing import Any
23
+
24
+
25
+ class SecretMode(Enum):
26
+ DENY_ALL = "deny_all"
27
+ ALLOW_EXPLICIT = "allow_explicit"
28
+ ALLOW_ALL = "allow_all"
29
+
30
+
31
+ class SecretEnforcementLevel(Enum):
32
+ STRONGLY_ENFORCED = "strongly_enforced"
33
+ UNSUPPORTED = "unsupported"
34
+
35
+
36
+ @dataclass(frozen=True, slots=True)
37
+ class SecretPolicy:
38
+ """Policy declaring how host secrets should be passed to the sandbox."""
39
+ mode: SecretMode = SecretMode.DENY_ALL
40
+ explicit_env: dict[str, str] = field(default_factory=dict)
41
+
42
+ def to_dict(self) -> dict[str, Any]:
43
+ return {
44
+ "mode": self.mode.value,
45
+ "explicit_env": self.explicit_env,
46
+ }
47
+
48
+ @classmethod
49
+ def from_dict(cls, data: dict[str, Any]) -> SecretPolicy:
50
+ mode_str = str(data.get("mode", "deny_all")).lower()
51
+ try:
52
+ mode = SecretMode(mode_str)
53
+ except ValueError:
54
+ mode = SecretMode.DENY_ALL
55
+ return cls(
56
+ mode=mode,
57
+ explicit_env=data.get("explicit_env", {})
58
+ )
59
+
60
+
61
+ def build_isolated_environment(
62
+ policy: SecretPolicy | None = None,
63
+ extra_env: dict[str, str] | None = None
64
+ ) -> dict[str, str]:
65
+ """Construct an isolated environment preventing wholesale host credential inheritance."""
66
+ # Start with a pristine minimal environment (do NOT merge os.environ by default)
67
+ isolated = {
68
+ "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
69
+ "HOME": "/workspace",
70
+ "TMPDIR": "/tmp",
71
+ }
72
+
73
+ if policy is None:
74
+ policy = SecretPolicy()
75
+
76
+ if policy.mode == SecretMode.ALLOW_ALL:
77
+ # Development override: inherit host env
78
+ isolated = dict(os.environ)
79
+
80
+ elif policy.mode == SecretMode.ALLOW_EXPLICIT:
81
+ isolated.update(policy.explicit_env)
82
+
83
+ # Apply execution-specific non-secret overrides
84
+ if extra_env:
85
+ isolated.update(extra_env)
86
+
87
+ return isolated
88
+
89
+
90
+ class SecretScrubber:
91
+ """Regex & value-matching secret redactor with ReDoS protection."""
92
+
93
+ REDACTED_LABEL = "[REDACTED_SECRET]"
94
+ _REDACT_TIMEOUT_SECONDS = 2.0
95
+
96
+ # -----------------------------------------------------------------------
97
+ # ReDoS-safe patterns: no nested quantifiers, no unbounded alternations.
98
+ # Each pattern is designed for linear-time matching.
99
+ # -----------------------------------------------------------------------
100
+ BUILTIN_PATTERNS: list[re.Pattern[str]] = [ # noqa: RUF012
101
+ # SSH Private Keys — bounded by clear delimiters, lazy inner match
102
+ re.compile(
103
+ r"-----BEGIN [A-Z0-9 ]+ PRIVATE KEY-----"
104
+ r"[\s\S]+?"
105
+ r"-----END [A-Z0-9 ]+ PRIVATE KEY-----"
106
+ ),
107
+ # API Key assignments — simplified: key_name followed by separator then token value
108
+ # Fixed: removed nested quantifier from original pattern.
109
+ # Original had \s*[:=\s]\s* which allowed catastrophic backtracking.
110
+ re.compile(
111
+ r"(?i)(?:api[_-]?key|secret(?:[_-]?key)?|client[_-]?secret|password|"
112
+ r"private[_-]?key|access[_-]?token|auth[_-]?token|bearer|internal[_-]?prompt)"
113
+ r"\s{0,4}[:=]\s{0,4}"
114
+ r"['\"]?"
115
+ r"([a-zA-Z0-9_%\\\-\.=]{8,512})"
116
+ r"['\"]?"
117
+ ),
118
+ re.compile(
119
+ r"(?i)\b(?:pulse[_-])?(?:audit[_-])?"
120
+ r"(?:release[_-])?"
121
+ r"(?:secret|api[_-]?key|internal[_-]?(?:prompt|trace))[a-z0-9_%_\\-]{6,256}\b"
122
+ ),
123
+ # Google API Keys — fixed-length prefix, bounded suffix
124
+ re.compile(r"AIzaSy[A-Za-z0-9_\-]{33}"),
125
+ # OpenAI / Anthropic / Groq / DeepSeek Keys — bounded length
126
+ re.compile(r"sk-[A-Za-z0-9_-]{20,128}"),
127
+ # Graphene keys — bounded length
128
+ re.compile(r"graphene-[A-Za-z0-9_-]{8,128}"),
129
+ # GitHub Personal Access Tokens — fixed structure
130
+ re.compile(r"gh[pousr]_[A-Za-z0-9]{36}"),
131
+ # Slack Tokens — bounded
132
+ re.compile(r"xox[baprs]-[A-Za-z0-9_-]{10,128}"),
133
+ # JWT Tokens — three dot-separated base64url segments, bounded
134
+ re.compile(r"eyJ[A-Za-z0-9_-]{10,512}\.eyJ[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,512}"),
135
+ # AWS Access Key IDs — fixed-length structure
136
+ re.compile(r"(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}"),
137
+ ]
138
+
139
+ def __init__(self, secrets: list[str] | None = None) -> None:
140
+ self._exact_secrets: set[str] = set()
141
+ if secrets:
142
+ for s in secrets:
143
+ self.add_secret(s)
144
+
145
+ def add_secret(self, secret: str) -> None:
146
+ """Register an exact sensitive string to redact."""
147
+ cleaned = secret.strip()
148
+ if len(cleaned) >= 4: # Avoid redacting tiny trivial strings like "yes", "true", "x"
149
+ self._exact_secrets.add(cleaned)
150
+
151
+ def redact(self, text: str) -> str:
152
+ """Scrub all registered secret values and regex patterns from text.
153
+
154
+ Security guarantees:
155
+ - Exact secrets are replaced via str.replace (O(n), no regex).
156
+ - Each regex pattern is bounded-length and ReDoS-safe.
157
+ - Total redaction is guarded by a thread-based timeout.
158
+ """
159
+ if not text:
160
+ return text
161
+
162
+ # Use a thread-based timeout to guard against any unforeseen backtracking
163
+ result_container: list[str] = []
164
+ error_container: list[Exception] = []
165
+
166
+ def _do_redact() -> None:
167
+ try:
168
+ result_container.append(self._redact_impl(text))
169
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
170
+ except Exception as exc: # noqa: BLE001
171
+ error_container.append(exc)
172
+
173
+ worker = threading.Thread(target=_do_redact, daemon=True)
174
+ worker.start()
175
+ worker.join(timeout=self._REDACT_TIMEOUT_SECONDS)
176
+
177
+ if worker.is_alive():
178
+ # Timeout — return generic redaction marker rather than plaintext (fail-closed)
179
+ return "[REDACTED_DUE_TO_TIMEOUT: redaction exceeded time limit]"
180
+
181
+ if error_container:
182
+ # Unexpected error — return text with error marker
183
+ return "[REDACTION_FAILED]"
184
+
185
+ return result_container[0] if result_container else text
186
+
187
+ def contains_explicit_secret(self, text: str) -> bool:
188
+ """Check if the text contains any of the explicitly registered secrets.
189
+
190
+ This only checks for the exact secret strings provided during authorization,
191
+ preventing false positives that might occur with generic regex patterns.
192
+ """
193
+ if not text or not self._exact_secrets:
194
+ return False
195
+
196
+ for secret in self._exact_secrets:
197
+ if secret in text:
198
+ return True
199
+ return False
200
+
201
+ def _redact_impl(self, text: str) -> str:
202
+ """Internal redaction without timeout guard."""
203
+ scrubbed = text
204
+
205
+ # Redact exact registered values and common serialized forms. URLs and
206
+ # JSON strings otherwise provide trivial redaction bypasses.
207
+ for secret in sorted(self._exact_secrets, key=len, reverse=True):
208
+ variants = {
209
+ secret,
210
+ urllib.parse.quote(secret, safe=""),
211
+ urllib.parse.quote_plus(secret, safe=""),
212
+ "".join(
213
+ character
214
+ if character.isalnum()
215
+ else f"%{ord(character):02X}"
216
+ for character in secret
217
+ ),
218
+ secret.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r"),
219
+ }
220
+ for variant in sorted(variants, key=len, reverse=True):
221
+ if variant:
222
+ scrubbed = re.sub(
223
+ re.escape(variant),
224
+ self.REDACTED_LABEL,
225
+ scrubbed,
226
+ flags=re.IGNORECASE,
227
+ )
228
+
229
+ # 2. Redact regex pattern matches
230
+ for pattern in self.BUILTIN_PATTERNS:
231
+ def replace_match(match: re.Match[str]) -> str:
232
+ # If pattern has sub-captures (e.g. key: value), replace only value part
233
+ if match.lastindex and match.lastindex >= 1:
234
+ full = match.group(0)
235
+ val = match.group(1)
236
+ return full.replace(val, self.REDACTED_LABEL)
237
+ return self.REDACTED_LABEL
238
+
239
+ scrubbed = pattern.sub(replace_match, scrubbed)
240
+
241
+ return scrubbed