pulse-coding-agent 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,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
|
pulse/sandbox/secrets.py
ADDED
|
@@ -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
|