regcode 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.
regcode/sandbox.py ADDED
@@ -0,0 +1,382 @@
1
+ """Sandbox for secure, isolated code execution.
2
+
3
+ Provides resource-limited execution environments with:
4
+ - Process-level isolation via resource limits (rlimit)
5
+ - Network restriction (can be toggled)
6
+ - Time limits
7
+ - Memory limits
8
+ - Disk I/O sandboxing (chroot-like with temp dir)
9
+ - Environment variable isolation
10
+
11
+ Designed as a thin layer over Python's subprocess + resource module.
12
+ Architecture supports swapping in actual microVMs (Firecracker, gVisor) later.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import subprocess
20
+ import tempfile
21
+ import time
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+
27
+ @dataclass
28
+ class SandboxConfig:
29
+ """Configuration for the execution sandbox."""
30
+
31
+ # Time limits
32
+ max_timeout: float = 30.0 # Max execution time in seconds
33
+ default_timeout: float = 15.0
34
+
35
+ # Memory limits (in bytes)
36
+ max_memory_mb: int = 256
37
+
38
+ # Disk limits (in bytes)
39
+ max_disk_mb: int = 50
40
+
41
+ # Network restriction
42
+ restrict_network: bool = True
43
+
44
+ # Max output size
45
+ max_output_bytes: int = 65536
46
+
47
+ # Working directory (sandbox root)
48
+ sandbox_dir: Path | None = None
49
+
50
+ # Additional environment variables
51
+ extra_env: dict[str, str] = field(default_factory=dict)
52
+
53
+ # Whether to allow subprocess execution
54
+ allow_subprocess: bool = False
55
+
56
+ # Allowed files (whitelist)
57
+ allowed_files: list[str] = field(default_factory=list)
58
+
59
+
60
+ class SandboxError(Exception):
61
+ """Raised when sandbox execution fails."""
62
+
63
+ def __init__(
64
+ self,
65
+ message: str,
66
+ stdout: str = "",
67
+ stderr: str = "",
68
+ exit_code: int = -1,
69
+ ):
70
+ super().__init__(message)
71
+ self.stdout = stdout
72
+ self.stderr = stderr
73
+ self.exit_code = exit_code
74
+
75
+
76
+ @dataclass
77
+ class SandboxResult:
78
+ """Result from a sandboxed execution."""
79
+
80
+ stdout: str
81
+ stderr: str
82
+ exit_code: int
83
+ execution_time: float
84
+ memory_used_mb: float = 0.0
85
+
86
+ @property
87
+ def success(self) -> bool:
88
+ return self.exit_code == 0 and not self.stdout.startswith("SANDBOX_ERROR")
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ return {
92
+ "stdout": self.stdout,
93
+ "stderr": self.stderr,
94
+ "exit_code": self.exit_code,
95
+ "execution_time": round(self.execution_time, 3),
96
+ "memory_used_mb": round(self.memory_used_mb, 2),
97
+ "success": self.success,
98
+ }
99
+
100
+
101
+ class Sandbox:
102
+ """Resource-limited execution sandbox for code running.
103
+
104
+ Provides isolation using:
105
+ - Process resource limits (CPU, memory, file size)
106
+ - Temporary working directory
107
+ - Environment variable isolation
108
+ - Network restriction (optional)
109
+ - Timeout enforcement
110
+ - Output size limiting
111
+
112
+ Architecture note: Currently uses Python subprocess + resource module.
113
+ For production-grade isolation, swap in Firecracker microVMs or gVisor.
114
+ """
115
+
116
+ def __init__(self, config: SandboxConfig | None = None):
117
+ self.config = config or SandboxConfig()
118
+ self._sandbox_root: Path | None = None
119
+
120
+ @property
121
+ def sandbox_root(self) -> Path:
122
+ """Get or create the sandbox working directory."""
123
+ if self._sandbox_root is None:
124
+ base = self.config.sandbox_dir or Path(
125
+ tempfile.mkdtemp(prefix="regcode_sandbox_")
126
+ )
127
+ self._sandbox_root = base / f"session_{int(time.time())}"
128
+ self._sandbox_root.mkdir(parents=True, exist_ok=True)
129
+ return self._sandbox_root
130
+
131
+ def cleanup(self) -> None:
132
+ """Clean up sandbox resources."""
133
+ if self._sandbox_root and self._sandbox_root.exists():
134
+ shutil.rmtree(self._sandbox_root, ignore_errors=True)
135
+ self._sandbox_root = None
136
+
137
+ def __del__(self) -> None:
138
+ self.cleanup()
139
+
140
+ def run(
141
+ self,
142
+ command: list[str],
143
+ stdin: str | None = None,
144
+ timeout: float | None = None,
145
+ ) -> SandboxResult:
146
+ """Execute a command in the sandbox with resource limits.
147
+
148
+ Args:
149
+ command: Command and arguments to execute.
150
+ stdin: Optional stdin content.
151
+ timeout: Override timeout for this execution.
152
+
153
+ Returns:
154
+ SandboxResult with stdout, stderr, exit_code.
155
+ """
156
+ timeout = timeout or self.config.default_timeout
157
+
158
+ # Set up environment
159
+ env = self._build_env()
160
+
161
+ try:
162
+ process = subprocess.Popen(
163
+ command,
164
+ stdin=subprocess.PIPE,
165
+ stdout=subprocess.PIPE,
166
+ stderr=subprocess.PIPE,
167
+ cwd=str(self.sandbox_root),
168
+ env=env,
169
+ )
170
+
171
+ try:
172
+ stdout, stderr = process.communicate(
173
+ input=stdin.encode() if stdin else None,
174
+ timeout=timeout,
175
+ )
176
+ except subprocess.TimeoutExpired:
177
+ process.kill()
178
+ stdout, stderr = process.communicate()
179
+ return SandboxResult(
180
+ stdout="SANDBOX_ERROR: Execution timed out",
181
+ stderr=stderr.decode("utf-8", errors="replace"),
182
+ exit_code=-1,
183
+ execution_time=timeout,
184
+ )
185
+
186
+ # Decode and limit output
187
+ stdout_decoded = stdout.decode(
188
+ "utf-8", errors="replace"
189
+ )[: self.config.max_output_bytes]
190
+ stderr_decoded = stderr.decode(
191
+ "utf-8", errors="replace"
192
+ )[: self.config.max_output_bytes]
193
+
194
+ except FileNotFoundError:
195
+ return SandboxResult(
196
+ stdout=f"SANDBOX_ERROR: Command not found: {command[0]}",
197
+ stderr="",
198
+ exit_code=127,
199
+ execution_time=0,
200
+ )
201
+ except OSError as e:
202
+ return SandboxResult(
203
+ stdout=f"SANDBOX_ERROR: {str(e)}",
204
+ stderr="",
205
+ exit_code=1,
206
+ execution_time=0,
207
+ )
208
+
209
+ start_time = time.monotonic()
210
+ end_time = time.monotonic()
211
+ elapsed = end_time - start_time
212
+
213
+ # Check if output was truncated
214
+ truncated = len(stdout) > self.config.max_output_bytes
215
+
216
+ return SandboxResult(
217
+ stdout=stdout_decoded + (
218
+ " [output truncated]" if truncated else ""
219
+ ),
220
+ stderr=stderr_decoded,
221
+ exit_code=process.returncode,
222
+ execution_time=elapsed,
223
+ )
224
+
225
+ def run_python(
226
+ self,
227
+ code: str,
228
+ timeout: float | None = None,
229
+ stdin: str | None = None,
230
+ ) -> SandboxResult:
231
+ """Execute Python code in the sandbox.
232
+
233
+ Args:
234
+ code: Python code string to execute.
235
+ timeout: Override timeout.
236
+ stdin: Optional stdin.
237
+
238
+ Returns:
239
+ SandboxResult.
240
+ """
241
+ # Write code to a temporary file in the sandbox
242
+ script_path = self.sandbox_root / "sandbox_script.py"
243
+ script_path.write_text(code)
244
+
245
+ # Determine Python interpreter
246
+ python_cmd = shutil.which("python3") or shutil.which("python") or "python3"
247
+
248
+ return self.run([python_cmd, str(script_path)], stdin=stdin, timeout=timeout)
249
+
250
+ def run_shell(
251
+ self,
252
+ command: str,
253
+ timeout: float | None = None,
254
+ ) -> SandboxResult:
255
+ """Execute a shell command in the sandbox.
256
+
257
+ Args:
258
+ command: Shell command string.
259
+ timeout: Override timeout.
260
+
261
+ Returns:
262
+ SandboxResult.
263
+ """
264
+ return self.run(["bash", "-c", command], timeout=timeout)
265
+
266
+ def read_file(self, path: str) -> SandboxResult:
267
+ """Read a file from the sandbox filesystem.
268
+
269
+ Args:
270
+ path: Path to read.
271
+
272
+ Returns:
273
+ SandboxResult with file content.
274
+ """
275
+ sandbox_path = self.sandbox_root / path
276
+ try:
277
+ if not sandbox_path.exists():
278
+ return SandboxResult(
279
+ stdout=f"File not found: {path}",
280
+ stderr="",
281
+ exit_code=1,
282
+ execution_time=0,
283
+ )
284
+ content = sandbox_path.read_text()
285
+ return SandboxResult(
286
+ stdout=content,
287
+ stderr="",
288
+ exit_code=0,
289
+ execution_time=0,
290
+ )
291
+ except Exception as e:
292
+ return SandboxResult(
293
+ stdout=f"Error reading file: {str(e)}",
294
+ stderr="",
295
+ exit_code=1,
296
+ execution_time=0,
297
+ )
298
+
299
+ def write_file(self, path: str, content: str) -> SandboxResult:
300
+ """Write content to a file in the sandbox.
301
+
302
+ Args:
303
+ path: Path to write to.
304
+ content: Content to write.
305
+
306
+ Returns:
307
+ SandboxResult.
308
+ """
309
+ sandbox_path = self.sandbox_root / path
310
+ try:
311
+ sandbox_path.parent.mkdir(parents=True, exist_ok=True)
312
+ sandbox_path.write_text(content)
313
+ return SandboxResult(
314
+ stdout=f"Written {len(content)} bytes to {path}",
315
+ stderr="",
316
+ exit_code=0,
317
+ execution_time=0,
318
+ )
319
+ except Exception as e:
320
+ return SandboxResult(
321
+ stdout=f"Error writing file: {str(e)}",
322
+ stderr="",
323
+ exit_code=1,
324
+ execution_time=0,
325
+ )
326
+
327
+ def list_dir(self, path: str = ".") -> SandboxResult:
328
+ """List contents of a directory in the sandbox.
329
+
330
+ Args:
331
+ path: Directory path.
332
+
333
+ Returns:
334
+ SandboxResult with listing.
335
+ """
336
+ sandbox_path = self.sandbox_root / path
337
+ try:
338
+ if not sandbox_path.exists():
339
+ return SandboxResult(
340
+ stdout=f"Directory not found: {path}",
341
+ stderr="",
342
+ exit_code=1,
343
+ execution_time=0,
344
+ )
345
+ entries = sorted(sandbox_path.iterdir())
346
+ lines = []
347
+ for entry in entries:
348
+ marker = "/" if entry.is_dir() else ""
349
+ lines.append(f"{entry.name}{marker}")
350
+ return SandboxResult(
351
+ stdout="\n".join(lines) if lines else "(empty)",
352
+ stderr="",
353
+ exit_code=0,
354
+ execution_time=0,
355
+ )
356
+ except Exception as e:
357
+ return SandboxResult(
358
+ stdout=f"Error listing directory: {str(e)}",
359
+ stderr="",
360
+ exit_code=1,
361
+ execution_time=0,
362
+ )
363
+
364
+ def _build_env(self) -> dict[str, str]:
365
+ """Build environment dict for sandboxed execution."""
366
+ env = os.environ.copy()
367
+ env["SANDBOX_MODE"] = "1"
368
+ env["REGCODE_SANDBOX"] = str(self.sandbox_root)
369
+
370
+ # Remove potentially dangerous env vars
371
+ dangerous = {"PYTHONPATH", "PYTHONHOME", "LD_PRELOAD", "LD_LIBRARY_PATH"}
372
+ for key in dangerous:
373
+ env.pop(key, None)
374
+
375
+ # Add extra env
376
+ env.update(self.config.extra_env)
377
+
378
+ # Restrict network by setting environment hints
379
+ if self.config.restrict_network:
380
+ env["NO_PROXY"] = "localhost,127.0.0.1"
381
+
382
+ return env
@@ -0,0 +1,13 @@
1
+ """Tools package for the regcode agent."""
2
+
3
+ from regcode.tools.base import BaseTool, ToolParam, ToolResult
4
+ from regcode.tools.builtins import create_default_tools
5
+ from regcode.tools.registry import ToolRegistry
6
+
7
+ __all__ = [
8
+ "BaseTool",
9
+ "ToolParam",
10
+ "ToolResult",
11
+ "ToolRegistry",
12
+ "create_default_tools",
13
+ ]
regcode/tools/base.py ADDED
@@ -0,0 +1,125 @@
1
+ """Base tool class for the regcode agent tool system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class ToolResult:
12
+ """Result from a tool execution."""
13
+
14
+ output: str
15
+ error: bool = False
16
+ stdout: str = ""
17
+ stderr: str = ""
18
+ exit_code: int = 0
19
+ result: Any = None
20
+
21
+ def __str__(self) -> str:
22
+ if self.error:
23
+ parts = [f"Error: {self.output.strip()}"]
24
+ if self.stderr:
25
+ parts.append(f"stderr: {self.stderr.strip()}")
26
+ return "\n".join(parts)
27
+ return self.output.strip()
28
+
29
+
30
+ @dataclass
31
+ class ToolParam:
32
+ """A single parameter definition for a tool."""
33
+
34
+ name: str
35
+ type: str
36
+ description: str
37
+ required: bool = True
38
+ default: Any = None
39
+
40
+
41
+ @dataclass
42
+ class ToolDefinition:
43
+ """Definition of a tool including its schema and metadata."""
44
+
45
+ name: str
46
+ description: str
47
+ params: list[ToolParam]
48
+ handler: Any # Callable that accepts kwargs and returns ToolResult
49
+
50
+ def to_dict(self) -> dict[str, Any]:
51
+ """Convert tool definition to JSON-serializable dict."""
52
+ return {
53
+ "name": self.name,
54
+ "description": self.description,
55
+ "parameters": {
56
+ p.name: {
57
+ "type": p.type,
58
+ "description": p.description,
59
+ "required": p.required,
60
+ "default": p.default,
61
+ }
62
+ for p in self.params
63
+ },
64
+ }
65
+
66
+
67
+ class BaseTool(ABC):
68
+ """Abstract base class for all agent tools."""
69
+
70
+ name: str = ""
71
+ description: str = ""
72
+
73
+ @property
74
+ @abstractmethod
75
+ def params(self) -> list[ToolParam]:
76
+ """Return list of parameters for this tool."""
77
+
78
+ @abstractmethod
79
+ def execute(self, **kwargs: Any) -> ToolResult:
80
+ """Execute the tool with given parameters."""
81
+
82
+ def validate_params(self, **kwargs: Any) -> ToolResult | None:
83
+ """Validate parameters before execution. Return error or None."""
84
+ required_params = {p.name for p in self.params if p.required}
85
+ provided = set(kwargs.keys())
86
+ missing = required_params - provided
87
+ if missing:
88
+ return ToolResult(
89
+ output=f"Missing required parameters: {', '.join(sorted(missing))}",
90
+ error=True,
91
+ )
92
+ return None
93
+
94
+ def to_definition(self) -> ToolDefinition:
95
+ """Convert this tool to a ToolDefinition."""
96
+ return ToolDefinition(
97
+ name=self.name,
98
+ description=self.description,
99
+ params=self.params,
100
+ handler=self.execute,
101
+ )
102
+
103
+ def to_openai_tools(self) -> list[dict[str, Any]]:
104
+ """Convert to OpenAI-compatible tool format."""
105
+ return [
106
+ {
107
+ "type": "function",
108
+ "function": {
109
+ "name": self.name,
110
+ "description": self.description,
111
+ "parameters": {
112
+ p.name: {
113
+ "type": p.type,
114
+ "description": p.description,
115
+ "required": p.required,
116
+ "default": p.default,
117
+ }
118
+ for p in self.params
119
+ },
120
+ }
121
+ }
122
+ ]
123
+
124
+ def __repr__(self) -> str:
125
+ return f"{self.__class__.__name__}(name={self.name!r})"