Skip to content

Commit 3e5c91b

Browse files
Merge pull request #55 from Contrast-Security-OSS/AIML-124-enhance-reset-issue-to-support-claude
AIML-124: external_agent.generate_fixes() to support claude code on existing issue
2 parents 2cb6637 + 5ed7548 commit 3e5c91b

File tree

7 files changed

+199
-14
lines changed

7 files changed

+199
-14
lines changed

src/coding_agents.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# -
2+
# #%L
3+
# Contrast AI SmartFix
4+
# %%
5+
# Copyright (C) 2025 Contrast Security, Inc.
6+
# %%
7+
8+
# License: Commercial
9+
# NOTICE: This Software and the patented inventions embodied within may only be
10+
# used as part of Contrast Security's commercial offerings. Even though it is
11+
# made available through public repositories, use of this Software is subject to
12+
# the applicable End User Licensing Agreement found at
13+
# https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
14+
# between Contrast Security and the End User. The Software may not be reverse
15+
# engineered, modified, repackaged, sold, redistributed or otherwise used in a
16+
# way not consistent with the End User License Agreement.
17+
# #L%
18+
#
19+
20+
from enum import Enum
21+
22+
23+
class CodingAgents(Enum):
24+
SMARTFIX = "SMARTFIX"
25+
GITHUB_COPILOT = "GITHUB_COPILOT"
26+
CLAUDE_CODE = "CLAUDE_CODE"

src/config.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import json
2323
from pathlib import Path
2424
from typing import Optional, Any, Dict, List
25+
from src.coding_agents import CodingAgents
2526

2627

2728
def _log_config_message(message: str, is_error: bool = False, is_warning: bool = False):
@@ -65,7 +66,7 @@ def __init__(self, env: Dict[str, str] = os.environ, testing: bool = False):
6566

6667
# --- AI Agent Configuration ---
6768
self.CODING_AGENT = self._get_coding_agent()
68-
is_smartfix_coding_agent = self.CODING_AGENT == "SMARTFIX"
69+
is_smartfix_coding_agent = self.CODING_AGENT == CodingAgents.SMARTFIX.name
6970

7071
default_agent_model = ""
7172
if is_smartfix_coding_agent:
@@ -165,11 +166,13 @@ def _check_contrast_config_values_exist(self):
165166

166167
def _get_coding_agent(self) -> str:
167168
coding_agent = self._get_env_var("CODING_AGENT", required=False, default="SMARTFIX")
168-
valid_agents = ["SMARTFIX", "GITHUB_COPILOT", "CLAUDE_CODE"]
169-
if coding_agent.upper() not in valid_agents:
170-
_log_config_message(f"Warning: Invalid CODING_AGENT '{coding_agent}'. Must be one of {valid_agents}. Defaulting to 'SMARTFIX'.", is_warning=True)
171-
return "SMARTFIX"
172-
return coding_agent.upper()
169+
try:
170+
# Try to convert string to Enum
171+
CodingAgents[coding_agent.upper()]
172+
return coding_agent.upper()
173+
except (KeyError, ValueError):
174+
_log_config_message(f"Warning: Invalid CODING_AGENT '{coding_agent}'. Must be one of {[agent.name for agent in CodingAgents]}. Defaulting to '{CodingAgents.SMARTFIX.name}'.", is_warning=True)
175+
return CodingAgents.SMARTFIX.name
173176

174177
def _parse_and_validate_severities(self, json_str: Optional[str]) -> List[str]:
175178
default_severities = ["CRITICAL", "HIGH"]

src/external_coding_agent.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,10 @@
2424
from src.config import Config
2525
from src import git_handler
2626
from src import telemetry_handler
27+
from src.coding_agents import CodingAgents
2728

2829

30+
@PendingDeprecationWarning
2931
class ExternalCodingAgent:
3032
"""
3133
A class that interfaces with an external coding agent through an API or command line.
@@ -143,7 +145,7 @@ def generate_fixes(self, vuln_uuid: str, remediation_id: str, vuln_title: str, i
143145
Returns:
144146
bool: False if the CODING_AGENT is SMARTFIX, True otherwise
145147
"""
146-
if hasattr(self.config, 'CODING_AGENT') and self.config.CODING_AGENT == "SMARTFIX":
148+
if hasattr(self.config, 'CODING_AGENT') and self.config.CODING_AGENT == CodingAgents.SMARTFIX.name:
147149
debug_log("SMARTFIX agent detected, ExternalCodingAgent.generate_fixes returning False")
148150
return False
149151

@@ -155,7 +157,7 @@ def generate_fixes(self, vuln_uuid: str, remediation_id: str, vuln_title: str, i
155157
remediation_label = f"smartfix-id:{remediation_id}"
156158
issue_title = vuln_title
157159

158-
if self.config.CODING_AGENT == "CLAUDE_CODE":
160+
if self.config.CODING_AGENT == CodingAgents.CLAUDE_CODE.name:
159161
debug_log("CLAUDE_CODE agent detected, tagging @claude in issue title for processing")
160162
issue_title = f"@claude fix: {issue_title}"
161163

@@ -186,6 +188,14 @@ def generate_fixes(self, vuln_uuid: str, remediation_id: str, vuln_title: str, i
186188

187189
telemetry_handler.update_telemetry("additionalAttributes.externalIssueNumber", issue_number)
188190

191+
if self.config.CODING_AGENT == CodingAgents.CLAUDE_CODE.name:
192+
# temporary short-circuit for Claude until we implement the PR processing logic
193+
log("Claude agent processing support is not implemented as of yet so stop processing and log agent failure", is_error=True)
194+
telemetry_handler.update_telemetry("resultInfo.prCreated", False)
195+
telemetry_handler.update_telemetry("resultInfo.failureReason", "Claude processing not implemented")
196+
telemetry_handler.update_telemetry("resultInfo.failureCategory", FailureCategory.AGENT_FAILURE.name)
197+
error_exit(remediation_id, FailureCategory.AGENT_FAILURE.value)
198+
189199
# Poll for PR creation by the external agent
190200
log(f"Waiting for external agent to create a PR for issue #{issue_number}")
191201

src/git_handler.py

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from src.utils import run_command, debug_log, log, error_exit
2626
from src.contrast_api import FailureCategory
2727
from src.config import get_config
28+
from src.coding_agents import CodingAgents
2829
config = get_config()
2930

3031

@@ -540,7 +541,7 @@ def create_issue(title: str, body: str, vuln_label: str, remediation_label: str)
540541
issue_number = int(os.path.basename(issue_url.strip()))
541542
log(f"Issue number extracted: {issue_number}")
542543

543-
if config.CODING_AGENT == "CLAUDE_CODE":
544+
if config.CODING_AGENT == CodingAgents.CLAUDE_CODE.name:
544545
debug_log("CLAUDE_CODE agent detected no need to edit issue for assignment")
545546
return issue_number
546547

@@ -629,7 +630,8 @@ def reset_issue(issue_number: int, remediation_label: str) -> bool:
629630
Resets a GitHub issue by:
630631
1. Removing all existing labels that start with "smartfix-id:"
631632
2. Adding the specified remediation label
632-
3. Unassigning the @Copilot user and reassigning the issue to @Copilot
633+
3. If coding agent is CoPilot then unassigning the @Copilot user and reassigning the issue to @Copilot
634+
4. If coding agent is Claude Code then adding a comment to notify @claude to reprocess the issue
633635
634636
The reset will not occur if there's an open PR for the issue.
635637
@@ -709,6 +711,25 @@ def reset_issue(issue_number: int, remediation_label: str) -> bool:
709711
run_command(add_label_command, env=gh_env, check=True)
710712
log(f"Added new remediation label to issue #{issue_number}")
711713

714+
# If using CLAUDE_CODE, skip reassignment and tag @claude in comment
715+
if config.CODING_AGENT == CodingAgents.CLAUDE_CODE.name:
716+
debug_log("CLAUDE_CODE agent detected need to add a comment and tag @claude for reprocessing")
717+
# Add a comment to the existing issue to notify @claude to reprocess
718+
comment:str = f"@claude reprocess this issue with the new remediation label: {remediation_label} and attempt a fix."
719+
comment_command = [
720+
"gh", "issue", "comment",
721+
"--repo", config.GITHUB_REPOSITORY,
722+
str(issue_number),
723+
"--create-if-none",
724+
"--edit-last",
725+
"--body", comment
726+
]
727+
728+
# add a new comment and use the @claude handle to reprocess the issue
729+
run_command(comment_command, env=gh_env, check=True)
730+
log(f"Added new comment tagging @claude to issue #{issue_number}")
731+
return True
732+
712733
# Unassign from @Copilot (if assigned)
713734
unassign_command = [
714735
"gh", "issue", "edit",

src/github/external_coding_agent.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from src.config import Config
2525
from src import git_handler
2626
from src import telemetry_handler
27+
from src.coding_agents import CodingAgents
2728

2829

2930
class ExternalCodingAgent:
@@ -143,7 +144,7 @@ def generate_fixes(self, vuln_uuid: str, remediation_id: str, vuln_title: str, i
143144
Returns:
144145
bool: False if the CODING_AGENT is SMARTFIX, True otherwise
145146
"""
146-
if hasattr(self.config, 'CODING_AGENT') and self.config.CODING_AGENT == "SMARTFIX":
147+
if hasattr(self.config, 'CODING_AGENT') and self.config.CODING_AGENT == CodingAgents.SMARTFIX.name:
147148
debug_log("SMARTFIX agent detected, ExternalCodingAgent.generate_fixes returning False")
148149
return False
149150

@@ -155,6 +156,10 @@ def generate_fixes(self, vuln_uuid: str, remediation_id: str, vuln_title: str, i
155156
remediation_label = f"smartfix-id:{remediation_id}"
156157
issue_title = vuln_title
157158

159+
if self.config.CODING_AGENT == CodingAgents.CLAUDE_CODE.name:
160+
debug_log("CLAUDE_CODE agent detected, tagging @claude in issue title for processing")
161+
issue_title = f"@claude fix: {issue_title}"
162+
158163
# Use the provided issue_body or fall back to default
159164
if issue_body is None:
160165
log(f"Failed to generate issue body for vulnerability id {vuln_uuid}", is_error=True)
@@ -182,6 +187,14 @@ def generate_fixes(self, vuln_uuid: str, remediation_id: str, vuln_title: str, i
182187

183188
telemetry_handler.update_telemetry("additionalAttributes.externalIssueNumber", issue_number)
184189

190+
if self.config.CODING_AGENT == CodingAgents.CLAUDE_CODE.name:
191+
# temporary short-circuit for Claude until we implement the PR processing logic
192+
log("Claude agent processing support is not implemented as of yet so stop processing and log agent failure", is_error=True)
193+
telemetry_handler.update_telemetry("resultInfo.prCreated", False)
194+
telemetry_handler.update_telemetry("resultInfo.failureReason", "Claude processing not implemented")
195+
telemetry_handler.update_telemetry("resultInfo.failureCategory", FailureCategory.AGENT_FAILURE.name)
196+
error_exit(remediation_id, FailureCategory.AGENT_FAILURE.value)
197+
185198
# Poll for PR creation by the external agent
186199
log(f"Waiting for external agent to create a PR for issue #{issue_number}")
187200

src/main.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
# Import configurations and utilities
3030
from src.config import get_config
31+
from src.coding_agents import CodingAgents
3132
from src.utils import debug_log, log, error_exit
3233
from src import telemetry_handler
3334
from src.qa_handler import run_build_command
@@ -310,7 +311,7 @@ def main(): # noqa: C901
310311
break
311312

312313
# --- Fetch Next Vulnerability Data from API ---
313-
if config.CODING_AGENT == "SMARTFIX":
314+
if config.CODING_AGENT == CodingAgents.SMARTFIX.name:
314315
# For SMARTFIX, get vulnerability with prompts
315316
log("\n::group::--- Fetching next vulnerability and prompts from Contrast API ---")
316317
vulnerability_data = contrast_api.get_vulnerability_with_prompts(
@@ -382,7 +383,7 @@ def main(): # noqa: C901
382383
log(f"\n\033[0;33m Selected vuln to fix: {vuln_title} \033[0m")
383384

384385
# --- Check if we need to use the external coding agent ---
385-
if config.CODING_AGENT != "SMARTFIX":
386+
if config.CODING_AGENT != CodingAgents.SMARTFIX.name:
386387
external_agent = ExternalCodingAgent(config)
387388
# Assemble the issue body from vulnerability details
388389
issue_body = external_agent.assemble_issue_body(vulnerability_data)

test/test_git_handler.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
# Import with testing=True
5151
from src.config import get_config, reset_config # noqa: E402
5252
from src import git_handler # noqa: E402
53+
from src.coding_agents import CodingAgents # noqa: E402
5354

5455

5556
class TestGitHandler(unittest.TestCase):
@@ -206,7 +207,8 @@ def test_create_issue_failure(self, mock_log, mock_ensure_label, mock_run_comman
206207
@patch('src.git_handler.ensure_label')
207208
@patch('src.git_handler.log')
208209
@patch('src.git_handler.debug_log')
209-
def test_reset_issue_success(self, mock_debug_log, mock_log, mock_ensure_label, mock_find_open_pr, mock_run_command, mock_check_issues):
210+
@patch('src.git_handler.config')
211+
def test_reset_issue_success(self, mock_config, mock_debug_log, mock_log, mock_ensure_label, mock_find_open_pr, mock_run_command, mock_check_issues):
210212
"""Test resetting a GitHub issue when successful"""
211213
# Setup
212214
issue_number = 42
@@ -215,6 +217,10 @@ def test_reset_issue_success(self, mock_debug_log, mock_log, mock_ensure_label,
215217
# Mock that no open PR exists
216218
mock_find_open_pr.return_value = None
217219
mock_check_issues.return_value = True
220+
221+
# Explicitly configure for SMARTFIX agent
222+
mock_config.CODING_AGENT = CodingAgents.SMARTFIX.name
223+
mock_config.GITHUB_REPOSITORY = 'mock/repo'
218224

219225
# Mock successful issue view with labels
220226
mock_run_command.side_effect = [
@@ -298,6 +304,111 @@ def test_reset_issue_with_open_pr(self, mock_log, mock_find_open_pr):
298304
"Cannot reset issue #42 because it has an open PR #123: https://github.com/mock/repo/pull/123",
299305
is_error=True
300306
)
307+
308+
@patch('src.git_handler.check_issues_enabled')
309+
@patch('src.git_handler.run_command')
310+
@patch('src.git_handler.find_open_pr_for_issue')
311+
@patch('src.git_handler.ensure_label')
312+
@patch('src.git_handler.log')
313+
@patch('src.git_handler.debug_log')
314+
@patch('src.git_handler.config')
315+
def test_reset_issue_claude_code(self, mock_config, mock_debug_log, mock_log, mock_ensure_label, mock_find_open_pr, mock_run_command, mock_check_issues):
316+
"""Test resetting a GitHub issue when using Claude Code agent"""
317+
# Setup
318+
issue_number = 42
319+
remediation_label = "smartfix-id:5678"
320+
321+
# Mock that no open PR exists
322+
mock_find_open_pr.return_value = None
323+
mock_check_issues.return_value = True
324+
325+
# Configure the mock to use CLAUDE_CODE
326+
mock_config.CODING_AGENT = CodingAgents.CLAUDE_CODE.name
327+
mock_config.GITHUB_REPOSITORY = 'mock/repo'
328+
329+
# Mock successful issue view with labels and other API calls
330+
mock_run_command.side_effect = [
331+
# First call - issue view response
332+
json.dumps({"labels": [{"name": "contrast-vuln-id:VULN-1234"}, {"name": "smartfix-id:OLD-REM"}]}),
333+
# Second call - remove label response
334+
"",
335+
# Third call - add label response
336+
"",
337+
# Fourth call - comment with @claude tag
338+
""
339+
]
340+
mock_ensure_label.return_value = True
341+
342+
# Execute
343+
result = git_handler.reset_issue(issue_number, remediation_label)
344+
345+
# Assert
346+
mock_check_issues.assert_called_once()
347+
self.assertEqual(mock_run_command.call_count, 4) # Should call run_command 4 times (view, remove label, add label, add comment)
348+
self.assertTrue(result)
349+
350+
# Check that Claude-specific logic was executed
351+
mock_debug_log.assert_any_call("CLAUDE_CODE agent detected need to add a comment and tag @claude for reprocessing")
352+
mock_log.assert_any_call(f"Added new comment tagging @claude to issue #{issue_number}")
353+
354+
# Verify the comment command
355+
comment_command_call = mock_run_command.call_args_list[3]
356+
comment_command = comment_command_call[0][0]
357+
358+
# Verify command structure
359+
self.assertEqual(comment_command[0], "gh")
360+
self.assertEqual(comment_command[1], "issue")
361+
self.assertEqual(comment_command[2], "comment")
362+
self.assertEqual(comment_command[5], str(issue_number))
363+
364+
# Verify comment body contains '@claude' and the remediation label
365+
comment_body = comment_command[-1]
366+
self.assertIn("@claude", comment_body)
367+
self.assertIn(remediation_label, comment_body)
368+
369+
@patch('src.git_handler.check_issues_enabled')
370+
@patch('src.git_handler.run_command')
371+
@patch('src.git_handler.find_open_pr_for_issue')
372+
@patch('src.git_handler.ensure_label')
373+
@patch('src.git_handler.log')
374+
@patch('src.git_handler.config')
375+
def test_reset_issue_claude_code_error(self, mock_config, mock_log, mock_ensure_label, mock_find_open_pr, mock_run_command, mock_check_issues):
376+
"""Test resetting a GitHub issue when using Claude Code agent but an error occurs"""
377+
# Setup
378+
issue_number = 42
379+
remediation_label = "smartfix-id:5678"
380+
381+
# Mock that no open PR exists
382+
mock_find_open_pr.return_value = None
383+
mock_check_issues.return_value = True
384+
385+
# Configure the mock to use CLAUDE_CODE
386+
mock_config.CODING_AGENT = CodingAgents.CLAUDE_CODE.name
387+
mock_config.GITHUB_REPOSITORY = 'mock/repo'
388+
389+
# Mock successful label operations but comment command fails
390+
mock_run_command.side_effect = [
391+
# First call - issue view response
392+
json.dumps({"labels": [{"name": "contrast-vuln-id:VULN-1234"}, {"name": "smartfix-id:OLD-REM"}]}),
393+
# Second call - remove label response
394+
"",
395+
# Third call - add label response
396+
"",
397+
# Fourth call - comment command fails
398+
Exception("Failed to comment")
399+
]
400+
mock_ensure_label.return_value = True
401+
402+
# Execute
403+
result = git_handler.reset_issue(issue_number, remediation_label)
404+
405+
# Assert
406+
mock_check_issues.assert_called_once()
407+
self.assertEqual(mock_run_command.call_count, 4) # Should still call run_command 4 times
408+
self.assertFalse(result) # Should return False due to the error
409+
410+
# Verify error was logged
411+
mock_log.assert_any_call(f"Failed to reset issue #{issue_number}: Failed to comment", is_error=True)
301412

302413
@patch('src.git_handler.run_command')
303414
@patch('src.git_handler.debug_log')

0 commit comments

Comments
 (0)