Skip to content

Commit 13df766

Browse files
committed
feat(security): Spec 076 US3 — detect-engine eval corpus + CI recall/FP gate (T017-T019)
Make detector reliability a blocking CI number for the offline Spec-076 detect.Engine. T017 — new labeled corpus specs/065-evaluation-foundation/datasets/ detect_corpus_v1.json (32 self-authored entries) carrying the full ToolView fields the structural checks need (server, tool name/description/schema, cross-server peers). Categories map to detect checks: unicode_smuggling, decoded_payload, shadowing (US1, gated today) plus capability_mismatch (US2, reported but not yet gated) and attack-resembling hard-negatives. Validated by detect_corpus_test.go (coherent labels, redistributable provenance, per-category coverage). README documents the file + counts. T018 — `scan-eval --gate --min-recall --max-fp` runs detect.Engine over the corpus, prints per-category recall/precision/FP/F1 JSON, and exits non-zero on a breach. A category is only enforced when its check is registered, so future checks (capability.mismatch) begin gating automatically with no corpus change. T019 — blocking step in the eval.yml security-d2 job: `scan-eval --gate --min-recall 0.90 --max-fp 0.05` (pure Go, offline, runs first so a detector regression fails fast). TDD: gate_test.go (incl. a committed-corpus regression anchor) written first. Committed corpus passes at recall 1.0 (16/16 gated), FP 0/14. Related #MCP-3579
1 parent 54d10a7 commit 13df766

7 files changed

Lines changed: 1214 additions & 0 deletions

File tree

.github/workflows/eval.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,17 @@ jobs:
5858
go-version: "1.25"
5959
cache: true
6060

61+
# Spec 076 / US3 (FR-013, SC-006): pure-Go, offline regression gate over the
62+
# labeled detect corpus. It runs the production detect.Engine and fails the
63+
# build if malicious recall drops below 0.90 or the hard-negative
64+
# false-positive rate climbs above 0.05. No mcp-eval/Python needed — runs
65+
# first so a detector regression fails fast.
66+
- name: Run Spec-076 detect-engine gate (offline, blocking)
67+
run: |
68+
go run ./cmd/scan-eval \
69+
--corpus specs/065-evaluation-foundation/datasets/detect_corpus_v1.json \
70+
--gate --min-recall 0.90 --max-fp 0.05
71+
6172
- name: Checkout mcp-eval (public, pinned)
6273
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
6374
with:

cmd/scan-eval/gate.go

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
"sort"
9+
10+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/security/detect"
11+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/security/detect/checks"
12+
)
13+
14+
// exitGateBreach is returned when --gate fails its recall/FP thresholds. It is
15+
// distinct from config (4) / write (1) so CI can tell a real regression from a
16+
// tooling error. Any non-zero value fails the CI step (FR-013, SC-006).
17+
const exitGateBreach = 6
18+
19+
// gateTool is the minimal projection of a tool the detect engine needs.
20+
type gateTool struct {
21+
Name string `json:"name"`
22+
Description string `json:"description"`
23+
InputSchema json.RawMessage `json:"input_schema,omitempty"`
24+
OutputSchema json.RawMessage `json:"output_schema,omitempty"`
25+
}
26+
27+
// gatePeer is another server's tool supplied as cross-server context so the
28+
// shadowing check can fire (it only emits when a collision/reference points at a
29+
// DIFFERENT server). Non-shadowing entries leave Peers empty.
30+
type gatePeer struct {
31+
Server string `json:"server"`
32+
Tool gateTool `json:"tool"`
33+
}
34+
35+
// gateEntry is one labeled sample: a tool, its owning server, optional peers,
36+
// the ground-truth label/category, and redistributable provenance.
37+
type gateEntry struct {
38+
ID string `json:"id"`
39+
Label string `json:"label"` // "malicious" | "benign"
40+
Category string `json:"category"` // detect taxonomy or benign|hard_negative
41+
Server string `json:"server"`
42+
Tool gateTool `json:"tool"`
43+
Peers []gatePeer `json:"peers,omitempty"`
44+
Provenance struct {
45+
Source string `json:"source"`
46+
License string `json:"license"`
47+
} `json:"provenance"`
48+
}
49+
50+
// gateCorpus is the Spec-076 detect-engine labeled evaluation corpus.
51+
type gateCorpus struct {
52+
Version string `json:"version"`
53+
Description string `json:"description"`
54+
Entries []gateEntry `json:"entries"`
55+
}
56+
57+
// categoryCheck maps each malicious category to the detect Check ID expected to
58+
// catch it. A category is only enforced by the gate when its check is actually
59+
// registered (see gateChecks) — so categories whose checks land in a later user
60+
// story are measured and reported but never fail the build prematurely. Add the
61+
// mapping when a new check is registered so the gate begins enforcing it.
62+
var categoryCheck = map[string]string{
63+
"unicode_smuggling": "unicode.hidden",
64+
"decoded_payload": "payload.decoded",
65+
"shadowing": "shadowing.cross_server",
66+
"capability_mismatch": "capability.mismatch", // US2 (T016) — not yet registered
67+
}
68+
69+
// gateChecks is the canonical set of detect checks the gate runs. It MUST mirror
70+
// the checks registered in the live scanner (internal/security/scanner/
71+
// inprocess.go); when a soft check (US2) or any new check is registered there,
72+
// add it here too so the gate measures the same detector the product ships.
73+
func gateChecks() []detect.Check {
74+
return []detect.Check{
75+
&checks.UnicodeHidden{},
76+
&checks.Shadowing{},
77+
&checks.PayloadDecoded{},
78+
}
79+
}
80+
81+
// categoryMetric is one category's per-run scorecard.
82+
type categoryMetric struct {
83+
Category string `json:"category"`
84+
Gated bool `json:"gated"` // is this category's check registered?
85+
Malicious int `json:"malicious"` // malicious samples in this category
86+
Detected int `json:"detected"` // malicious samples the engine flagged
87+
Recall float64 `json:"recall"`
88+
}
89+
90+
// gateMetrics is the full metrics report emitted for the CI log.
91+
type gateMetrics struct {
92+
Corpus string `json:"corpus_version"`
93+
Checks []string `json:"checks"`
94+
Categories []categoryMetric `json:"categories"`
95+
GatedMalicious int `json:"gated_malicious"`
96+
GatedDetected int `json:"gated_detected"`
97+
OverallRecall float64 `json:"overall_recall"`
98+
BenignTotal int `json:"benign_total"`
99+
FalsePositives int `json:"false_positives"`
100+
FPRate float64 `json:"fp_rate"`
101+
Precision float64 `json:"precision"`
102+
F1 float64 `json:"f1"`
103+
}
104+
105+
// evaluateGateCorpus runs the detect engine over every entry and tallies recall
106+
// (over categories whose checks are registered), false-positive rate over all
107+
// benign samples, precision, and F1. Each entry is scanned in a RegistryView of
108+
// its own tool plus its declared peers, so shadowing fires deterministically and
109+
// entries never cross-contaminate one another.
110+
func evaluateGateCorpus(c *gateCorpus, checkList []detect.Check) gateMetrics {
111+
engine := detect.NewEngine(detect.Options{Checks: checkList})
112+
113+
registered := make(map[string]struct{}, len(checkList))
114+
for _, ch := range checkList {
115+
registered[ch.ID()] = struct{}{}
116+
}
117+
gatedCategory := func(cat string) bool {
118+
id, ok := categoryCheck[cat]
119+
if !ok {
120+
return false
121+
}
122+
_, reg := registered[id]
123+
return reg
124+
}
125+
126+
type catTally struct {
127+
gated bool
128+
malicious, flagged int
129+
}
130+
cats := map[string]*catTally{}
131+
order := []string{}
132+
133+
var gatedMal, gatedDet, benignTotal, falsePos, truePos int
134+
135+
for i := range c.Entries {
136+
e := c.Entries[i]
137+
flagged := scanEntryFlagged(engine, e)
138+
139+
switch e.Label {
140+
case "malicious":
141+
ct := cats[e.Category]
142+
if ct == nil {
143+
ct = &catTally{gated: gatedCategory(e.Category)}
144+
cats[e.Category] = ct
145+
order = append(order, e.Category)
146+
}
147+
ct.malicious++
148+
if flagged {
149+
ct.flagged++
150+
}
151+
if ct.gated {
152+
gatedMal++
153+
if flagged {
154+
gatedDet++
155+
truePos++
156+
}
157+
}
158+
default: // benign / hard_negative
159+
benignTotal++
160+
if flagged {
161+
falsePos++
162+
}
163+
}
164+
}
165+
166+
m := gateMetrics{
167+
Corpus: c.Version,
168+
Checks: sortedCheckIDs(checkList),
169+
GatedMalicious: gatedMal,
170+
GatedDetected: gatedDet,
171+
BenignTotal: benignTotal,
172+
FalsePositives: falsePos,
173+
}
174+
for _, cat := range order {
175+
ct := cats[cat]
176+
m.Categories = append(m.Categories, categoryMetric{
177+
Category: cat,
178+
Gated: ct.gated,
179+
Malicious: ct.malicious,
180+
Detected: ct.flagged,
181+
Recall: ratio(ct.flagged, ct.malicious),
182+
})
183+
}
184+
m.OverallRecall = ratio(gatedDet, gatedMal)
185+
m.FPRate = ratio(falsePos, benignTotal)
186+
m.Precision = ratio(truePos, truePos+falsePos)
187+
if m.Precision+m.OverallRecall > 0 {
188+
m.F1 = 2 * m.Precision * m.OverallRecall / (m.Precision + m.OverallRecall)
189+
}
190+
return m
191+
}
192+
193+
// scanEntryFlagged builds the entry's RegistryView (its tool + peers), scans it,
194+
// and reports whether the engine produced any finding for the entry's own tool.
195+
func scanEntryFlagged(engine *detect.Engine, e gateEntry) bool {
196+
views := []detect.ToolView{toGateView(e.Server, e.Tool)}
197+
for _, p := range e.Peers {
198+
views = append(views, toGateView(p.Server, p.Tool))
199+
}
200+
res := engine.Scan(detect.NewRegistryView(views))
201+
want := e.Server + ":" + e.Tool.Name
202+
for _, f := range res.Findings {
203+
if f.Location == want {
204+
return true
205+
}
206+
}
207+
return false
208+
}
209+
210+
func toGateView(server string, t gateTool) detect.ToolView {
211+
return detect.ToolView{
212+
Server: server,
213+
Name: t.Name,
214+
Description: t.Description,
215+
InputSchema: t.InputSchema,
216+
OutputSchema: t.OutputSchema,
217+
}
218+
}
219+
220+
// decide applies the gate thresholds. It returns ok=false plus a human-readable
221+
// reason per breached metric.
222+
func (m gateMetrics) decide(minRecall, maxFP float64) (ok bool, reasons []string) {
223+
if m.OverallRecall < minRecall {
224+
reasons = append(reasons, fmt.Sprintf("recall %.4f < min-recall %.4f", m.OverallRecall, minRecall))
225+
}
226+
if m.FPRate > maxFP {
227+
reasons = append(reasons, fmt.Sprintf("false-positive rate %.4f > max-fp %.4f", m.FPRate, maxFP))
228+
}
229+
return len(reasons) == 0, reasons
230+
}
231+
232+
// runGate evaluates the corpus, prints the metrics JSON, and returns the process
233+
// exit code: exitOK on pass, exitGateBreach on a recall/FP breach.
234+
func runGate(c *gateCorpus, minRecall, maxFP float64, stdout, stderr io.Writer) int {
235+
m := evaluateGateCorpus(c, gateChecks())
236+
237+
out, err := json.MarshalIndent(m, "", " ")
238+
if err != nil {
239+
fmt.Fprintf(stderr, "error: marshaling gate metrics: %v\n", err)
240+
return exitWriteError
241+
}
242+
fmt.Fprintln(stdout, string(out))
243+
244+
if m.GatedMalicious == 0 {
245+
fmt.Fprintln(stderr, "error: no malicious samples in a gated category — the gate would be vacuous")
246+
return exitConfigError
247+
}
248+
249+
ok, reasons := m.decide(minRecall, maxFP)
250+
if !ok {
251+
for _, r := range reasons {
252+
fmt.Fprintf(stderr, "GATE FAILED: %s\n", r)
253+
}
254+
return exitGateBreach
255+
}
256+
fmt.Fprintf(stderr, "GATE PASSED: recall=%.4f (>=%.4f), fp=%.4f (<=%.4f)\n", m.OverallRecall, minRecall, m.FPRate, maxFP)
257+
return exitOK
258+
}
259+
260+
// loadGateCorpus reads and decodes the detect-engine eval corpus.
261+
func loadGateCorpus(path string) (*gateCorpus, error) {
262+
data, err := os.ReadFile(path)
263+
if err != nil {
264+
return nil, fmt.Errorf("reading gate corpus %q: %w", path, err)
265+
}
266+
var c gateCorpus
267+
if err := json.Unmarshal(data, &c); err != nil {
268+
return nil, fmt.Errorf("parsing gate corpus %q: %w", path, err)
269+
}
270+
if len(c.Entries) == 0 {
271+
return nil, fmt.Errorf("gate corpus %q has no entries", path)
272+
}
273+
return &c, nil
274+
}
275+
276+
func sortedCheckIDs(checkList []detect.Check) []string {
277+
ids := make([]string, 0, len(checkList))
278+
for _, ch := range checkList {
279+
ids = append(ids, ch.ID())
280+
}
281+
sort.Strings(ids)
282+
return ids
283+
}
284+
285+
// ratio is n/d with a 0 guard (an empty denominator yields 0, not NaN).
286+
func ratio(n, d int) float64 {
287+
if d == 0 {
288+
return 0
289+
}
290+
return float64(n) / float64(d)
291+
}

0 commit comments

Comments
 (0)