Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,41 @@ This project uses Beads (bd) for issue tracking. See the MCP resource `beads://q
- `in_progress` → Actively working on task (SET THIS WHEN YOU START!)
- `closed` → Task complete, tested, and merged

### Human Review Required Label

**IMPORTANT: Beads labeled `needs-human-review` require human approval before AI work begins.**

**AI Behavior:**
- Before starting work on any bead, check for the `needs-human-review` label
- If present, **DO NOT start work** on the bead
- Instead, ask the human to review the bead description and approach
- Once human reviews and approves, they will remove the `needs-human-review` label
- Only after label is removed may AI proceed with implementation

**Example workflow:**
```
AI: "I see bead mcp-xyz is ready to work on, but it has the 'needs-human-review' label.
Please review the bead description and let me know if the approach looks good.
Once approved, remove the label and I'll proceed with implementation."
```

### Human Review Label Workflow

**When a human reviews a bead with the `needs-human-review` label:**

1. **Review the bead description** - Evaluate the proposed approach, implementation details, and any concerns
2. **Update the bead** - If changes are needed based on review, update the bead description with the approved approach
3. **Update labels:**
```bash
bd label remove <bead-id> needs-human-review
bd label add <bead-id> human-reviewed
```
4. **Proceed to next bead** - Continue reviewing remaining beads with `needs-human-review` label

**Label meanings:**
- `needs-human-review` - Bead requires human approval before AI can start work
- `human-reviewed` - Bead has been reviewed and approved by human, AI may proceed when ready

### AI Model Labeling

**When a bead is worked on by an AI system, label it to track which model performed the work:**
Expand Down
481 changes: 329 additions & 152 deletions src/main/java/com/contrast/labs/ai/mcp/contrast/AssessService.java

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ public class RouteCoverageService {
*
* @param appId Required - The application ID to retrieve route coverage for
* @param sessionMetadataName Optional - Filter by session metadata field name (e.g., "branch").
* Empty strings are treated as null (no filter).
* Must be provided with sessionMetadataValue. Empty strings are treated as null (no filter).
* @param sessionMetadataValue Optional - Filter by session metadata field value (e.g., "main").
* Required if sessionMetadataName is provided. Empty strings are treated as null.
* Must be provided with sessionMetadataName. Empty strings are treated as null.
* @param useLatestSession Optional - If true, only return routes from the latest session
* @return RouteCoverageResponse containing route coverage data with details for each route
* @throws IOException If an error occurs while retrieving data from Contrast
* @throws IllegalArgumentException If sessionMetadataName is provided without
* sessionMetadataValue
* sessionMetadataValue, or if sessionMetadataValue is provided without sessionMetadataName
*/
@Tool(
name = "get_route_coverage",
Expand All @@ -61,8 +61,9 @@ public class RouteCoverageService {
+ " not exercised) or EXERCISED (received HTTP traffic). All filter parameters are"
+ " truly optional - if none provided (null or empty strings), returns all routes"
+ " across all sessions. Parameters: appId (required), sessionMetadataName"
+ " (optional), sessionMetadataValue (optional - required if sessionMetadataName"
+ " provided), useLatestSession (optional).")
+ " (optional), sessionMetadataValue (optional). NOTE: sessionMetadataName and"
+ " sessionMetadataValue must be provided together or both omitted - providing"
+ " only one will result in an error. useLatestSession (optional).")
public RouteCoverageResponse getRouteCoverage(
String appId,
String sessionMetadataName,
Expand All @@ -79,6 +80,13 @@ public RouteCoverageResponse getRouteCoverage(
throw new IllegalArgumentException(errorMsg);
}

// Validate sessionMetadataValue requires sessionMetadataName
if (StringUtils.hasText(sessionMetadataValue) && !StringUtils.hasText(sessionMetadataName)) {
var errorMsg = "sessionMetadataName is required when sessionMetadataValue is provided";
log.error(errorMsg);
throw new IllegalArgumentException(errorMsg);
}

// Initialize SDK
var contrastSDK =
SDKHelper.getSDK(hostName, apiKey, serviceKey, userName, httpProxyHost, httpProxyPort);
Expand Down
19 changes: 19 additions & 0 deletions src/main/java/com/contrast/labs/ai/mcp/contrast/SastService.java
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,29 @@ public String getLatestScanResult(String projectName) throws IOException {
.orElseThrow(() -> new IOException("Project not found"));
log.debug("Found project with id: {}", project.id());

// Check if project has any completed scans
if (project.lastScanId() == null) {
var errorMsg =
String.format(
"No scan results available for project: %s. Project exists but has no completed"
+ " scans.",
projectName);
log.warn(errorMsg);
throw new IOException(errorMsg);
}

var scans = contrastSDK.scan(orgID).scans(project.id());
log.debug("Retrieved scans for project, last scan id: {}", project.lastScanId());

var scan = scans.get(project.lastScanId());
if (scan == null) {
var errorMsg =
String.format(
"No scan results available for project: %s. Scan ID %s not found.",
projectName, project.lastScanId());
log.warn(errorMsg);
throw new IOException(errorMsg);
}
log.debug("Retrieved scan with id: {}", project.lastScanId());

try (InputStream sarifStream = scan.sarif();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,12 @@
* execution. Soft failures (warnings) continue with corrected values.
*
* @param form SDK TraceFilterForm with all filters applied
* @param appId Application ID for routing decision (null = org-level query)
* @param warnings Validation warnings (soft failures - execution continues)
* @param errors Validation errors (hard failures - execution must stop)
*/
@Slf4j
public record VulnerabilityFilterParams(
TraceFilterForm form, String appId, List<String> warnings, List<String> errors) {
TraceFilterForm form, List<String> warnings, List<String> errors) {
// Valid status values for validation
private static final Set<String> VALID_STATUSES =
Set.of("Reported", "Suspicious", "Confirmed", "Remediated", "Fixed");
Expand All @@ -50,7 +49,6 @@ public record VulnerabilityFilterParams(
*
* @param severities Comma-separated severity levels (e.g., "CRITICAL,HIGH")
* @param statuses Comma-separated statuses (e.g., "Reported,Confirmed"), null = smart defaults
* @param appId Application ID for filtering (null = all apps)
* @param vulnTypes Comma-separated vulnerability types (e.g., "sql-injection,xss-reflected")
* @param environments Comma-separated environments (e.g., "PRODUCTION,QA")
* @param lastSeenAfter ISO date or epoch timestamp (e.g., "2025-01-01")
Expand All @@ -61,7 +59,6 @@ public record VulnerabilityFilterParams(
public static VulnerabilityFilterParams of(
String severities,
String statuses,
String appId,
String vulnTypes,
String environments,
String lastSeenAfter,
Expand Down Expand Up @@ -206,7 +203,7 @@ public static VulnerabilityFilterParams of(
}
}

return new VulnerabilityFilterParams(form, appId, List.copyOf(warnings), List.copyOf(errors));
return new VulnerabilityFilterParams(form, List.copyOf(warnings), List.copyOf(errors));
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.contrast.labs.ai.mcp.contrast.sdkextension.data.application;

import com.google.gson.annotations.SerializedName;
import java.util.Collections;
import java.util.List;
import lombok.Data;

Expand Down Expand Up @@ -112,4 +113,35 @@ public class Application {

@SerializedName("onboarded_time")
private Long onboardedTime;

// Defensive getters - return empty collections instead of null (Effective Java Item 54)
// These override Lombok's generated getters to ensure null-safety

public List<String> getRoles() {
return roles != null ? roles : Collections.emptyList();
}

public List<String> getTags() {
return tags != null ? tags : Collections.emptyList();
}

public List<String> getTechs() {
return techs != null ? techs : Collections.emptyList();
}

public List<String> getPolicies() {
return policies != null ? policies : Collections.emptyList();
}

public List<Metadata> getMetadataEntities() {
return metadataEntities != null ? metadataEntities : Collections.emptyList();
}

public List<Field> getValidationErrorFields() {
return validationErrorFields != null ? validationErrorFields : Collections.emptyList();
}

public List<Field> getMissingRequiredFields() {
return missingRequiredFields != null ? missingRequiredFields : Collections.emptyList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.application.Application;
import com.contrast.labs.ai.mcp.contrast.sdkextension.data.application.Metadata;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.UUID;

Expand Down Expand Up @@ -73,7 +74,8 @@ public AnonymousApplicationBuilder withTag(String tag) {
}

public AnonymousApplicationBuilder withTags(List<String> tags) {
this.tags = tags;
// Never null collections pattern - use empty list if null
this.tags = tags != null ? tags : Collections.emptyList();
Copy link
Collaborator

@dougj-contrast dougj-contrast Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, just FYI another way to do these checks is:

 this.tags = Optional.ofNullable(tags).orElse(Collections.emptyList());

return this;
}

Expand All @@ -83,7 +85,8 @@ public AnonymousApplicationBuilder withTech(String tech) {
}

public AnonymousApplicationBuilder withTechs(List<String> techs) {
this.techs = techs;
// Never null collections pattern - use empty list if null
this.techs = techs != null ? techs : Collections.emptyList();
return this;
}

Expand All @@ -96,7 +99,8 @@ public AnonymousApplicationBuilder withMetadata(String name, String value) {
}

public AnonymousApplicationBuilder withMetadataEntities(List<Metadata> metadataEntities) {
this.metadataEntities = metadataEntities;
// Never null collections pattern - use empty list if null
this.metadataEntities = metadataEntities != null ? metadataEntities : Collections.emptyList();
return this;
}

Expand Down
Loading