a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
316 lines
9.0 KiB
Go
316 lines
9.0 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
mcp "github.com/mark3labs/mcp-go/mcp"
|
|
|
|
"github.com/zzet/gortex/internal/astquery"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// analyze kind=unsafe_patterns
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// Bundled scan for unsafe / panic-prone primitives across every
|
|
// supported language. Wraps the per-language astquery detectors
|
|
// listed in astquery.UnsafePatternDetectors, fans them out across
|
|
// the indexed file set, and aggregates the matches into one
|
|
// per-site row plus a per-detector summary.
|
|
//
|
|
// Why a bundled kind: the underlying detectors are already
|
|
// invocable individually via `search_ast detector=…` — the bundle
|
|
// exists so an agent can ask "show me every panic-prone site in
|
|
// this repo" with one call instead of N calls, while still being
|
|
// able to narrow by `language`, `detector`, `path_prefix`, and
|
|
// `severity`.
|
|
|
|
type unsafePatternRow struct {
|
|
Detector string `json:"detector"`
|
|
Severity string `json:"severity"`
|
|
Language string `json:"language"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
Symbol string `json:"symbol,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
}
|
|
|
|
type unsafePatternSummary struct {
|
|
Detector string `json:"detector"`
|
|
Severity string `json:"severity"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// handleAnalyzeUnsafePatterns runs the bundled unsafe-pattern
|
|
// detectors and returns aggregated rows + per-detector counts.
|
|
//
|
|
// Filters (all optional):
|
|
// - language — comma-separated subset (rust,python,javascript,typescript,go).
|
|
// - detector — comma-separated subset (must be a member of
|
|
// astquery.UnsafePatternDetectors). Lets the agent
|
|
// narrow the bundle without falling back to
|
|
// individual search_ast calls.
|
|
// - severity — comma-separated subset (error,warning,info).
|
|
// - path_prefix — keep matches whose file path starts with this.
|
|
// - limit — cap rows (default 200; matches error_surface's UX).
|
|
// - exclude_tests — override per-detector default (defaults to true).
|
|
func (s *Server) handleAnalyzeUnsafePatterns(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
|
args := req.GetArguments()
|
|
languageFilter := parseCSVSet(stringArg(args, "language"))
|
|
detectorFilter := parseCSVSet(stringArg(args, "detector"))
|
|
severityFilter := parseCSVSet(stringArg(args, "severity"))
|
|
pathPrefix := strings.TrimSpace(stringArg(args, "path_prefix"))
|
|
limit := intArg(args, "limit", 200)
|
|
excludeTests, excludeTestsSet := boolArg(args, "exclude_tests")
|
|
|
|
allowedRepos, err := s.resolveRepoFilter(ctx, req)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Build target list once; reused across every detector to avoid
|
|
// re-walking the KindFile node set per rule.
|
|
targets, err := s.buildASTTargets("", pathPrefix, allowedRepos)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(err.Error()), nil
|
|
}
|
|
|
|
// Per-file enclosing-symbol index — shared across detectors so
|
|
// every row gets symbol enrichment without re-building.
|
|
fileSymbols := s.buildFileSymbolIndex(targets)
|
|
lookup := func(graphPath string, line int) (string, string) {
|
|
idx := fileSymbols[graphPath]
|
|
if idx == nil {
|
|
return "", ""
|
|
}
|
|
return idx.find(line)
|
|
}
|
|
|
|
// Reject unknown detector names early so the agent gets a
|
|
// pointed error instead of an empty result.
|
|
if len(detectorFilter) > 0 {
|
|
known := make(map[string]struct{}, len(astquery.UnsafePatternDetectors))
|
|
for _, n := range astquery.UnsafePatternDetectors {
|
|
known[n] = struct{}{}
|
|
}
|
|
var unknown []string
|
|
for n := range detectorFilter {
|
|
if _, ok := known[n]; !ok {
|
|
unknown = append(unknown, n)
|
|
}
|
|
}
|
|
if len(unknown) > 0 {
|
|
sort.Strings(unknown)
|
|
return mcp.NewToolResultError(fmt.Sprintf(
|
|
"analyze unsafe_patterns: unknown detector(s) %s (valid: %s)",
|
|
strings.Join(unknown, ","),
|
|
strings.Join(astquery.UnsafePatternDetectors, ","),
|
|
)), nil
|
|
}
|
|
}
|
|
|
|
rows := make([]unsafePatternRow, 0, 64)
|
|
summary := make(map[string]*unsafePatternSummary, len(astquery.UnsafePatternDetectors))
|
|
var errsAcc []string
|
|
|
|
for _, name := range astquery.UnsafePatternDetectors {
|
|
if len(detectorFilter) > 0 {
|
|
if _, ok := detectorFilter[name]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
opts := astquery.Options{
|
|
Detector: name,
|
|
Targets: targets,
|
|
SymbolLookup: lookup,
|
|
Resolver: astquery.DefaultLanguageResolver,
|
|
// Generous per-detector cap; the outer `limit` is the
|
|
// agent-facing budget. Picking 5000 protects against a
|
|
// pathological repo where one detector returns tens of
|
|
// thousands of rows and starves the rest of the bundle.
|
|
Limit: 5000,
|
|
}
|
|
if excludeTestsSet {
|
|
opts.ExcludeTests = excludeTests
|
|
} else {
|
|
// Default to true — every bundled detector already
|
|
// opts in, but we force the engine flag too so a
|
|
// caller that wires in a detector with ExcludeTests=
|
|
// false doesn't accidentally bleed test noise into
|
|
// the bundle.
|
|
opts.ExcludeTests = true
|
|
}
|
|
|
|
res, runErr := astquery.Run(ctx, opts)
|
|
if runErr != nil {
|
|
errsAcc = append(errsAcc, fmt.Sprintf("%s: %v", name, runErr))
|
|
continue
|
|
}
|
|
errsAcc = append(errsAcc, res.Errors...)
|
|
|
|
entry := summary[name]
|
|
if entry == nil {
|
|
sev := ""
|
|
if len(res.Matches) > 0 {
|
|
sev = res.Matches[0].Severity
|
|
}
|
|
entry = &unsafePatternSummary{Detector: name, Severity: sev}
|
|
summary[name] = entry
|
|
}
|
|
|
|
for _, m := range res.Matches {
|
|
if entry.Severity == "" {
|
|
entry.Severity = m.Severity
|
|
}
|
|
if len(languageFilter) > 0 {
|
|
if _, ok := languageFilter[strings.ToLower(m.Language)]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
if len(severityFilter) > 0 {
|
|
if _, ok := severityFilter[strings.ToLower(m.Severity)]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
rows = append(rows, unsafePatternRow{
|
|
Detector: m.Detector,
|
|
Severity: m.Severity,
|
|
Language: m.Language,
|
|
File: m.File,
|
|
Line: m.Line,
|
|
Symbol: m.SymbolID,
|
|
Text: m.Text,
|
|
})
|
|
entry.Count++
|
|
}
|
|
}
|
|
|
|
// Stable order: severity-rank (error > warning > info), then
|
|
// detector, then file, then line. Severity-first keeps the
|
|
// most impactful rows at the top when the agent truncates by
|
|
// `limit`.
|
|
sort.Slice(rows, func(i, j int) bool {
|
|
ri, rj := severityRank(rows[i].Severity), severityRank(rows[j].Severity)
|
|
if ri != rj {
|
|
return ri > rj
|
|
}
|
|
if rows[i].Detector != rows[j].Detector {
|
|
return rows[i].Detector < rows[j].Detector
|
|
}
|
|
if rows[i].File != rows[j].File {
|
|
return rows[i].File < rows[j].File
|
|
}
|
|
return rows[i].Line < rows[j].Line
|
|
})
|
|
|
|
totalRows := len(rows)
|
|
truncated := false
|
|
if limit > 0 && len(rows) > limit {
|
|
rows = rows[:limit]
|
|
truncated = true
|
|
}
|
|
|
|
summaries := make([]unsafePatternSummary, 0, len(summary))
|
|
for _, name := range astquery.UnsafePatternDetectors {
|
|
if len(detectorFilter) > 0 {
|
|
if _, ok := detectorFilter[name]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
entry := summary[name]
|
|
if entry == nil {
|
|
continue
|
|
}
|
|
summaries = append(summaries, *entry)
|
|
}
|
|
sort.Slice(summaries, func(i, j int) bool {
|
|
ri, rj := severityRank(summaries[i].Severity), severityRank(summaries[j].Severity)
|
|
if ri != rj {
|
|
return ri > rj
|
|
}
|
|
if summaries[i].Count != summaries[j].Count {
|
|
return summaries[i].Count > summaries[j].Count
|
|
}
|
|
return summaries[i].Detector < summaries[j].Detector
|
|
})
|
|
|
|
if s.isGCX(ctx, req) {
|
|
items := make([]unsafePatternItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, unsafePatternItem(r))
|
|
}
|
|
return s.gcxResponseWithBudget(req)(encodeAnalyze("unsafe_patterns", items))
|
|
}
|
|
|
|
if isCompact(req) {
|
|
var b strings.Builder
|
|
for _, r := range rows {
|
|
sym := r.Symbol
|
|
if sym == "" {
|
|
sym = "-"
|
|
}
|
|
fmt.Fprintf(&b, "%-7s %-26s %s:%d %s\n",
|
|
r.Severity, r.Detector, r.File, r.Line, sym)
|
|
}
|
|
if len(rows) == 0 {
|
|
b.WriteString("no unsafe patterns\n")
|
|
}
|
|
return mcp.NewToolResultText(b.String()), nil
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"matches": rows,
|
|
"summary": summaries,
|
|
"total": totalRows,
|
|
"truncated": truncated,
|
|
}
|
|
if truncated {
|
|
resp["limit"] = limit
|
|
}
|
|
if len(errsAcc) > 0 {
|
|
resp["errors"] = errsAcc
|
|
}
|
|
return s.respondJSONOrTOON(ctx, req, resp)
|
|
}
|
|
|
|
// parseCSVSet splits "rust,python" into {rust:{}, python:{}}.
|
|
// Empty input returns nil so callers can `len(m) == 0` to short-
|
|
// circuit the filter.
|
|
func parseCSVSet(in string) map[string]struct{} {
|
|
in = strings.TrimSpace(in)
|
|
if in == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(in, ",")
|
|
out := make(map[string]struct{}, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(strings.ToLower(p))
|
|
if p == "" {
|
|
continue
|
|
}
|
|
out[p] = struct{}{}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// severityRank maps a severity label to a sort key so "error" beats
|
|
// "warning" beats "info" without a fragile string comparison.
|
|
func severityRank(sev string) int {
|
|
switch strings.ToLower(sev) {
|
|
case "error":
|
|
return 3
|
|
case "warning":
|
|
return 2
|
|
case "info":
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|