Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions capabilities/web-security/agents/web-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ You may also have tools from MCP servers. Check your tool schema for what's avai
- **agent-browser**: Prefer running the local `agent-browser` CLI directly when it is available on `PATH`; it is the primary browser automation path. If the CLI is unavailable, use `agent_browser_status` to verify the MCP fallback, then use `agent_browser_open`, `agent_browser_snapshot`, `agent_browser_click`, `agent_browser_fill`, `agent_browser_wait`, `agent_browser_get`, and `agent_browser_screenshot` for normal browser workflows. Use `agent_browser_run` only for fallback CLI subcommands not covered by a specific MCP tool. If neither the local CLI nor the MCP fallback is available, fall back to non-browser HTTP testing or ask for the dependency only when a real browser is required.
- **protoscope**: Prefer running the local `protoscope` CLI directly when it is available on `PATH`; it is the primary protobuf inspection and assembly path. If the CLI is unavailable, use `protoscope_status` to verify the MCP fallback. Use `protoscope_inspect_file` or `protoscope_inspect_hex` to decode binary protobuf payloads, and `protoscope_assemble_text` or `protoscope_assemble_file` to build binary protobuf bytes from Protoscope text. Use descriptor-set and message-type options when available to improve field names and enum output.
- **hackerone**: Query HackerOne programs, scopes, reports, and hacktivity. Run `hackerone_health` first to verify credentials. Use `hackerone_get_program_scope` to enumerate in-scope assets before testing. Use `hackerone_search_hacktivity` to study previously disclosed vulnerabilities in a program. Use `hackerone_submit_report` only after the full reporting pipeline completes (assess_confidence → report-preflight → exploit-verifier → report-writer). Requires `H1_USERNAME` and `H1_API_TOKEN` env vars.
- **github**: Create GitHub remediation issues from validated findings. Run `github_health` first to verify credentials. Use `github_list_labels` before creating issues when label names are uncertain. Use `github_create_issue` only after the full reporting pipeline completes; include the validated report body, severity/priority labels, and links to Dreadnode evidence or artifacts. Do not post sensitive exploit details to public repositories unless the user explicitly confirms that disclosure is intended. Requires `GITHUB_TOKEN` with Issues write permission.
- **linear**: Create internal Linear remediation issues from validated findings. Run `linear_health` first to verify credentials. Use `linear_list_teams` to find the team UUID before creating issues. Use `linear_create_issue` only after the full reporting pipeline completes; include the validated report body, severity/priority mapping, and links to Dreadnode evidence or artifacts. Requires `LINEAR_API_KEY` or `LINEAR_ACCESS_TOKEN`.

Scan and tool output is input to your OODA loop, not a deliverable. When a scan completes, orient on the results, prioritize leads by exploitability, load relevant skills, and begin active exploitation immediately. A completed scan is the start of your work, not the end.
Expand Down
13 changes: 13 additions & 0 deletions capabilities/web-security/capability.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,24 @@ mcp:
- "run"
- "${CAPABILITY_ROOT}/mcp/hackerone.py"
init_timeout: 60
github:
command: "uv"
args:
- "run"
- "${CAPABILITY_ROOT}/mcp/github.py"
env:
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
GITHUB_API_URL: "${GITHUB_API_URL:-https://api.github.com}"
init_timeout: 60
linear:
command: "uv"
args:
- "run"
- "${CAPABILITY_ROOT}/mcp/linear.py"
env:
LINEAR_API_KEY: "${LINEAR_API_KEY:-}"
LINEAR_ACCESS_TOKEN: "${LINEAR_ACCESS_TOKEN:-}"
LINEAR_API_URL: "${LINEAR_API_URL:-https://api.linear.app/graphql}"
init_timeout: 60
agent-browser:
command: "uv"
Expand Down
208 changes: 208 additions & 0 deletions capabilities/web-security/mcp/github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "fastmcp>=2.0",
# "httpx>=0.28",
# ]
# ///
"""GitHub issue tools for web-security report export.

Auth: GITHUB_TOKEN with Issues write permission for target repositories.
Use these tools only after a web-security finding has passed validation,
and avoid posting sensitive exploit detail to public repositories unless the
user explicitly confirms that disclosure is intended.
"""

from __future__ import annotations

import os
from typing import Annotated, Any

import httpx
from fastmcp import FastMCP

_DEFAULT_API_URL = "https://api.github.com"
_API_VERSION = "2022-11-28"
MAX_OUTPUT_CHARS = 30_000

mcp = FastMCP("github")


class _GitHubClient:
"""Lazy GitHub REST API client."""

def __init__(self) -> None:
self._client: httpx.AsyncClient | None = None

def _settings(self) -> tuple[str, str]:
api_url = os.environ.get("GITHUB_API_URL", _DEFAULT_API_URL).strip().rstrip("/")
token = os.environ.get("GITHUB_TOKEN", "").strip()
if not token:
raise RuntimeError(
"GitHub credentials not configured. Set GITHUB_TOKEN with "
"Issues write permission for the target repository."
)
return api_url, token

async def get(self) -> httpx.AsyncClient:
if self._client is not None:
return self._client

api_url, token = self._settings()
self._client = httpx.AsyncClient(
base_url=api_url,
timeout=30.0,
follow_redirects=True,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": _API_VERSION,
},
)
return self._client


_github = _GitHubClient()


def _raise_for_github(resp: httpx.Response, action: str) -> None:
if 200 <= resp.status_code < 300:
return
detail = resp.text[:1000]
raise RuntimeError(f"GitHub {action} failed: HTTP {resp.status_code}: {detail}")


def _truncate(value: str, limit: int = MAX_OUTPUT_CHARS) -> str:
if len(value) <= limit:
return value
return value[:limit] + f"\n... [TRUNCATED: {len(value)} chars total]"


def _drop_empty(value: dict[str, Any]) -> dict[str, Any]:
return {
key: item
for key, item in value.items()
if item is not None and item != "" and item != [] and item != {}
}


def _issue_line(issue: dict[str, Any]) -> str:
number = issue.get("number", "?")
state = issue.get("state", "?")
title = issue.get("title", "?")
url = issue.get("html_url", "")
return f"#{number}\t{state}\t{title}\t{url}"


@mcp.tool
async def github_health() -> str:
"""Check GitHub API connectivity and show the authenticated user."""
client = await _github.get()
resp = await client.get("/user")
_raise_for_github(resp, "health check")
data = resp.json()
return (
"Connected to GitHub\n"
f" Login: {data.get('login', '?')}\n"
f" ID: {data.get('id', '?')}\n"
f" URL: {data.get('html_url', '?')}"
)


@mcp.tool
async def github_list_labels(
owner: Annotated[str, "Repository owner or organization"],
repo: Annotated[str, "Repository name"],
per_page: Annotated[int, "Maximum labels to return"] = 100,
) -> str:
"""List labels for a GitHub repository."""
client = await _github.get()
resp = await client.get(
f"/repos/{owner}/{repo}/labels",
params={"per_page": min(per_page, 100)},
)
_raise_for_github(resp, "label list")
labels = resp.json()
if not labels:
return f"No labels found for {owner}/{repo}."

lines = [f"Labels for {owner}/{repo}:"]
for label in labels:
description = label.get("description") or ""
line = f" {label.get('name', '?')}"
if description:
line += f"\t{description[:120]}"
lines.append(line)
return "\n".join(lines)


@mcp.tool
async def github_create_issue(
owner: Annotated[str, "Repository owner or organization"],
repo: Annotated[str, "Repository name"],
title: Annotated[str, "Issue title"],
body: Annotated[str, "Validated finding or report body in Markdown"],
labels: Annotated[list[str] | None, "Optional label names"] = None,
assignees: Annotated[list[str] | None, "Optional GitHub usernames"] = None,
milestone: Annotated[int | None, "Optional milestone number"] = None,
) -> str:
"""Create a GitHub issue from a validated web-security finding."""
payload = _drop_empty(
{
"title": title,
"body": body,
"labels": labels,
"assignees": assignees,
"milestone": milestone,
}
)
client = await _github.get()
resp = await client.post(f"/repos/{owner}/{repo}/issues", json=payload)
_raise_for_github(resp, "issue create")
issue = resp.json()
return f"Created GitHub issue {_issue_line(issue)}"


@mcp.tool
async def github_get_issue(
owner: Annotated[str, "Repository owner or organization"],
repo: Annotated[str, "Repository name"],
issue_number: Annotated[int, "Issue number"],
) -> str:
"""Get a GitHub issue summary and body."""
client = await _github.get()
resp = await client.get(f"/repos/{owner}/{repo}/issues/{issue_number}")
_raise_for_github(resp, "issue fetch")
issue = resp.json()
lines = [
_issue_line(issue),
f"Author: {(issue.get('user') or {}).get('login', '?')}",
f"Labels: {', '.join(label.get('name', '?') for label in issue.get('labels', []))}",
"",
"--- Body ---",
issue.get("body") or "",
]
return _truncate("\n".join(lines))


@mcp.tool
async def github_add_comment(
owner: Annotated[str, "Repository owner or organization"],
repo: Annotated[str, "Repository name"],
issue_number: Annotated[int, "Issue number"],
body: Annotated[str, "Markdown comment body"],
) -> str:
"""Add a comment to an existing GitHub issue."""
client = await _github.get()
resp = await client.post(
f"/repos/{owner}/{repo}/issues/{issue_number}/comments",
json={"body": body},
)
_raise_for_github(resp, "comment create")
comment = resp.json()
return f"Added GitHub comment {comment.get('id', '?')}: {comment.get('html_url', '')}".strip()


if __name__ == "__main__":
mcp.run()
Loading
Loading