devloop 0.2.2__tar.gz → 0.3.0__tar.gz

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 (67) hide show
  1. {devloop-0.2.2 → devloop-0.3.0}/PKG-INFO +41 -8
  2. {devloop-0.2.2 → devloop-0.3.0}/README.md +36 -7
  3. {devloop-0.2.2 → devloop-0.3.0}/pyproject.toml +11 -1
  4. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/linter.py +79 -89
  5. devloop-0.3.0/src/devloop/agents/sandbox_helper.py +231 -0
  6. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/type_checker.py +24 -14
  7. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/cli/commands/summary.py +27 -1
  8. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/cli/main.py +120 -10
  9. devloop-0.3.0/src/devloop/cli/pyodide_installer.py +132 -0
  10. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/config.py +146 -103
  11. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/context_store.py +1 -3
  12. devloop-0.3.0/src/devloop/core/operational_health.py +282 -0
  13. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/summary_formatter.py +13 -1
  14. devloop-0.3.0/src/devloop/security/__init__.py +15 -0
  15. devloop-0.3.0/src/devloop/security/audit_logger.py +263 -0
  16. devloop-0.3.0/src/devloop/security/bubblewrap_sandbox.py +352 -0
  17. devloop-0.3.0/src/devloop/security/cgroups_helper.py +253 -0
  18. devloop-0.3.0/src/devloop/security/factory.py +207 -0
  19. devloop-0.3.0/src/devloop/security/no_sandbox.py +146 -0
  20. devloop-0.3.0/src/devloop/security/package.json +19 -0
  21. devloop-0.3.0/src/devloop/security/pyodide_runner.js +290 -0
  22. devloop-0.3.0/src/devloop/security/pyodide_sandbox.py +271 -0
  23. devloop-0.3.0/src/devloop/security/sandbox.py +228 -0
  24. {devloop-0.2.2 → devloop-0.3.0}/LICENSE +0 -0
  25. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/__init__.py +0 -0
  26. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/__init__.py +0 -0
  27. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/agent_health_monitor.py +0 -0
  28. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/ci_monitor.py +0 -0
  29. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/code_rabbit.py +0 -0
  30. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/doc_lifecycle.py +0 -0
  31. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/echo.py +0 -0
  32. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/file_logger.py +0 -0
  33. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/formatter.py +0 -0
  34. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/git_commit_assistant.py +0 -0
  35. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/performance_profiler.py +0 -0
  36. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/security_scanner.py +0 -0
  37. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/snyk.py +0 -0
  38. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/agents/test_runner.py +0 -0
  39. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/cli/__init__.py +0 -0
  40. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/cli/commands/__init__.py +0 -0
  41. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/cli/commands/custom_agents.py +0 -0
  42. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/cli/commands/feedback.py +0 -0
  43. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/cli/main_v1.py +0 -0
  44. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/collectors/__init__.py +0 -0
  45. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/collectors/base.py +0 -0
  46. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/collectors/filesystem.py +0 -0
  47. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/collectors/git.py +0 -0
  48. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/collectors/manager.py +0 -0
  49. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/collectors/process.py +0 -0
  50. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/collectors/system.py +0 -0
  51. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/__init__.py +0 -0
  52. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/agent.py +0 -0
  53. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/agent_template.py +0 -0
  54. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/amp_integration.py +0 -0
  55. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/auto_fix.py +0 -0
  56. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/context.py +0 -0
  57. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/contextual_feedback.py +0 -0
  58. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/custom_agent.py +0 -0
  59. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/debug_trace.py +0 -0
  60. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/event.py +0 -0
  61. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/event_store.py +0 -0
  62. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/feedback.py +0 -0
  63. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/learning.py +0 -0
  64. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/manager.py +0 -0
  65. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/performance.py +0 -0
  66. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/proactive_feedback.py +0 -0
  67. {devloop-0.2.2 → devloop-0.3.0}/src/devloop/core/summary_generator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devloop
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Intelligent background agents for development workflow automation
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -21,6 +21,10 @@ Classifier: Topic :: Software Development :: Build Tools
21
21
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
22
  Classifier: Topic :: Software Development :: Quality Assurance
23
23
  Classifier: Topic :: Utilities
24
+ Provides-Extra: all-optional
25
+ Provides-Extra: ci-monitor
26
+ Provides-Extra: code-rabbit
27
+ Provides-Extra: snyk
24
28
  Requires-Dist: aiofiles (>=23.2,<24.0)
25
29
  Requires-Dist: psutil (>=5.9,<6.0)
26
30
  Requires-Dist: pydantic (>=2.5,<3.0)
@@ -104,9 +108,31 @@ All agents run **non-intrusively in the background**, respecting your workflow.
104
108
  #### Option 1: From PyPI (Recommended)
105
109
 
106
110
  ```bash
111
+ # Basic installation (all default agents)
107
112
  pip install devloop
113
+
114
+ # With optional agents (Snyk security scanning)
115
+ pip install devloop[snyk]
116
+
117
+ # With multiple optional agents
118
+ pip install devloop[snyk,code-rabbit]
119
+
120
+ # With all optional agents
121
+ pip install devloop[all-optional]
108
122
  ```
109
123
 
124
+ **Available extras:**
125
+ - `snyk` — Dependency vulnerability scanning via Snyk CLI
126
+ - `code-rabbit` — AI-powered code analysis
127
+ - `ci-monitor` — CI/CD pipeline monitoring
128
+ - `all-optional` — All of the above
129
+
130
+ **Optional sandbox enhancements:**
131
+ - **Pyodide WASM Sandbox** (cross-platform Python sandboxing)
132
+ - Requires: Node.js 18+ (system dependency)
133
+ - Install: See [Pyodide Installation Guide](./docs/PYODIDE_INSTALLATION.md)
134
+ - Works in POC mode without installation for testing
135
+
110
136
  #### Option 2: From Source
111
137
 
112
138
  ```bash
@@ -127,17 +153,24 @@ poetry shell
127
153
  ### Initialize & Run (Fully Automated)
128
154
 
129
155
  ```bash
130
- # 1. Initialize in your project (handles everything automatically)
156
+ # 1. Initialize in your project (interactive setup)
131
157
  devloop init /path/to/your/project
132
158
  ```
133
159
 
134
- The `init` command automatically:
135
- - ✅ Sets up .devloop directory and configuration
136
- - ✅ Creates AGENTS.md and CODING_RULES.md
137
- - Sets up git hooks (if git repo)
160
+ The `init` command will:
161
+ - ✅ Set up .devloop directory with default agents
162
+ - ✅ Ask which optional agents you want to enable:
163
+ - **Snyk** Scan dependencies for vulnerabilities
164
+ - **Code Rabbit** — AI-powered code analysis
165
+ - **CI Monitor** — Track CI/CD pipeline status
166
+ - ✅ Create configuration file with your selections
167
+ - ✅ Set up git hooks (if git repo)
138
168
  - ✅ Registers Amp integration (if in Amp)
139
- - ✅ Configures commit/push discipline enforcement
140
- - ✅ Verifies everything works
169
+
170
+ ```bash
171
+ # 1a. Alternative: Non-interactive setup (skip optional agent prompts)
172
+ devloop init /path/to/your/project --non-interactive
173
+ ```
141
174
 
142
175
  Then just:
143
176
  ```bash
@@ -70,9 +70,31 @@ All agents run **non-intrusively in the background**, respecting your workflow.
70
70
  #### Option 1: From PyPI (Recommended)
71
71
 
72
72
  ```bash
73
+ # Basic installation (all default agents)
73
74
  pip install devloop
75
+
76
+ # With optional agents (Snyk security scanning)
77
+ pip install devloop[snyk]
78
+
79
+ # With multiple optional agents
80
+ pip install devloop[snyk,code-rabbit]
81
+
82
+ # With all optional agents
83
+ pip install devloop[all-optional]
74
84
  ```
75
85
 
86
+ **Available extras:**
87
+ - `snyk` — Dependency vulnerability scanning via Snyk CLI
88
+ - `code-rabbit` — AI-powered code analysis
89
+ - `ci-monitor` — CI/CD pipeline monitoring
90
+ - `all-optional` — All of the above
91
+
92
+ **Optional sandbox enhancements:**
93
+ - **Pyodide WASM Sandbox** (cross-platform Python sandboxing)
94
+ - Requires: Node.js 18+ (system dependency)
95
+ - Install: See [Pyodide Installation Guide](./docs/PYODIDE_INSTALLATION.md)
96
+ - Works in POC mode without installation for testing
97
+
76
98
  #### Option 2: From Source
77
99
 
78
100
  ```bash
@@ -93,17 +115,24 @@ poetry shell
93
115
  ### Initialize & Run (Fully Automated)
94
116
 
95
117
  ```bash
96
- # 1. Initialize in your project (handles everything automatically)
118
+ # 1. Initialize in your project (interactive setup)
97
119
  devloop init /path/to/your/project
98
120
  ```
99
121
 
100
- The `init` command automatically:
101
- - ✅ Sets up .devloop directory and configuration
102
- - ✅ Creates AGENTS.md and CODING_RULES.md
103
- - Sets up git hooks (if git repo)
122
+ The `init` command will:
123
+ - ✅ Set up .devloop directory with default agents
124
+ - ✅ Ask which optional agents you want to enable:
125
+ - **Snyk** Scan dependencies for vulnerabilities
126
+ - **Code Rabbit** — AI-powered code analysis
127
+ - **CI Monitor** — Track CI/CD pipeline status
128
+ - ✅ Create configuration file with your selections
129
+ - ✅ Set up git hooks (if git repo)
104
130
  - ✅ Registers Amp integration (if in Amp)
105
- - ✅ Configures commit/push discipline enforcement
106
- - ✅ Verifies everything works
131
+
132
+ ```bash
133
+ # 1a. Alternative: Non-interactive setup (skip optional agent prompts)
134
+ devloop init /path/to/your/project --non-interactive
135
+ ```
107
136
 
108
137
  Then just:
109
138
  ```bash
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "devloop"
3
- version = "0.2.2"
3
+ version = "0.3.0"
4
4
  description = "Intelligent background agents for development workflow automation"
5
5
  authors = ["DevLoop Contributors <devloop@example.com>"]
6
6
  license = "MIT"
@@ -33,6 +33,10 @@ classifiers = [
33
33
  "Topic :: Utilities"
34
34
  ]
35
35
  packages = [{include = "devloop", from = "src"}]
36
+ include = [
37
+ "src/devloop/security/package.json",
38
+ "src/devloop/security/pyodide_runner.js",
39
+ ]
36
40
 
37
41
  [tool.poetry.dependencies]
38
42
  python = "^3.11"
@@ -53,6 +57,12 @@ bandit = "^1.7"
53
57
  radon = "^6.0"
54
58
  types-aiofiles = "^23.2"
55
59
 
60
+ [tool.poetry.extras]
61
+ snyk = []
62
+ code-rabbit = []
63
+ ci-monitor = []
64
+ all-optional = []
65
+
56
66
  [tool.poetry.scripts]
57
67
  devloop = "devloop.cli.main:app"
58
68
 
@@ -1,14 +1,15 @@
1
1
  """Linter agent - runs linters on file changes."""
2
2
 
3
- import asyncio
4
3
  import json
5
4
  from datetime import datetime
6
5
  from pathlib import Path
7
6
  from typing import Any, Dict, List, Optional
8
7
 
8
+ from devloop.agents.sandbox_helper import create_agent_sandbox_helper
9
9
  from devloop.core.agent import Agent, AgentResult
10
10
  from devloop.core.context_store import Finding, Severity
11
11
  from devloop.core.event import Event
12
+ from devloop.security.sandbox import CommandNotAllowedError, SandboxTimeoutError
12
13
 
13
14
 
14
15
  class LinterConfig:
@@ -70,6 +71,11 @@ class LinterAgent(Agent):
70
71
  )
71
72
  self.config = LinterConfig(config or {})
72
73
  self._last_run: Dict[str, float] = {} # path -> timestamp for debouncing
74
+ # Initialize sandbox helper for secure command execution
75
+ self.sandbox = create_agent_sandbox_helper(
76
+ agent_name=name,
77
+ agent_type="linter",
78
+ )
73
79
 
74
80
  async def handle(self, event: Event) -> AgentResult:
75
81
  """Handle file change event by running linter."""
@@ -198,45 +204,32 @@ class LinterAgent(Agent):
198
204
  async def _run_ruff(self, path: Path) -> LinterResult:
199
205
  """Run ruff on a Python file."""
200
206
  try:
201
- # Get updated environment with venv bin in PATH
202
- import os
203
-
204
- env = os.environ.copy()
205
- venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
206
- if venv_bin.exists():
207
- env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
208
-
209
- # Check if ruff is installed
210
- check = await asyncio.create_subprocess_exec(
211
- "ruff",
212
- "--version",
213
- stdout=asyncio.subprocess.PIPE,
214
- stderr=asyncio.subprocess.PIPE,
215
- env=env,
216
- )
217
- await check.communicate()
218
-
219
- if check.returncode != 0:
220
- return LinterResult(success=False, error="ruff not installed")
221
-
222
- # Run ruff with JSON output
223
- proc = await asyncio.create_subprocess_exec(
224
- "ruff",
225
- "check",
226
- "--output-format",
227
- "json",
228
- str(path),
229
- stdout=asyncio.subprocess.PIPE,
230
- stderr=asyncio.subprocess.PIPE,
231
- env=env,
232
- )
207
+ # Get venv path
208
+ venv_path = Path(__file__).parent.parent.parent.parent / ".venv"
233
209
 
234
- stdout, stderr = await proc.communicate()
210
+ # Check if ruff is available in the sandbox
211
+ if not await self.sandbox.check_tool_available("ruff"):
212
+ return LinterResult(
213
+ success=False, error="ruff not installed or not allowed"
214
+ )
215
+
216
+ # Run ruff with JSON output in sandbox
217
+ if venv_path.exists():
218
+ result = await self.sandbox.run_sandboxed_with_venv(
219
+ ["ruff", "check", "--output-format", "json", str(path)],
220
+ venv_path=venv_path,
221
+ cwd=path.parent,
222
+ )
223
+ else:
224
+ result = await self.sandbox.run_sandboxed(
225
+ ["ruff", "check", "--output-format", "json", str(path)],
226
+ cwd=path.parent,
227
+ )
235
228
 
236
229
  # ruff returns non-zero if issues found, but that's expected
237
- if stdout:
230
+ if result.stdout:
238
231
  try:
239
- issues = json.loads(stdout.decode())
232
+ issues = json.loads(result.stdout)
240
233
  return LinterResult(success=True, issues=issues)
241
234
  except json.JSONDecodeError:
242
235
  # No issues found or invalid JSON
@@ -245,39 +238,33 @@ class LinterAgent(Agent):
245
238
  # No output = no issues
246
239
  return LinterResult(success=True, issues=[])
247
240
 
248
- except FileNotFoundError:
249
- return LinterResult(success=False, error="ruff command not found")
241
+ except CommandNotAllowedError as e:
242
+ self.logger.error(f"ruff command not allowed in sandbox: {e}")
243
+ return LinterResult(success=False, error="ruff command not allowed")
244
+ except SandboxTimeoutError:
245
+ return LinterResult(success=False, error="ruff execution timeout")
246
+ except Exception as e:
247
+ self.logger.error(f"Error running ruff in sandbox: {e}")
248
+ return LinterResult(success=False, error=str(e))
250
249
 
251
250
  async def _run_eslint(self, path: Path) -> LinterResult:
252
251
  """Run eslint on a JavaScript/TypeScript file."""
253
252
  try:
254
- # Check if eslint is installed
255
- check = await asyncio.create_subprocess_exec(
256
- "eslint",
257
- "--version",
258
- stdout=asyncio.subprocess.PIPE,
259
- stderr=asyncio.subprocess.PIPE,
260
- )
261
- await check.communicate()
262
-
263
- if check.returncode != 0:
264
- return LinterResult(success=False, error="eslint not installed")
265
-
266
- # Run eslint with JSON output
267
- proc = await asyncio.create_subprocess_exec(
268
- "eslint",
269
- "--format",
270
- "json",
271
- str(path),
272
- stdout=asyncio.subprocess.PIPE,
273
- stderr=asyncio.subprocess.PIPE,
274
- )
253
+ # Check if eslint is available in the sandbox
254
+ if not await self.sandbox.check_tool_available("eslint"):
255
+ return LinterResult(
256
+ success=False, error="eslint not installed or not allowed"
257
+ )
275
258
 
276
- stdout, stderr = await proc.communicate()
259
+ # Run eslint with JSON output in sandbox
260
+ result = await self.sandbox.run_sandboxed(
261
+ ["eslint", "--format", "json", str(path)],
262
+ cwd=path.parent,
263
+ )
277
264
 
278
- if stdout:
265
+ if result.stdout:
279
266
  try:
280
- results = json.loads(stdout.decode())
267
+ results = json.loads(result.stdout)
281
268
  # ESLint returns array of file results
282
269
  if results and len(results) > 0:
283
270
  issues = results[0].get("messages", [])
@@ -287,48 +274,51 @@ class LinterAgent(Agent):
287
274
 
288
275
  return LinterResult(success=True, issues=[])
289
276
 
290
- except FileNotFoundError:
291
- return LinterResult(success=False, error="eslint command not found")
277
+ except CommandNotAllowedError as e:
278
+ self.logger.error(f"eslint command not allowed in sandbox: {e}")
279
+ return LinterResult(success=False, error="eslint command not allowed")
280
+ except SandboxTimeoutError:
281
+ return LinterResult(success=False, error="eslint execution timeout")
282
+ except Exception as e:
283
+ self.logger.error(f"Error running eslint in sandbox: {e}")
284
+ return LinterResult(success=False, error=str(e))
292
285
 
293
286
  async def _auto_fix(self, linter: str, path: Path) -> LinterResult:
294
287
  """Attempt to auto-fix issues."""
295
288
  try:
296
- # Get updated environment with venv bin in PATH
297
- import os
298
-
299
- env = os.environ.copy()
300
- venv_bin = Path(__file__).parent.parent.parent.parent / ".venv" / "bin"
301
- if venv_bin.exists():
302
- env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
289
+ # Get venv path
290
+ venv_path = Path(__file__).parent.parent.parent.parent / ".venv"
303
291
 
304
292
  if linter == "ruff":
305
- proc = await asyncio.create_subprocess_exec(
306
- "ruff",
307
- "check",
308
- "--fix",
309
- str(path),
310
- stdout=asyncio.subprocess.PIPE,
311
- stderr=asyncio.subprocess.PIPE,
312
- env=env,
313
- )
314
- await proc.communicate()
293
+ if venv_path.exists():
294
+ await self.sandbox.run_sandboxed_with_venv(
295
+ ["ruff", "check", "--fix", str(path)],
296
+ venv_path=venv_path,
297
+ cwd=path.parent,
298
+ )
299
+ else:
300
+ await self.sandbox.run_sandboxed(
301
+ ["ruff", "check", "--fix", str(path)],
302
+ cwd=path.parent,
303
+ )
315
304
  return LinterResult(success=True)
316
305
 
317
306
  elif linter == "eslint":
318
- proc = await asyncio.create_subprocess_exec(
319
- "eslint",
320
- "--fix",
321
- str(path),
322
- stdout=asyncio.subprocess.PIPE,
323
- stderr=asyncio.subprocess.PIPE,
324
- env=env,
307
+ await self.sandbox.run_sandboxed(
308
+ ["eslint", "--fix", str(path)],
309
+ cwd=path.parent,
325
310
  )
326
- await proc.communicate()
327
311
  return LinterResult(success=True)
328
312
 
329
313
  return LinterResult(success=False, error="Auto-fix not supported")
330
314
 
315
+ except CommandNotAllowedError as e:
316
+ self.logger.error(f"Auto-fix command not allowed in sandbox: {e}")
317
+ return LinterResult(success=False, error="Auto-fix command not allowed")
318
+ except SandboxTimeoutError:
319
+ return LinterResult(success=False, error="Auto-fix execution timeout")
331
320
  except Exception as e:
321
+ self.logger.error(f"Error during auto-fix in sandbox: {e}")
332
322
  return LinterResult(success=False, error=str(e))
333
323
 
334
324
  async def _write_findings_to_context(
@@ -0,0 +1,231 @@
1
+ """Helper utilities for agents to use sandbox execution.
2
+
3
+ This module provides a simple API for agents to execute commands in a sandbox
4
+ without needing to understand the details of sandbox configuration and selection.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import os
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional
13
+
14
+ from devloop.security.factory import create_sandbox
15
+ from devloop.security.sandbox import (
16
+ CommandNotAllowedError,
17
+ SandboxConfig,
18
+ SandboxExecutor,
19
+ SandboxResult,
20
+ SandboxTimeoutError,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class AgentSandboxHelper:
27
+ """Helper class for agents to use sandbox execution.
28
+
29
+ This provides a simple interface for agents to run commands in a sandbox
30
+ without needing to manage sandbox lifecycle or configuration details.
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ agent_name: str,
36
+ agent_type: str,
37
+ config: Optional[SandboxConfig] = None,
38
+ ):
39
+ """Initialize sandbox helper for an agent.
40
+
41
+ Args:
42
+ agent_name: Name of the agent (for logging)
43
+ agent_type: Type of agent (for sandbox selection, e.g., "linter", "formatter")
44
+ config: Optional sandbox configuration (uses defaults if not provided)
45
+ """
46
+ self.agent_name = agent_name
47
+ self.agent_type = agent_type
48
+ self.config = config or SandboxConfig()
49
+ self._sandbox: Optional[SandboxExecutor] = None
50
+ self._sandbox_initialized = False
51
+
52
+ async def _get_sandbox(self) -> SandboxExecutor:
53
+ """Get or create sandbox executor.
54
+
55
+ Returns:
56
+ SandboxExecutor instance
57
+ """
58
+ if self._sandbox is None or not self._sandbox_initialized:
59
+ self._sandbox = await create_sandbox(self.config, self.agent_type)
60
+ self._sandbox_initialized = True
61
+ logger.debug(
62
+ f"Initialized sandbox for {self.agent_name} ({self.agent_type})"
63
+ )
64
+ return self._sandbox
65
+
66
+ async def run_sandboxed(
67
+ self,
68
+ cmd: List[str],
69
+ cwd: Optional[Path] = None,
70
+ env: Optional[Dict[str, str]] = None,
71
+ timeout: Optional[int] = None,
72
+ ) -> SandboxResult:
73
+ """Run a command in the sandbox.
74
+
75
+ This is the main method agents should use to execute commands safely.
76
+
77
+ Args:
78
+ cmd: Command and arguments to execute
79
+ cwd: Working directory (defaults to current directory)
80
+ env: Environment variables (will be filtered to allowed list)
81
+ timeout: Optional timeout override (uses config default if not provided)
82
+
83
+ Returns:
84
+ SandboxResult with stdout, stderr, exit_code, and metrics
85
+
86
+ Raises:
87
+ CommandNotAllowedError: If command is not in whitelist
88
+ SandboxTimeoutError: If execution exceeds timeout
89
+ RuntimeError: If sandbox execution fails
90
+
91
+ Example:
92
+ >>> helper = AgentSandboxHelper("linter", "linter")
93
+ >>> result = await helper.run_sandboxed(["ruff", "check", "file.py"])
94
+ >>> if result.exit_code == 0:
95
+ ... print(result.stdout)
96
+ """
97
+ sandbox = await self._get_sandbox()
98
+
99
+ # Use provided cwd or default to current directory
100
+ if cwd is None:
101
+ cwd = Path.cwd()
102
+
103
+ # Merge environment with current environment if provided
104
+ if env is not None:
105
+ merged_env = os.environ.copy()
106
+ merged_env.update(env)
107
+ env = merged_env
108
+
109
+ # Override timeout if provided
110
+ if timeout is not None:
111
+ original_timeout = self.config.timeout_seconds
112
+ self.config.timeout_seconds = timeout
113
+ try:
114
+ result = await sandbox.execute(cmd, cwd, env)
115
+ finally:
116
+ self.config.timeout_seconds = original_timeout
117
+ else:
118
+ result = await sandbox.execute(cmd, cwd, env)
119
+
120
+ logger.debug(
121
+ f"{self.agent_name}: Executed {cmd[0]} in {result.duration_ms}ms "
122
+ f"(exit={result.exit_code})"
123
+ )
124
+
125
+ return result
126
+
127
+ async def check_tool_available(self, tool_name: str) -> bool:
128
+ """Check if a tool is available and allowed in the sandbox.
129
+
130
+ Args:
131
+ tool_name: Name of the tool to check (e.g., "ruff", "black")
132
+
133
+ Returns:
134
+ True if tool is available and allowed, False otherwise
135
+
136
+ Example:
137
+ >>> helper = AgentSandboxHelper("linter", "linter")
138
+ >>> if await helper.check_tool_available("ruff"):
139
+ ... result = await helper.run_sandboxed(["ruff", "check", "file.py"])
140
+ """
141
+ try:
142
+ result = await self.run_sandboxed(
143
+ [tool_name, "--version"],
144
+ timeout=5, # Quick check
145
+ )
146
+ return result.exit_code == 0
147
+ except (CommandNotAllowedError, RuntimeError):
148
+ return False
149
+ except SandboxTimeoutError:
150
+ # If it times out on --version, something is wrong
151
+ return False
152
+
153
+ async def run_sandboxed_with_venv(
154
+ self,
155
+ cmd: List[str],
156
+ venv_path: Path,
157
+ cwd: Optional[Path] = None,
158
+ env: Optional[Dict[str, str]] = None,
159
+ ) -> SandboxResult:
160
+ """Run a command with a Python virtual environment in PATH.
161
+
162
+ This is a convenience method for agents that need to run Python tools
163
+ installed in a virtual environment.
164
+
165
+ Args:
166
+ cmd: Command and arguments to execute
167
+ venv_path: Path to virtual environment root
168
+ cwd: Working directory
169
+ env: Additional environment variables
170
+
171
+ Returns:
172
+ SandboxResult from execution
173
+
174
+ Example:
175
+ >>> helper = AgentSandboxHelper("linter", "linter")
176
+ >>> venv = Path(__file__).parent.parent.parent.parent / ".venv"
177
+ >>> result = await helper.run_sandboxed_with_venv(
178
+ ... ["ruff", "check", "file.py"],
179
+ ... venv_path=venv
180
+ ... )
181
+ """
182
+ # Add venv bin to PATH
183
+ venv_bin = venv_path / "bin"
184
+ if not venv_bin.exists():
185
+ raise ValueError(f"Virtual environment bin not found: {venv_bin}")
186
+
187
+ # Prepare environment with venv in PATH
188
+ env = env or {}
189
+ env["PATH"] = f"{venv_bin}:{os.environ.get('PATH', '')}"
190
+
191
+ return await self.run_sandboxed(cmd, cwd, env)
192
+
193
+
194
+ def create_agent_sandbox_helper(
195
+ agent_name: str,
196
+ agent_type: str,
197
+ config: Optional[Dict] = None,
198
+ ) -> AgentSandboxHelper:
199
+ """Factory function to create a sandbox helper for an agent.
200
+
201
+ This is the recommended way for agents to create their sandbox helper.
202
+
203
+ Args:
204
+ agent_name: Name of the agent
205
+ agent_type: Type identifier (e.g., "linter", "formatter", "type_checker")
206
+ config: Optional configuration dict (will be converted to SandboxConfig)
207
+
208
+ Returns:
209
+ AgentSandboxHelper instance ready to use
210
+
211
+ Example:
212
+ >>> # In an agent's __init__:
213
+ >>> from devloop.agents.sandbox_helper import create_agent_sandbox_helper
214
+ >>> self.sandbox = create_agent_sandbox_helper(
215
+ ... agent_name=self.name,
216
+ ... agent_type="linter"
217
+ ... )
218
+ >>>
219
+ >>> # In the agent's handle method:
220
+ >>> result = await self.sandbox.run_sandboxed(["ruff", "check", str(path)])
221
+ """
222
+ sandbox_config = None
223
+ if config:
224
+ # Convert config dict to SandboxConfig if provided
225
+ sandbox_config = SandboxConfig(**config)
226
+
227
+ return AgentSandboxHelper(
228
+ agent_name=agent_name,
229
+ agent_type=agent_type,
230
+ config=sandbox_config,
231
+ )