devloop 0.2.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 (55) hide show
  1. devloop/__init__.py +3 -0
  2. devloop/agents/__init__.py +33 -0
  3. devloop/agents/agent_health_monitor.py +105 -0
  4. devloop/agents/ci_monitor.py +237 -0
  5. devloop/agents/code_rabbit.py +248 -0
  6. devloop/agents/doc_lifecycle.py +374 -0
  7. devloop/agents/echo.py +24 -0
  8. devloop/agents/file_logger.py +46 -0
  9. devloop/agents/formatter.py +511 -0
  10. devloop/agents/git_commit_assistant.py +421 -0
  11. devloop/agents/linter.py +399 -0
  12. devloop/agents/performance_profiler.py +284 -0
  13. devloop/agents/security_scanner.py +322 -0
  14. devloop/agents/snyk.py +292 -0
  15. devloop/agents/test_runner.py +484 -0
  16. devloop/agents/type_checker.py +242 -0
  17. devloop/cli/__init__.py +1 -0
  18. devloop/cli/commands/__init__.py +1 -0
  19. devloop/cli/commands/custom_agents.py +144 -0
  20. devloop/cli/commands/feedback.py +161 -0
  21. devloop/cli/commands/summary.py +50 -0
  22. devloop/cli/main.py +430 -0
  23. devloop/cli/main_v1.py +144 -0
  24. devloop/collectors/__init__.py +17 -0
  25. devloop/collectors/base.py +55 -0
  26. devloop/collectors/filesystem.py +126 -0
  27. devloop/collectors/git.py +171 -0
  28. devloop/collectors/manager.py +159 -0
  29. devloop/collectors/process.py +221 -0
  30. devloop/collectors/system.py +195 -0
  31. devloop/core/__init__.py +21 -0
  32. devloop/core/agent.py +206 -0
  33. devloop/core/agent_template.py +498 -0
  34. devloop/core/amp_integration.py +166 -0
  35. devloop/core/auto_fix.py +224 -0
  36. devloop/core/config.py +272 -0
  37. devloop/core/context.py +0 -0
  38. devloop/core/context_store.py +530 -0
  39. devloop/core/contextual_feedback.py +311 -0
  40. devloop/core/custom_agent.py +439 -0
  41. devloop/core/debug_trace.py +289 -0
  42. devloop/core/event.py +105 -0
  43. devloop/core/event_store.py +316 -0
  44. devloop/core/feedback.py +311 -0
  45. devloop/core/learning.py +351 -0
  46. devloop/core/manager.py +219 -0
  47. devloop/core/performance.py +433 -0
  48. devloop/core/proactive_feedback.py +302 -0
  49. devloop/core/summary_formatter.py +159 -0
  50. devloop/core/summary_generator.py +275 -0
  51. devloop-0.2.0.dist-info/METADATA +705 -0
  52. devloop-0.2.0.dist-info/RECORD +55 -0
  53. devloop-0.2.0.dist-info/WHEEL +4 -0
  54. devloop-0.2.0.dist-info/entry_points.txt +3 -0
  55. devloop-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,322 @@
1
+ #!/usr/bin/env python3
2
+ """Security Scanner Agent - Detects security vulnerabilities in code."""
3
+
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ import sys
8
+ from datetime import datetime, UTC
9
+ from pathlib import Path
10
+ from typing import Dict, Any, List, Optional
11
+ from dataclasses import dataclass
12
+
13
+ from ..core.agent import Agent, AgentResult
14
+ from ..core.context_store import (
15
+ context_store,
16
+ Finding,
17
+ Severity,
18
+ ScopeType,
19
+ )
20
+ from ..core.event import Event
21
+
22
+
23
+ @dataclass
24
+ class SecurityConfig:
25
+ """Configuration for security scanning."""
26
+
27
+ enabled_tools: Optional[List[str]] = None # ["bandit", "safety", "trivy"]
28
+ severity_threshold: str = "medium" # low, medium, high
29
+ confidence_threshold: str = "medium" # low, medium, high
30
+ exclude_patterns: Optional[List[str]] = None
31
+ max_issues: int = 50
32
+
33
+ def __post_init__(self):
34
+ if self.enabled_tools is None:
35
+ self.enabled_tools = ["bandit"]
36
+ if self.exclude_patterns is None:
37
+ self.exclude_patterns = ["test*", "*_test.py", "*/tests/*"]
38
+
39
+
40
+ class SecurityResult:
41
+ """Security scan result."""
42
+
43
+ def __init__(
44
+ self, tool: str, issues: List[Dict[str, Any]], errors: List[str] = None
45
+ ):
46
+ self.tool = tool
47
+ self.issues = issues
48
+ self.errors = errors or []
49
+ self.timestamp = None
50
+
51
+ def to_dict(self) -> Dict[str, Any]:
52
+ return {
53
+ "tool": self.tool,
54
+ "issues_found": len(self.issues),
55
+ "issues": self.issues,
56
+ "errors": self.errors,
57
+ "severity_breakdown": self._get_severity_breakdown(),
58
+ "confidence_breakdown": self._get_confidence_breakdown(),
59
+ }
60
+
61
+ def _get_severity_breakdown(self) -> Dict[str, int]:
62
+ breakdown = {"low": 0, "medium": 0, "high": 0, "unknown": 0}
63
+ for issue in self.issues:
64
+ severity = issue.get("severity", "unknown").lower()
65
+ breakdown[severity] = breakdown.get(severity, 0) + 1
66
+ return breakdown
67
+
68
+ def _get_confidence_breakdown(self) -> Dict[str, int]:
69
+ breakdown = {"low": 0, "medium": 0, "high": 0, "unknown": 0}
70
+ for issue in self.issues:
71
+ confidence = issue.get("confidence", "unknown").lower()
72
+ breakdown[confidence] = breakdown.get(confidence, 0) + 1
73
+ return breakdown
74
+
75
+
76
+ class SecurityScannerAgent(Agent):
77
+ """Agent for scanning code for security vulnerabilities."""
78
+
79
+ def __init__(self, config: Dict[str, Any], event_bus):
80
+ super().__init__(
81
+ "security-scanner", ["file:modified", "file:created"], event_bus
82
+ )
83
+ self.config = SecurityConfig(**config)
84
+ self.logger = logging.getLogger(f"agent.{self.name}")
85
+
86
+ async def handle(self, event: Event) -> AgentResult:
87
+ """Handle file change events by scanning for security issues."""
88
+ try:
89
+ file_path = event.payload.get("path")
90
+ if not file_path:
91
+ return AgentResult(
92
+ agent_name=self.name,
93
+ success=False,
94
+ duration=0.0,
95
+ message="No file path in event",
96
+ )
97
+
98
+ path = Path(file_path)
99
+ if not path.exists():
100
+ return AgentResult(
101
+ agent_name=self.name,
102
+ success=False,
103
+ duration=0.0,
104
+ message=f"File does not exist: {file_path}",
105
+ )
106
+
107
+ # Only scan Python files for now
108
+ if path.suffix != ".py":
109
+ return AgentResult(
110
+ agent_name=self.name,
111
+ success=True,
112
+ duration=0.0,
113
+ message=f"Skipped non-Python file: {file_path}",
114
+ )
115
+
116
+ # Check if file matches exclude patterns
117
+ if self._should_exclude_file(str(path)):
118
+ return AgentResult(
119
+ agent_name=self.name,
120
+ success=True,
121
+ duration=0.0,
122
+ message=f"Excluded file: {file_path}",
123
+ )
124
+
125
+ # Run security scan
126
+ results = await self._run_security_scan(path)
127
+
128
+ agent_result = AgentResult(
129
+ agent_name=self.name,
130
+ success=True,
131
+ duration=0.0, # Would be calculated in real implementation
132
+ message=f"Scanned {file_path} with {results.tool}",
133
+ data={
134
+ "file": str(path),
135
+ "tool": results.tool,
136
+ "issues_found": len(results.issues),
137
+ "issues": results.issues,
138
+ "severity_breakdown": results._get_severity_breakdown(),
139
+ "confidence_breakdown": results._get_confidence_breakdown(),
140
+ "errors": results.errors,
141
+ },
142
+ )
143
+
144
+ # Write to context store for Claude Code integration
145
+ await self._write_findings_to_context(path, results.issues)
146
+
147
+ return agent_result
148
+ except Exception as e:
149
+ self.logger.error(
150
+ f"Error handling security scan for {event.payload.get('path', 'unknown')}: {e}",
151
+ exc_info=True,
152
+ )
153
+ return AgentResult(
154
+ agent_name=self.name,
155
+ success=False,
156
+ duration=0.0,
157
+ message=f"Security scan failed: {str(e)}",
158
+ error=str(e),
159
+ )
160
+
161
+ async def _write_findings_to_context(
162
+ self, path: Path, issues: List[Dict[str, Any]]
163
+ ) -> None:
164
+ """Write security issues to the context store."""
165
+ # Map bandit severity to our Severity enum
166
+ severity_map = {
167
+ "high": Severity.ERROR,
168
+ "medium": Severity.WARNING,
169
+ "low": Severity.INFO,
170
+ }
171
+
172
+ for idx, issue in enumerate(issues):
173
+ issue_severity = issue.get("severity", "medium").lower()
174
+ severity = severity_map.get(issue_severity, Severity.WARNING)
175
+
176
+ # Security issues are always blocking if high severity
177
+ blocking = issue_severity == "high"
178
+
179
+ finding = Finding(
180
+ id=f"security_{path.name}_{issue.get('line_number', 0)}_{idx}",
181
+ agent="security-scanner",
182
+ timestamp=datetime.now(UTC).isoformat() + "Z",
183
+ file=str(path),
184
+ line=issue.get("line_number"),
185
+ severity=severity,
186
+ blocking=blocking,
187
+ category=f"security_{issue.get('test_id', 'unknown')}",
188
+ message=issue.get("text", "Security issue detected"),
189
+ scope_type=ScopeType.CURRENT_FILE,
190
+ caused_by_recent_change=True,
191
+ is_new=True,
192
+ )
193
+ await context_store.add_finding(finding)
194
+
195
+ def _should_exclude_file(self, file_path: str) -> bool:
196
+ """Check if file should be excluded from scanning."""
197
+ if not self.config.exclude_patterns:
198
+ return False
199
+ for pattern in self.config.exclude_patterns:
200
+ if pattern.startswith("*") and pattern.endswith("*"):
201
+ if pattern[1:-1] in file_path:
202
+ return True
203
+ elif pattern.startswith("*"):
204
+ if file_path.endswith(pattern[1:]):
205
+ return True
206
+ elif pattern.endswith("*"):
207
+ if file_path.startswith(pattern[:-1]):
208
+ return True
209
+ elif pattern == file_path:
210
+ return True
211
+ return False
212
+
213
+ async def _run_security_scan(self, file_path: Path) -> SecurityResult:
214
+ """Run security scanning tools."""
215
+ try:
216
+ results = []
217
+
218
+ # Try bandit first (most common Python security scanner)
219
+ if self.config.enabled_tools and "bandit" in self.config.enabled_tools:
220
+ bandit_result = await self._run_bandit(file_path)
221
+ if bandit_result:
222
+ results.append(bandit_result)
223
+
224
+ # If no results from primary tools, return empty
225
+ if results:
226
+ return results[0] # Return first successful result
227
+
228
+ return SecurityResult("none", [], ["No security scanning tools available"])
229
+ except Exception as e:
230
+ self.logger.error(
231
+ f"Error running security scan on {file_path}: {e}", exc_info=True
232
+ )
233
+ return SecurityResult("error", [], [f"Security scan error: {str(e)}"])
234
+
235
+ async def _run_bandit(self, file_path: Path) -> Optional[SecurityResult]:
236
+ """Run Bandit security scanner."""
237
+ try:
238
+ # Check if bandit is available
239
+ import subprocess # nosec B404 - Required for running security analysis tools
240
+
241
+ result = subprocess.run(
242
+ [sys.executable, "-c", "import bandit"], capture_output=True, text=True
243
+ ) # nosec B603 - Running trusted system Python with safe arguments
244
+ if result.returncode != 0:
245
+ return SecurityResult(
246
+ "bandit", [], ["Bandit not installed - run: pip install bandit"]
247
+ )
248
+
249
+ cmd = [
250
+ sys.executable,
251
+ "-m",
252
+ "bandit",
253
+ "-f",
254
+ "json",
255
+ "-r",
256
+ str(file_path),
257
+ "--severity-level",
258
+ self.config.severity_threshold,
259
+ "--confidence-level",
260
+ self.config.confidence_threshold,
261
+ ]
262
+ if self.config.exclude_patterns:
263
+ cmd.extend(["-x", ",".join(self.config.exclude_patterns)])
264
+
265
+ # Run bandit in subprocess
266
+ process = await asyncio.create_subprocess_exec(
267
+ *cmd,
268
+ stdout=asyncio.subprocess.PIPE,
269
+ stderr=asyncio.subprocess.PIPE,
270
+ cwd=file_path.parent,
271
+ )
272
+
273
+ stdout, stderr = await process.communicate()
274
+
275
+ if process.returncode == 0:
276
+ # Parse JSON output
277
+ try:
278
+ data = json.loads(stdout.decode())
279
+ issues = []
280
+
281
+ # Extract issues from bandit output
282
+ for result in data.get("results", []):
283
+ filename = result.get("filename", "")
284
+ if str(file_path) in filename or filename.endswith(
285
+ str(file_path)
286
+ ):
287
+ for issue in result.get("issues", []):
288
+ issues.append(
289
+ {
290
+ "code": issue.get("code", ""),
291
+ "filename": issue.get("filename", ""),
292
+ "line_number": issue.get("line_number", 0),
293
+ "line_range": issue.get("line_range", []),
294
+ "test_id": issue.get("test_id", ""),
295
+ "test_name": issue.get("test_name", ""),
296
+ "severity": issue.get(
297
+ "issue_severity", "unknown"
298
+ ),
299
+ "confidence": issue.get(
300
+ "issue_confidence", "unknown"
301
+ ),
302
+ "text": issue.get("issue_text", ""),
303
+ "cwe": issue.get("cwe", {}),
304
+ "more_info": issue.get("more_info", ""),
305
+ }
306
+ )
307
+
308
+ return SecurityResult("bandit", issues[: self.config.max_issues])
309
+
310
+ except json.JSONDecodeError:
311
+ return SecurityResult(
312
+ "bandit",
313
+ [],
314
+ [f"Failed to parse bandit output: {stdout.decode()[:200]}"],
315
+ )
316
+
317
+ else:
318
+ error_msg = stderr.decode().strip()
319
+ return SecurityResult("bandit", [], [f"Bandit failed: {error_msg}"])
320
+
321
+ except Exception as e:
322
+ return SecurityResult("bandit", [], [f"Bandit execution error: {str(e)}"])
devloop/agents/snyk.py ADDED
@@ -0,0 +1,292 @@
1
+ """Snyk agent - integrates Snyk CLI for vulnerability scanning."""
2
+
3
+ import asyncio
4
+ import json
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List
8
+
9
+ from devloop.core.agent import Agent, AgentResult
10
+ from devloop.core.context_store import Finding, Severity
11
+ from devloop.core.event import Event
12
+
13
+
14
+ class SnykConfig:
15
+ """Configuration for SnykAgent."""
16
+
17
+ def __init__(self, config: Dict[str, Any]):
18
+ self.enabled = config.get("enabled", True)
19
+ self.api_token = config.get("apiToken")
20
+ self.severity = config.get("severity", "high")
21
+ self.file_patterns = config.get(
22
+ "filePatterns",
23
+ ["**/package.json", "**/requirements.txt", "**/Gemfile", "**/pom.xml"],
24
+ )
25
+ self.debounce = config.get("debounce", 500) # ms
26
+
27
+
28
+ class SnykResult:
29
+ """Result from running Snyk scan."""
30
+
31
+ def __init__(
32
+ self,
33
+ success: bool,
34
+ vulnerabilities: List[Dict[str, Any]] | None = None,
35
+ error: str | None = None,
36
+ ):
37
+ self.success = success
38
+ self.vulnerabilities = vulnerabilities or []
39
+ self.error = error
40
+
41
+ @property
42
+ def has_vulnerabilities(self) -> bool:
43
+ """Check if there are any vulnerabilities."""
44
+ return len(self.vulnerabilities) > 0
45
+
46
+ @property
47
+ def vulnerability_count(self) -> int:
48
+ """Get number of vulnerabilities."""
49
+ return len(self.vulnerabilities)
50
+
51
+ @property
52
+ def critical_count(self) -> int:
53
+ """Get count of critical vulnerabilities."""
54
+ return sum(
55
+ 1
56
+ for v in self.vulnerabilities
57
+ if v.get("severity", "").lower() == "critical"
58
+ )
59
+
60
+ @property
61
+ def high_count(self) -> int:
62
+ """Get count of high severity vulnerabilities."""
63
+ return sum(
64
+ 1 for v in self.vulnerabilities if v.get("severity", "").lower() == "high"
65
+ )
66
+
67
+
68
+ class SnykAgent(Agent):
69
+ """Agent that runs Snyk security scanning on dependency files."""
70
+
71
+ def __init__(
72
+ self,
73
+ name: str,
74
+ triggers: List[str],
75
+ event_bus,
76
+ config: Dict[str, Any] | None = None,
77
+ feedback_api=None,
78
+ performance_monitor=None,
79
+ ):
80
+ super().__init__(
81
+ name,
82
+ triggers,
83
+ event_bus,
84
+ feedback_api=feedback_api,
85
+ performance_monitor=performance_monitor,
86
+ )
87
+ self.config = SnykConfig(config or {})
88
+ self._last_run: Dict[str, float] = {} # path -> timestamp for debouncing
89
+
90
+ async def handle(self, event: Event) -> AgentResult:
91
+ """Handle file change event by running Snyk scan."""
92
+ # Extract file path
93
+ file_path = event.payload.get("path")
94
+ if not file_path:
95
+ return AgentResult(
96
+ agent_name=self.name,
97
+ success=True,
98
+ duration=0,
99
+ message="No file path in event",
100
+ )
101
+
102
+ path = Path(file_path)
103
+
104
+ # Check if file should be scanned
105
+ if not self._should_scan(path):
106
+ return AgentResult(
107
+ agent_name=self.name,
108
+ success=True,
109
+ duration=0,
110
+ message=f"Skipped {path.name} (not a dependency file)",
111
+ )
112
+
113
+ # Run Snyk scan
114
+ result = await self._run_snyk(path)
115
+
116
+ # Build result message
117
+ if result.error:
118
+ message = f"Snyk error on {path.name}: {result.error}"
119
+ success = False
120
+ elif result.has_vulnerabilities:
121
+ critical = result.critical_count
122
+ high = result.high_count
123
+ message = (
124
+ f"Found {result.vulnerability_count} vulnerability(ies) in {path.name}"
125
+ )
126
+ if critical > 0:
127
+ message += f" ({critical} critical, {high} high)"
128
+ success = True
129
+ else:
130
+ message = f"No vulnerabilities in {path.name}"
131
+ success = True
132
+
133
+ agent_result = AgentResult(
134
+ agent_name=self.name,
135
+ success=success,
136
+ duration=0,
137
+ message=message,
138
+ data={
139
+ "file": str(path),
140
+ "tool": "snyk",
141
+ "vulnerabilities": result.vulnerabilities,
142
+ "vulnerability_count": result.vulnerability_count,
143
+ "critical_count": result.critical_count,
144
+ "high_count": result.high_count,
145
+ },
146
+ )
147
+
148
+ # Write findings to context store
149
+ await self._write_findings_to_context(path, result)
150
+
151
+ return agent_result
152
+
153
+ def _should_scan(self, path: Path) -> bool:
154
+ """Check if file should be scanned based on patterns."""
155
+ # Skip if file doesn't exist
156
+ if not path.exists():
157
+ return False
158
+
159
+ # Only scan dependency files
160
+ file_name = path.name
161
+ for pattern in self.config.file_patterns:
162
+ # Simple pattern matching for common dependency files
163
+ if pattern.startswith("**/"):
164
+ pattern = pattern[3:]
165
+ if file_name == pattern:
166
+ return True
167
+
168
+ return False
169
+
170
+ async def _run_snyk(self, path: Path) -> SnykResult:
171
+ """Run Snyk scan on a dependency file."""
172
+ try:
173
+ # Check if snyk CLI is installed
174
+ check = await asyncio.create_subprocess_exec(
175
+ "snyk",
176
+ "--version",
177
+ stdout=asyncio.subprocess.PIPE,
178
+ stderr=asyncio.subprocess.PIPE,
179
+ )
180
+ await check.communicate()
181
+
182
+ if check.returncode != 0:
183
+ return SnykResult(
184
+ success=False, error="snyk CLI not installed or not authenticated"
185
+ )
186
+
187
+ # Run Snyk test with JSON output
188
+ proc = await asyncio.create_subprocess_exec(
189
+ "snyk",
190
+ "test",
191
+ str(path.parent), # Scan from directory containing dependency file
192
+ "--json",
193
+ stdout=asyncio.subprocess.PIPE,
194
+ stderr=asyncio.subprocess.PIPE,
195
+ )
196
+
197
+ stdout, stderr = await proc.communicate()
198
+
199
+ # Snyk returns non-zero if vulnerabilities found, but that's expected
200
+ if stdout:
201
+ try:
202
+ data = json.loads(stdout.decode())
203
+
204
+ # Handle Snyk JSON response format
205
+ if isinstance(data, dict):
206
+ vulnerabilities = data.get("vulnerabilities", [])
207
+ error = data.get("error")
208
+ if error:
209
+ error_msg = error.get("message", "Unknown Snyk error")
210
+ return SnykResult(
211
+ success=False,
212
+ error=error_msg,
213
+ )
214
+ return SnykResult(success=True, vulnerabilities=vulnerabilities)
215
+ else:
216
+ # If it's a list, use it directly
217
+ return SnykResult(success=True, vulnerabilities=data)
218
+
219
+ except json.JSONDecodeError as e:
220
+ return SnykResult(
221
+ success=False, error=f"Failed to parse Snyk output: {e}"
222
+ )
223
+
224
+ return SnykResult(success=True, vulnerabilities=[])
225
+
226
+ except FileNotFoundError:
227
+ return SnykResult(success=False, error="snyk command not found")
228
+ except Exception as e:
229
+ self.logger.error(f"Error running Snyk: {e}")
230
+ return SnykResult(success=False, error=str(e))
231
+
232
+ async def _write_findings_to_context(self, path: Path, result: SnykResult) -> None:
233
+ """Write Snyk findings to context store."""
234
+ if not result.success or not result.has_vulnerabilities:
235
+ return
236
+
237
+ from devloop.core.context_store import context_store
238
+
239
+ # Convert each vulnerability to a Finding
240
+ for idx, vuln in enumerate(result.vulnerabilities):
241
+ # Extract vulnerability details
242
+ vuln_id = vuln.get("id", f"snyk-{idx}")
243
+ title = vuln.get("title", "Unknown vulnerability")
244
+ severity_str = vuln.get("severity", "medium").lower()
245
+ cvss_score = vuln.get("cvssScore")
246
+ package = vuln.get("package", "unknown")
247
+ from_package = vuln.get("from", [])
248
+ fix_available = vuln.get("fixAvailable", False)
249
+ upgradePath = vuln.get("upgradePath", [])
250
+
251
+ # Map severity values
252
+ severity_map = {
253
+ "critical": Severity.ERROR,
254
+ "high": Severity.ERROR,
255
+ "medium": Severity.WARNING,
256
+ "low": Severity.INFO,
257
+ }
258
+ severity = severity_map.get(severity_str, Severity.WARNING)
259
+
260
+ # Build suggestion
261
+ suggestion = ""
262
+ if fix_available and upgradePath:
263
+ suggestion = f"Upgrade path available: {' -> '.join(upgradePath)}"
264
+ elif fix_available:
265
+ suggestion = "Fix available - run 'snyk fix' to apply"
266
+
267
+ # Create Finding
268
+ finding = Finding(
269
+ id=f"{self.name}-{path.name}-{vuln_id}",
270
+ agent=self.name,
271
+ timestamp=str(datetime.now()),
272
+ file=str(path),
273
+ line=None,
274
+ column=None,
275
+ severity=severity,
276
+ message=title,
277
+ category=f"vulnerability-{severity_str}",
278
+ suggestion=suggestion,
279
+ context={
280
+ "tool": "snyk",
281
+ "vulnerability_id": vuln_id,
282
+ "package": package,
283
+ "cvss_score": cvss_score,
284
+ "fix_available": fix_available,
285
+ "from": from_package,
286
+ },
287
+ )
288
+
289
+ try:
290
+ await context_store.add_finding(finding)
291
+ except Exception as e:
292
+ self.logger.error(f"Failed to write finding to context: {e}")